Testing
Overview
Frankie has a built-in test runner — no external libraries, no configuration.
Write assertions in any .fk file and run it with frankiec test.
Every assertion prints a ✓ or ✗ in real time, and the runner exits with code 1 if any test fails, making it CI-friendly.
Running Tests
frankiec test # runs test.fk in the current directory
frankiec test mytest.fk # runs a specific file
frankiec test --filter math # v1.17 — only test groups whose name contains "math"
frankiec test --tag slow # v1.17 — only test groups tagged "slow"
Output looks like:
╔══ Frankie Test Runner ════════════════════════════════
║ test.fk
╠═══════════════════════════════════════════════════════
✓ addition works
✓ mean of [1,2,3] is 2.0
✗ expected 4, got 5
✓ FileNotFoundError raised on missing file
╠═══════════════════════════════════════════════════════
║ 3/4 passed, 1 failed (12.3ms)
╚═══════════════════════════════════════════════════════
Assertions
All assertion functions are available without any import inside a test file.
assert_eq(actual, expected, msg)
Equality
The workhorse. Passes if actual == expected.
assert_eq(2 + 2, 4, "addition")
assert_eq(mean([1,2,3]), 2.0, "mean of 1..3")
assert_eq("hello".upcase, "HELLO", "upcase")
assert_eq([1,2,3].length, 3, "vector length")
assert_neq(actual, expected, msg)
Inequality
Passes if actual != expected.
assert_neq(rand_int(1, 100), 0, "rand_int never returns 0")
assert_neq("hello", "", "non-empty string")
assert_true(value, msg)
Truthy
Passes if value is truthy (anything except false and nil).
assert_true(file_exists("/tmp"), "tmp dir exists")
assert_true([1,2,3].include?(2), "include? works")
assert_true("hello".start_with?("he"), "start_with?")
assert_true(5 > 3, "comparison")
assert_nil(value, msg)
Nil check
Passes if value is nil.
assert_nil(find_user(999), "missing user returns nil")
assert_nil([1,2,3].find do |x| x > 9 end, "find with no match")
assert_nil({a: 1}["missing"], "missing hash key is nil")
assert_match(value, pattern, msg)
Regex match
Passes if pattern matches anywhere in value.
assert_match("alice@example.com", "\\w+@\\w+\\.\\w+", "valid email")
assert_match("2024-03-15", "^\\d{4}-\\d{2}-\\d{2}$", "ISO date")
assert_match("Error: timeout", "timeout", "error message contains timeout")
assert_not_nil(value, msg)
Non-nil check (introduced in v1.15)
Passes if value is anything other than nil.
assert_not_nil(find_user(1), "user exists")
assert_not_nil(result["id"], "result has an id")
assert_not_nil("", "empty string is not nil")
assert_in(value, collection, msg)
Membership check (introduced in v1.15)
Passes if collection includes value.
assert_in("admin", roles, "user has admin role")
assert_in(42, valid_ids, "id is valid")
assert_in("fr", ["en", "fr", "de"], "French supported")
assert_approx_eq(actual, expected, delta, msg)
Approximate float equality (introduced in v1.13.1)
Passes if |actual - expected| <= delta. Default delta is 0.001.
assert_approx_eq(3.14159, 3.14, 0.01, "pi rounded")
assert_approx_eq(mean([1,2,3]), 2.0, 0.001, "mean")
assert_approx_eq(0.1 + 0.2, 0.3, 0.0001, "float addition")
assert_raises(fn, msg)
Any error raised
Passes if calling fn raises any error.
assert_raises(->() { x = 1 // 0 }, "division by zero raises")
assert_raises(->() { file_read("/no/such/file.txt") }, "missing file raises")
assert_raises_typed(fn, type, msg)
Specific error type
Passes if calling fn raises exactly the specified error type.
assert_raises_typed(->() { x = 1 // 0 }, "ZeroDivisionError", "division raises ZeroDivisionError")
assert_raises_typed(->() { file_read("/no/such/file.txt") }, "FileNotFoundError", "missing file raises FileNotFoundError")
assert_raises_typed(->() { "hello" + 42 }, "TypeError", "string + int raises TypeError")
Supported type strings: "ZeroDivisionError", "FileNotFoundError", "TypeError", "ValueError", "IndexError", "KeyError", "NameError", "AttributeError", "TimeoutError", "RuntimeError", "Exception" — plus any user-defined error type declared with error (v1.17).
Test Groups — test (v1.17)
Group related assertions into named, filterable blocks with test:
test "math basics" do
assert_eq(1 + 1, 2, "addition works")
assert_eq(abs(-5), 5, "abs negative")
end
test "slow integration", tags: ["slow", "network"] do
assert_true(http_get("http://localhost:3000/health")["ok"])
end
Run a subset from the command line:
frankiec test # run everything
frankiec test --filter math # groups whose name contains "math"
frankiec test --tag slow # groups tagged "slow"
Skipped groups are reported in the summary. test is a contextual keyword — a variable named test keeps working.
Stubbing — stub / unstub (v1.17)
Swap any global function during a test — including stdlib functions like shell and http_get — to test without side effects:
test "stubbing" do
stub("shell", ->(cmd) { {ok: true, stdout: "fake", exit_code: 0} })
assert_eq(shell("anything")["stdout"], "fake")
unstub("shell") # restore the real shell
end
unstub(name) restores one function; unstub() with no arguments restores everything.
Structuring Tests
Use test groups (above), or simply place assertions anywhere in the file — they are collected and reported together. For readability, group related assertions with comments:
# test.fk
# ── Math ──────────────────────────────────────────────────────
assert_eq(abs(-5), 5, "abs negative")
assert_eq(abs(5), 5, "abs positive")
assert_eq(round(3.14159,2), 3.14, "round to 2dp")
assert_eq(clamp(15, 0, 10), 10, "clamp above max")
assert_eq(clamp(-5, 0, 10), 0, "clamp below min")
# ── String ────────────────────────────────────────────────────
assert_eq("hello".upcase, "HELLO", "upcase")
assert_eq(" hi ".strip, "hi", "strip")
assert_eq("a,b,c".split(",").length, 3, "split count")
assert_match("frank123", "\\d+", "contains digits")
# ── Vector ────────────────────────────────────────────────────
v = [3, 1, 4, 1, 5, 9, 2, 6]
assert_eq(v.length, 8, "length")
assert_eq(min(v), 1, "min")
assert_eq(max(v), 9, "max")
assert_eq(v.sort[0], 1, "sort first")
assert_eq(v.uniq.length, 7, "uniq removes one duplicate")
# ── Error handling ────────────────────────────────────────────
assert_raises_typed(->() { 1 // 0 }, "ZeroDivisionError", "division by zero")
assert_raises_typed(->() { file_read("/no/such/file.txt") }, "FileNotFoundError", "missing file")
Testing Your Own Functions
Require your lib files the same way main.fk does:
# test.fk
require "lib/math_utils"
require "lib/string_utils"
# ── math_utils ────────────────────────────────────────────────
assert_eq(circle_area(1), round(3.14159, 2), "unit circle")
assert_eq(circle_area(0), 0.0, "zero radius")
assert_eq(factorial(5), 120, "factorial 5")
assert_eq(factorial(0), 1, "factorial 0")
# ── string_utils ──────────────────────────────────────────────
assert_eq(slugify("Hello World"), "hello-world", "slugify basic")
assert_eq(slugify(" spaces "), "spaces", "slugify trims")
assert_eq(truncate("hello", 3), "hel...", "truncate")
assert_nil(truncate("hi", 10), "no truncate needed returns nil")
Testing File I/O
Use a temporary path and clean up in every test:
path = "/tmp/fk_test_#{rand_int(1000, 9999)}.txt"
# Write and read back
file_write(path, "hello frankie\n")
assert_true(file_exists(path), "file was created")
assert_eq(file_read(path).strip, "hello frankie", "content correct")
# Append
file_append(path, "second line\n")
assert_eq(file_lines(path).length, 2, "two lines after append")
# Delete
file_delete(path)
assert_true(not file_exists(path), "file deleted")
# FileNotFoundError
assert_raises_typed(->() { file_read("/no/such/file.txt") }, "FileNotFoundError", "missing file raises correctly")
Watch Mode
Run tests automatically whenever you save:
frankiec watch test.fk --test
Every time you save test.fk (or any file, since watch triggers on the target file), the test suite re-runs.
Combine with require to automatically pick up changes in your lib files too.
Quick Reference
| Assertion | Passes when |
|---|---|
assert_eq(actual, expected, msg) |
actual == expected |
assert_neq(actual, expected, msg) |
actual != expected |
assert_true(value, msg) |
value is truthy |
assert_nil(value, msg) |
value is nil |
assert_not_nil(value, msg) |
value is not nil |
assert_in(value, collection, msg) |
collection includes value |
assert_approx_eq(a, b, delta, msg) |
|a - b| <= delta (default delta 0.001) |
assert_match(value, pattern, msg) |
pattern matches anywhere in value |
assert_raises(fn, msg) |
calling fn raises any error |
assert_raises_typed(fn, type, msg) |
calling fn raises that specific type |
| Helper (v1.17) | Purpose |
|---|---|
test "name", tags: [...] do ... end |
Named, filterable test group |
stub(name, fn) |
Replace a global function for the test |
unstub(name) / unstub() |
Restore one / all stubbed functions |