A BDD-style test framework for Aether, featuring describe, it, before_each, and after_each syntax with trailing blocks and closures.
Inspired by Cuppa.
# Clone the repository
git clone https://github.com/aether-lang-org/aeocha.git
cd aeocha
# Use the bootstrap script to set up the Aether toolchain
./bootstrap_aether.shA test framework tests your code. So a real example is two files: the code under test, and a test that imports and calls it.
calc.ae — the code under test:
exports ( add, abs )
add(a: int, b: int) -> int { return a + b }
abs(n: int) -> int {
if n < 0 { return 0 - n } else { return n }
}
calc_test.ae — the test, which imports calc and asserts on what its
functions return:
import aeocha
import calc
main() {
fw = aeocha.init() // start a test run
aeocha.describe(fw, "calc.add") { // group of related tests
aeocha.it("adds two positives") callback {
aeocha.assert_eq(calc.add(2, 3), 5, "2 + 3")
}
aeocha.it("adds with a negative") callback {
aeocha.assert_eq(calc.add(10, -4), 6, "10 + (-4)")
}
}
aeocha.describe(fw, "calc.abs") {
aeocha.it("leaves positives unchanged") callback {
aeocha.assert_eq(calc.abs(7), 7, "abs(7)")
}
aeocha.it("flips negatives") callback {
aeocha.assert_eq(calc.abs(-7), 7, "abs(-7)")
}
}
aeocha.run_summary(fw) // print results, exit 0/1
}
Run it. ae resolves a bare import aeocha from the current directory, so
running from the directory that holds aeocha.ae just works (calc.ae is
found next to the test):
ae run calc_test.aeTo run from anywhere else, point AETHER_LIB_DIR at the directory holding
aeocha.ae (a PATH-style list of module search dirs, same shape as --lib):
AETHER_LIB_DIR=/path/to/aeocha ae run calc_test.aecalc.add
✓ adds two positives
✓ adds with a negative
calc.abs
✓ leaves positives unchanged
✓ flips negatives
4 passing
Exit code is 0 when everything passes, 1 if anything fails.
import calc— the code under test is an ordinary module; the test imports it and calls its functions, asserting on the results. (The assertions checkcalc.add(...)/calc.abs(...), not bare literals — you're testing your code, not arithmetic.)fw = aeocha.init()— creates the test-run context. You capture it once and pass it only to the two structural calls: the top-leveldescribeandrun_summary. (You don't pass it to assertions — see below.)aeocha.describe(fw, "calc.add") { ... }— a group. The{ ... }is a trailing block: everything inside runs in the group's context. Nesteddescribe/it/hooks omit thefwargument — Aether injects the context automatically.aeocha.it("...") callback { ... }— one test case. Thecallback { ... }body holds your assertions.aeocha.assert_eq(actual, expected, msg)— an assertion. Nofw— matchers report against the runinit()started. A failed assertion records the failure and keeps going (so you see every problem in a run, not just the first), and turns the surroundingitred.aeocha.run_summary(fw)— prints the pass/fail summary and exits0/1.
before_each / after_each run around every it in their group (omit fw):
aeocha.describe(fw, "Counter") {
aeocha.before_each() callback { reset() }
aeocha.it("starts at zero") callback {
aeocha.assert_eq(count(), 0, "fresh counter")
}
}
import aeocha (*) brings every name into scope so you can drop the aeocha.
prefix (init(), describe(fw, ...), assert_eq(...)). The qualified form
(import aeocha) is used throughout this README to make it obvious what comes
from the framework.
Requires
aev0.331+. If you editaeocha.aeitself, runrm -rf ~/.aether/cachefirst — the compiler cache doesn't notice changes in imported modules and will silently run the old version.
- BDD Syntax: Clean, nested
describeanditblocks. - Hooks: Supports
before_eachandafter_eachfor setup/teardown. - Assertions: Robust set of integer, string, and pointer matchers.
- Integration Testing: Specialized matchers for process output, exit codes, and HTTP responses.
- Tooling Ready: Compatible with Aether's
aebtest runners.
| Function | Purpose |
|---|---|
init() |
Initialize framework context. |
describe(fw, name) { … } |
Group tests. |
it(name) callback { … } |
Define a test case. |
it_within(name, budget) callback { … } |
A test case that also fails if its body runs longer than budget (a Duration, e.g. 50ms). |
before_each() callback { … } |
Setup hook. |
after_each() callback { … } |
Teardown hook. |
assert_eq(a, b, msg) |
Equality check (flat style). No fw — matchers report against the framework init() set. Same for assert_true/false, assert_str_eq, assert_not_eq, assert_gt, assert_contains, assert_null, assert_not_null. |
expect_int(x).to_equal(5).to_be_gt(0) |
Fluent assertion chain (subject-first). Int matchers: to_equal, to_be_gt, to_be_lt, to_be_truthy, to_be_falsy, not_(). |
expect_str(s).to_contain("x").to_start_with("y") |
Fluent string chain: to_equal_str, to_contain, to_start_with. |
expect_int(x).satisfies(pred, msg) |
Fluent escape hatch — run an arbitrary fn(value)->1/0 predicate mid-chain (satisfies_str for strings). |
assert_str_eq_diff(actual, expected, msg) |
Exact string equality; on mismatch shows both values with a caret under the first differing byte (Jest-style). |
expect_list_size(xs, n, msg) |
A std.list of strings has exactly n items. Also expect_list_empty(xs, msg), expect_list_has_str(xs, needle, msg). |
expect_list_contains_all(xs, needles, msg) |
Every string in needles (a std.list) appears in xs, order-independent. |
expect_list_every(xs, pred, msg) |
Every element satisfies pred (fn(string)->1/0); empty list passes vacuously. |
expect_stdout_matches_regex(out, pattern, msg) |
A stdout line matches a PCRE2 regex (per-line, unanchored). |
expect_stderr_contains(err, needle, msg) |
Captured child stderr (from os.run_full) contains needle. |
expect_stderr_empty(err, msg) |
Child wrote nothing to stderr. |
expect_elapsed_under(elapsed_ns, budget, msg) |
A caller-measured monotonic-ns span is under a Duration budget (e.g. 50ms). |
within(5s) / without(5s) |
Floating modifier: make the next GET matcher (or eventually) retry until it passes (within) or stops passing (without), up to the budget, then auto-revert. within_poll/without_poll set the poll interval. |
eventually(pred, msg) |
Poll a zero-arg predicate (fn returning 1/0) under a preceding within/without budget until it holds (or stops holding); fail if not reached. No budget → one evaluation. |
expect_http_get_status(url, status, msg) |
GET url; assert transport ok + status. One line, no resp lifecycle. Honours a preceding within/without. |
expect_http_get_body_eq(url, want, msg) |
GET url; assert 200 + body exactly want. |
expect_http_get_body_contains(url, needle, msg) |
GET url; assert 200 + body contains needle. |
run_summary(fw) |
Report and exit. (Structural — still takes fw, as does top-level describe(fw, name).) |
A matcher in Aeocha is just a function that calls aeocha.fail(msg) when the
check doesn't hold. There's no base class, no registration, and no framework
handle to thread — fail reports against the framework init() set. Aeocha's
own assert_*/expect_* matchers are written exactly this way, so your matchers
are first-class by construction.
// Your own matcher — define it anywhere, call it in any it().
expect_even(n: int, msg: string) {
if n % 2 != 0 {
aeocha.fail("${msg} — ${n} is not even")
}
}
aeocha.it("counts are even") callback {
expect_even(items_processed(), "processed an even count")
}
Because failures accumulate (a failed matcher records and continues rather than
aborting), several expect_/assert_ calls in one it() all report — you see
every problem in a run, not just the first (soft-assert semantics).
For a one-off check without naming a whole matcher, use the fluent escape hatch:
is_prime(n: int) -> int { ... } // returns 1 / 0
aeocha.it("is a prime over 10") callback {
aeocha.expect_int(candidate).to_be_gt(10).satisfies(is_prime, "is prime")
}
You can also extend the fluent chain itself with your own .to_* method, from
your own module. The subject types IntSubject/StrSubject are exported, and a
matcher is just a free function taking the subject first (it chains via UFCS):
import aeocha
// A custom fluent matcher — note the bare type name `IntSubject`.
to_be_even(s: IntSubject, msg: string) -> IntSubject {
if s.value % 2 != 0 { aeocha.fail("${msg} — ${s.value} is not even") }
return s // return the subject so the chain continues
}
aeocha.it("count is a positive even") callback {
aeocha.expect_int(count()).to_be_gt(0).to_be_even("even count")
}
Either spelling of the type works: the bare IntSubject (in scope from
import aeocha) or the qualified aeocha.IntSubject — use the qualified form if
another imported module also exports an IntSubject and you need to disambiguate.
This project is licensed under the MIT License. See the LICENSE file for details.