Testing the Compiler Trusts
Testing as You Go in Volume 1 made three promises about this chapter: "the second volume goes into testing in depth", how coverage for publishing is measured "is covered in the second volume", and of test-driven development, "The second volume returns to it". It also left the loop of the guessing game untested, and Dependency Injection and Applications has since put that loop under a test. This chapter is the rest. The universal problem it answers is one you have met in JUnit and pytest: a green suite can be hollow. A test that asserts nothing passes. An assertion in a helper that no test calls never runs, and nothing says so. Coverage is a plugin that someone must remember to configure. The EK9 runner is part of the compiler, and the compiler refuses to call that suite green.1
Three kinds of test, one runner
Every example in this chapter is one project: a small pricing and checkout module with a dev/ folder beside it. The program
under test is complete, and it is the one you have seen the shape of already: a component behind an interface, a class that
injects it, and a few pure functions.
#!ek9 defines module book.testing.shop defines constant PERCENT_BASE <- 100 BULK_QUANTITY <- 10 BULK_PERCENT <- 20 defines class OrderService notifier as Notifier! placeOrder() -> item as String price as Integer percent as Integer notifier.notify(receipt(item, discounted(price, percent))) default operator ? defines function discounted() as pure -> price as Integer percent as Integer <- rtn as Integer: Integer() if percent > PERCENT_BASE throw Exception(`a discount of ${percent} percent is more than the price`) rtn: price - price * percent / PERCENT_BASE bulkPrice() as pure -> quantity as Integer unitPrice as Integer <- rtn as Integer: quantity * unitPrice if quantity >= BULK_QUANTITY rtn: discounted(quantity * unitPrice, BULK_PERCENT) receipt() as pure -> item as String price as Integer <- rtn as String: `${item}: ${price}p` defines component Notifier as abstract notify() as abstract -> message as String ConsoleNotifier is Notifier override notify() -> message as String Stdout().println(message) defines application shopApp register ConsoleNotifier() as Notifier defines program Checkout() with application of shopApp orders <- OrderService() orders.placeOrder("kettle", 2500, 20) orders.placeOrder("mug", 400, 0)
It prints the two receipts:
kettle: 2000p
mug: 400p
A test is a program marked @Test, in a source file under dev/. There is no test framework to add, and no test class to
extend. The runner recognises three kinds, and it treats them as equals.
An assertion test states what must be true. assert is for tests, as require is for production code, and
assertThrows and assertDoesNotThrow cover exceptions. assertThrows hands back the exception it caught, so you can look at
the reason:
@Test MoreThanFullDiscountIsRefused() assertThrows (Exception, discounted(400, 101)) @Test ReasonNamesThePercent() refused <- assertThrows (Exception, discounted(400, 150)) assert refused.reason() == "a discount of 150 percent is more than the price" @Test SensibleDiscountThrowsNothing() assertDoesNotThrow (discounted(400, 50))
A black-box test has no assertion. It prints, and the runner compares everything it printed to a file named
expected_output.txt beside it. Where the program talks to the world, here through Notifier, the test brings its own
stand-in and a test application that registers it: TaggedNotifier is the whole of what a mocking framework would have done,
and it is an ordinary component.
defines module book.testing.shop.checkout references book.testing.shop::Notifier book.testing.shop::OrderService defines component TaggedNotifier is Notifier override notify() -> message as String Stdout().println(`[test] ${message}`) defines application taggedShop register TaggedNotifier() as Notifier defines program @Test CheckoutOutput() with application of taggedShop orders <- OrderService() orders.placeOrder("kettle", 2500, 20) orders.placeOrder("mug", 400, 0)
with expected_output.txt beside it holding:
[test] kettle: 2000p
[test] mug: 400p
A parameterized test takes program arguments, and the runner runs it once per case: a commandline_arg_<case>.txt file
supplies the arguments, one per line, and expected_case_<case>.txt pins that case's output. The table is files, not a data
provider:
defines module book.testing.shop.receipts references book.testing.shop::discounted book.testing.shop::receipt defines program @Test ReceiptForArguments() -> item as String price as String percent as String stdout <- Stdout() stdout.println(receipt(item, discounted(Integer(price), Integer(percent))))
Two cases are sale (kettle, 2500, 20, expecting kettle: 2000p) and free (mug, 400, 100, expecting mug: 0p).
If you know Go's table-driven tests or golden files, these are the same ideas with a file convention instead of a library.
Here is the whole project under ek9 -t -tC:
ℹ️ Found 13 tests:
10 assert (unit tests with assertions)
2 blackbox (output validated against expected_output.txt)
1 parameterized (2 cases total)
Executing 13 tests...
✅ PASS book.testing.shop.checkout::CheckoutOutput [BlackBox] (8ms)
✅ PASS book.testing.shop.production::ProductionCheckout [BlackBox] (8ms)
✅ PASS book.testing.shop.receipts::ReceiptForArguments [Param:free] (6ms)
✅ PASS book.testing.shop.receipts::ReceiptForArguments [Param:sale] (1ms)
✅ PASS book.testing.shop.unit::EveryPartIsSet [Assert] (6ms)
✅ PASS book.testing.shop.unit::FullDiscountIsFree [Assert] (6ms)
✅ PASS book.testing.shop.unit::MoreThanFullDiscountIsRefused [Assert] (6ms)
✅ PASS book.testing.shop.unit::NineItemsPayInFull [Assert] (6ms)
✅ PASS book.testing.shop.unit::NoDiscountKeepsThePrice [Assert] (6ms)
✅ PASS book.testing.shop.unit::ReasonNamesThePercent [Assert] (6ms)
✅ PASS book.testing.shop.unit::ReceiptShowsItemAndPrice [Assert] (6ms)
✅ PASS book.testing.shop.unit::SensibleDiscountThrowsNothing [Assert] (6ms)
✅ PASS book.testing.shop.unit::TenItemsGetTwentyPercentOff [Assert] (6ms)
✅ PASS book.testing.shop.unit::TenPercentOff [Assert] (6ms)
Summary: 14 passed, 0 failed (14 results from 13 programs)
Types: 10 assert, 2 blackbox, 2 parameterized
Duration: 89ms
📊 Coverage Summary
✅ Methods: 90.9% (10/11)
✅ Lines: 88.6% (31/35)
✅ Branches: 100.0% (2/2)
✅ Overall: 88.89% (32/36 probes hit)
📦 By Module:
✅ book.testing.shop: 88.89% (32/36)
The labels [Assert], [BlackBox] and [Param:free] are the three kinds, counted together in one summary. The tests are listed
in alphabetical order, not source order. A test that fails says where and why. Here are two that were written wrongly, one
asserting the wrong total and one expecting an exception that is never thrown:
❌ FAIL book.testing.pricing.failing::NothingThrown [Assert] (3ms)
assertThrows FAILED
Location: ./dev/testFailingAssertions.ek9:15:7
Expression: discounted(2000, 10)
Expected: org.ek9.lang::Exception
Actual: No exception was thrown
❌ FAIL book.testing.pricing.failing::WrongExpectation [Assert] (3ms)
Assertion failed: `discounted(2000, 10) == 1900` at ./dev/testFailingAssertions.ek9:11:7
Summary: 0 passed, 2 failed (2 total)
Types: 2 assert
Duration: 7ms
ek9: tests failed (exit 11)
The exit code is 11 for a failed test, which is what a CI step needs to see.
Test first, and what red looks like
Testing as You Go cited Kent Beck for writing the test before the code. In Java or Python the first red is a failing
assertion, after you have written a stub that returns the wrong thing. In EK9 it can come sooner. Write the bulk-order test
before bulkPrice exists, and the project does not compile:
defines module book.testing.pricing.first references book.testing.pricing::bulkPrice defines program @Test TenItemsGetTwentyPercentOff() assert bulkPrice(10, 200) == 1600
EK9 : Error : E03030: 'bulkPrice' on line 5 position 26: 'book.testing.pricing::bulkPrice' reference does not resolve
See: ek9 -h E03030
That is the red step: the test names a function, and the compiler says there is no such thing. Write bulkPrice (it is in the
program above), run ek9 -t, and the same test is green. The loop is short because -t compiles the program and its dev/
files together, so a test that does not yet have something to test cannot be mistaken for one that passes.
What the compiler refuses to call a test
The three rules that follow are what make the word "green" mean something. Each is checked by the compiler, before any test runs, and each has a code. The first is a test that cannot fail because it checks nothing:
@Test TenPercentOff() stdout <- Stdout() stdout.println($discounted(2000, 10))
EK9 : Error : E81007: 'TenPercentOff' on line 10 position 4: 'book.testing.pricing.empty::TenPercentOff' has no assertions and no expected output files: @Test program has no assertions and no expected output files
See: ek9 -h E81007
The second half of the message matters. A test with no assert is legal when it has an expected_output.txt or its
expected_case files, because then its output is the assertion; that is a black-box test. With neither, it checks nothing.
The second is an assertion that no test reaches. A helper that holds an assert is only useful if a @Test calls it:
defines function checkNeverNegative() -> price as Integer assert discounted(price, 10) >= 0 defines program @Test TenPercentOff() assert discounted(2000, 10) == 1800
EK9 : Error : E81011: 'checkNeverNegative' on line 9 position 4: 'book.testing.pricing.orphan::checkNeverNegative' contains assertions but is not reachable from any @Test program: assertion is not reachable from any @Test program
See: ek9 -h E81011
This is the rule that retires the dead assertion. In JUnit the helper compiles, is never called, and the suite is green with the check silently absent; here an orphan assertion does not compile.
The third is the opposite fault, an assertion that production code can reach. If a plain program also calls the helper, the assertion would run in production:
@Test TenPercentOff() checkNeverNegative(2000) Report() checkNeverNegative(400)
EK9 : Error : E81012: 'checkNeverNegative' on line 9 position 4: 'book.testing.pricing.production::checkNeverNegative' contains assertions but is reachable from production program (use 'require' instead): assertion is reachable from non-test program
See: ek9 -h E81012
The message names the remedy: production code that must check something uses require, the other half of the pair from
Testing as You Go. Last, assert and @Test are only legal in dev/ source. In the production file both are the same
code, E81015, from different phases:
EK9 : Error : E81015: 'assert' on line 15 position 6: 'assert': test construct only allowed in dev source files
See: ek9 -h E81015
EK9 : Error : E81015: 'TenPercentOff' on line 18 position 4: '@Test': test construct only allowed in dev source files
See: ek9 -h E81015
Keep the two codes apart. E81015 is about where the file is: an assert or a @Test in production source. E81012 is about
what reaches an assertion: it fires inside dev/, on a helper a production program also calls. E81011 and E81007 need the
dev/ folder compiled with the program, which is why they appear under ek9 -t and not under a plain compile.
| A suite that looks green | JUnit, pytest | EK9 |
|---|---|---|
| A test that asserts nothing | passes | E81007, unless an expected-output file is its assertion |
| An assertion in a helper no test calls | compiles, never runs, nothing is reported | E81011 |
| An assertion production code can reach | a library call that runs wherever it is called | E81012 |
assert or a test outside the test folder |
allowed | E81015 |
| Coverage | an add-on you configure | built in; the 80% bar is the next section |
The gate, and what coverage cannot see
Coverage is not an add-on here. ek9 -t collects it on every run, and it gates: below 80% the run ends with E83001 and exit
12 even if every test passed. The same 80% is the bar ek9 -P applies before it will package a program, and there is no flag
to lower it. Take the project above and leave out its last two additions, the production black-box test and EveryPartIsSet.
All twelve tests are still green, and the build still fails:
Summary: 12 passed, 0 failed (12 results from 11 programs)
Types: 9 assert, 1 blackbox, 2 parameterized
Duration: 79ms
📊 Coverage Summary
❌ Methods: 36.4% (4/11)
⚠️ Lines: 65.7% (23/35)
✅ Branches: 100.0% (2/2)
⚠️ Overall: 63.89% (23/36 probes hit)
📦 By Module:
⚠️ book.testing.shop: 63.89% (23/36)
⚠️ Coverage below 80% - detailed report written to: .ek9/coverage-detail.json
❌ E83001: Code coverage 63.9% is below required threshold 80%
Methods: 36.4% (4/11)
Lines: 65.7% (23/35)
Branches: 100.0% (2/2)
Uncovered items:
7 method(s)
• book.testing.shop::OrderService.? (./Shop.ek9:22)
• book.testing.shop::Notifier.? (./Shop.ek9:53)
• book.testing.shop::ConsoleNotifier.? (./Shop.ek9:57)
... and 4 more
Use -t4 or -t5 for detailed coverage report.
ek9: coverage below the required threshold (exit 12)
Two of the uncovered methods are worth explaining, because you will meet them in every small project. The ? operators are
compiler-written, one for each aggregate, and each counts as a method; assert OrderService()? in EveryPartIsSet runs
them. And the production wiring, shopApp, only counts as exercised if some test runs against it, which is what the
production test does with the real ConsoleNotifier. The Checkout program itself stays uncovered, because a test cannot
call a program; it is the thin part that talks, and the bar leaves room for it.
Coverage is a fact about lines, though, and it can be 100% while the tests asked only the easy questions. ek9 -t5 adds a
report of input variety: for each callable, how many kinds of value the tests passed in. These are the five callables of the
project above:
Input-variety (numerator, measurement-only):
book.testing.shop::ConsoleNotifier.notify driven 2 cell(s), asserted 2: p0: SET p1: SPECIAL_CHARS
book.testing.shop::OrderService.placeOrder driven 6 cell(s), asserted 6: p0: SET p1: TYPICAL p2: POSITIVE,TYPICAL p3: POSITIVE,ZERO
book.testing.shop::bulkPrice driven 2 cell(s), asserted 2: p0: POSITIVE p1: POSITIVE
book.testing.shop::discounted driven 4 cell(s), asserted 4: p0: POSITIVE,TYPICAL p1: POSITIVE,ZERO
book.testing.shop::receipt driven 4 cell(s), asserted 4: p0: TYPICAL p1: POSITIVE,TYPICAL,ZERO
Every line of discounted ran, but its two Integer parameters were only ever given positive numbers and zero: no negative
price, no unset one. ek9 -t6 writes an HTML report whose Input Variety dial puts a number on it, with the denominator, which
the -t5 table does not print:
Input classes driven
17.0%
18 / 106 cells across 7 construct(s)
For discounted alone the report's JSON, input-variety.json, gives a floor of 18 classes between its two parameters, of
which the tests drove 4. The report's own text states its status: "The floor is 1-wise: each input value-class needs driving
at least once. It is measurement only -- nothing here fails a build." That is the whole of its authority. Input variety is the
exposure half of HEX, the advisory change-risk score (ek9 -h hex), and Quality Built In is where the two halves meet. It
never gates; coverage gates. A suite validated only by expected-output files records driven classes and none asserted, because
only an assert opens the window in which a result is observed, so read a low asserted figure beside the count of
output-validated tests before concluding that testing is thin.
The cost
The compiler policing your tests is not free, and you will feel it in three places. A test that exists only to print something
useful while you debug is E81007 the moment it is committed without an expected-output file, so a scratch test has to be a
scratch program, outside dev/. A black-box test compares all of the output, byte for byte, so a changed message or an
unstable value, a timestamp or a random number, fails it, and you either control the source of the value, as the fixed
secret did in Dependency Injection and Applications, or you assert on part of it instead. And the 80% bar does not
distinguish a thorough test from assert OrderService()?: the ? assertion above exists to run a compiler-written method, and
the gate is satisfied by it.
What the cost buys is narrower than "your code is correct", and the chapter has tried not to say more. It buys a suite in which every green test has either an assertion that production code cannot reach or an output file that pins what was printed, every assertion is reachable from a test, coverage is measured without a plugin, and a stand-in for the outside world is a component you write in a dozen lines. What it does not buy is a judgement about whether the right questions were asked. Input variety is the nearest the compiler comes to that, and it only measures.
-
If you skipped Volume 1, Testing as You Go is the chapter to read first: it introduces
@Test,assert,ek9 -tand the coverage bar. ↩