BDD framework: unit, integration, Gherkin

PatLang's own self-hosted Gherkin-style BDD framework (lib/test.patlang: t_init/check/step/run_feature_tagged), running against the point of sale: unit assertions, event-driven integration checks, and a Gherkin feature whose steps dispatch through apply. The same framework drives PatLang's own self-hosted regression suites (self_hosting/reflect_transpile_selftest.patlang, self_hosting/examples/pos_tests.patlang), each wired into cargo test via a thin Rust cucumber step that just asserts on this framework's own ALL TESTS PASSED output.

PatLang source
# =============================================================================
# Test framework (Stage 1 dialect): unit assertions plus a Gherkin-style
# feature runner. Step definitions are registered in the object store keyed
# by their text; features are plain text dispatched line by line, so the
# same framework covers unit, integration, and behaviour tests.
# =============================================================================

make a function called t_init returns done
  set_var("t_pass", 0)
  set_var("t_fail", 0)
  set_var("t_tagfilter", "")
  set_var("t_pending_tags", "")
  set_var("t_skipping", 0)
  return true
end

make a function called contains_text takes hay, needle returns r
  if needle.length > hay.length then
    return false
  end
  let i = 0
  while i <= hay.length - needle.length do
    if substr(hay, i, needle.length) == needle then
      return true
    end
    let i = i + 1
  end
  return false
end

make a function called check takes label, actual, expected returns ok
  if actual == expected then
    set_var("t_pass", get("__vars", "t_pass") + 1)
    print("  ok: " + label)
    return true
  else
    set_var("t_fail", get("__vars", "t_fail") + 1)
    print("  FAIL: " + label + " (got " + actual + ", want " + expected + ")")
    return false
  end
end

make a function called t_report returns ok
  let p = get("__vars", "t_pass")
  let f = get("__vars", "t_fail")
  print("tests: " + p + " passed, " + f + " failed")
  if f == 0 then
    print("ALL TESTS PASSED")
    return true
  else
    print("TESTS FAILED")
    return false
  end
end

# ---- Gherkin runner ----

# Register a step: step("a fresh till", "st_fresh_till")
make a function called step takes text, fname returns done
  new("Step", text)
  send(text, "set", "fn", fname)
  return true
end

make a function called starts_with takes s, prefix returns r
  if s.length < prefix.length then
    return false
  end
  return substr(s, 0, prefix.length) == prefix
end

make a function called trim_left takes s returns out
  let i = 0
  let scanning = true
  while (i < s.length) and scanning do
    let c = char_code(s, i)
    if (c == 32) or (c == 9) then
      let i = i + 1
    else
      let scanning = false
    end
  end
  return substr(s, i, s.length - i)
end

# Strip a Gherkin keyword; returns the step text or "" if not a step line
make a function called step_text takes line returns out
  if starts_with(line, "Given ") then
    return substr(line, 6, line.length - 6)
  end
  if starts_with(line, "When ") then
    return substr(line, 5, line.length - 5)
  end
  if starts_with(line, "Then ") then
    return substr(line, 5, line.length - 5)
  end
  if starts_with(line, "And ") then
    return substr(line, 4, line.length - 4)
  end
  return ""
end

# Run only scenarios whose preceding @tag line contains `tag` ("" = all)
make a function called run_feature_tagged takes feature, tag returns ok
  set_var("t_tagfilter", tag)
  return run_feature(feature)
end

make a function called run_feature_file takes path returns ok
  return run_feature(read_file(path))
end

make a function called run_feature takes feature returns ok
  let h = str_intern(feature)
  let n = sc_len(h)
  let i = 0
  let line = sb_new()
  while i <= n do
    let c = sc_code(h, i)
    if (c == 10) or (c == -1) then
      let raw = trim_left(sb_str(line))
      let line = sb_new()
      if starts_with(raw, "@") then
        set_var("t_pending_tags", raw)
      end
      if starts_with(raw, "Feature:") then
        print(raw)
      end
      if starts_with(raw, "Scenario:") then
        let filter = get("__vars", "t_tagfilter")
        let tags = get("__vars", "t_pending_tags")
        set_var("t_pending_tags", "")
        if filter then
          if tags then
            if contains_text(tags, filter) then
              set_var("t_skipping", 0)
              print(raw + "  [" + tags + "]")
            else
              set_var("t_skipping", 1)
              print(raw + "  [skipped: needs " + filter + "]")
            end
          else
            set_var("t_skipping", 1)
            print(raw + "  [skipped: needs " + filter + "]")
          end
        else
          set_var("t_skipping", 0)
          print(raw)
        end
      else
        let text = step_text(raw)
        if (text != "") and (get("__vars", "t_skipping") != 1) then
          let fname = get(text, "fn")
          if fname then
            apply(fname)
          else
            set_var("t_fail", get("__vars", "t_fail") + 1)
            print("  FAIL: undefined step: " + text)
          end
        end
      end
      let i = i + 1
    else
      if c == 13 then
        let i = i + 1
      else
        sb_push(line, sc_char(h, i))
        let i = i + 1
      end
    end
  end
  return true
end

# =============================================================================
# Point-of-sale library (Stage 1 dialect): event-driven checkout combining
# events (scan/pay), OO (product catalog in the object store), and logic
# (dairy facts drive a discount rule). Prices are integer pence.
# =============================================================================

make a function called pos_setup returns done
  new("Product", "apple")
  send("apple", "set", "price", 30)
  new("Product", "banana")
  send("banana", "set", "price", 25)
  new("Product", "milk")
  send("milk", "set", "price", 120)
  fact("dairy", "milk", "yes")
  fact("dairy", "cheese", "yes")
  set_var("total", 0)
  set_var("items", 0)
  set_var("receipt", "")
  goal("serve_customer", "till-1")
  return true
end

make a function called pos_scan takes item returns price
  let price = get(item, "price")
  if not price then
    print("  unknown item: " + item)
    return 0
  end
  if query("dairy", item, 0) > 0 then
    # dairy discount: 10% off
    let price = price * 9 / 10
  end
  set_var("total", get("__vars", "total") + price)
  set_var("items", get("__vars", "items") + 1)
  set_var("receipt", get("__vars", "receipt") + "  " + item + "  " + price + "p\n")
  return price
end

make a function called pos_total returns t
  return get("__vars", "total")
end

make a function called pos_pay takes tendered returns change
  let total = get("__vars", "total")
  let change = tendered - total
  print("--- RECEIPT ---")
  print(get("__vars", "receipt") + "  items: " + get("__vars", "items"))
  print("  total: " + total + "p, tendered: " + tendered + "p, change: " + change + "p")
  return change
end

when scan do
  pos_scan(event_data)
end

when pay do
  pos_pay(event_data)
end

# Test suite for the point-of-sale library (concatenate after lib/test.patlang
# and lib/pos.patlang). Shows unit tests, event-driven integration tests, and
# a Gherkin feature exercising the same code.

t_init()

# ---- unit tests ----
print("unit: catalog and pricing")
pos_setup()
check("apple price", get("apple", "price"), 30)
check("milk is dairy", query("dairy", "milk", 0), 1)
check("banana is not dairy", query("dairy", "banana", 0), 0)
check("scan returns discounted dairy price", pos_scan("milk"), 108)
check("scan returns full price", pos_scan("banana"), 25)
check("running total", pos_total(), 133)

# ---- integration test: the same flow through events ----
print("integration: checkout via events")
pos_setup()
emit("scan", "apple")
emit("scan", "apple")
emit("scan", "milk")
check("event-driven total", pos_total(), 168)
check("change from 2 pounds", pos_pay(200), 32)

# ---- Gherkin feature ----

make a function called st_fresh_till returns done
  pos_setup()
  return true
end

make a function called st_scan_two_apples returns done
  emit("scan", "apple")
  emit("scan", "apple")
  return true
end

make a function called st_scan_milk returns done
  emit("scan", "milk")
  return true
end

make a function called st_total_is_168 returns done
  return check("the total is 168p", pos_total(), 168)
end

make a function called st_dairy_discount_applied returns done
  return check("dairy discount applied", get("milk", "price") - 12, 108)
end

step("a fresh till", "st_fresh_till")
step("the customer buys two apples", "st_scan_two_apples")
step("the customer buys a bottle of milk", "st_scan_milk")
step("the total is 168 pence", "st_total_is_168")
step("the dairy discount was applied", "st_dairy_discount_applied")

let feature = "Feature: checkout
@smoke @till
Scenario: apples and discounted milk
Given a fresh till
When the customer buys two apples
And the customer buys a bottle of milk
Then the total is 168 pence
And the dairy discount was applied
@audit
Scenario: nightly stock audit
Given a fresh till
Then the total is 168 pence
"
# run only @smoke scenarios: the @audit scenario (which would fail) is skipped
run_feature_tagged(feature, "@smoke")

t_report()

(not run yet)

Native run on the build machine:

unit: catalog and pricing
  ok: apple price
  ok: milk is dairy
  ok: banana is not dairy
  ok: scan returns discounted dairy price
  ok: scan returns full price
  ok: running total
integration: checkout via events
  ok: event-driven total
--- RECEIPT ---
  apple  30p
  apple  30p
  milk  108p
  items: 3
  total: 168p, tendered: 200p, change: 32p
  ok: change from 2 pounds
Feature: checkout
Scenario: apples and discounted milk  [@smoke @till]
  ok: the total is 168p
  ok: dairy discount applied
Scenario: nightly stock audit  [skipped: needs @smoke]
tests: 10 passed, 0 failed
ALL TESTS PASSED
true