Proposal: Incremental Test Execution and Explicit Floating Tests
Status: Approved Author: Eric (with AI assistance) Created: 2026-01-23 Approved: 2026-01-28 Affects: Language design, compiler, test runner
Summary
Two related changes to Ori’s testing system:
- Explicit floating tests: Replace naming convention with
tests _syntax - Incremental test execution: Attached tests auto-run during compilation when their targets change
// Attached test - runs during compilation when @add changes
@test_add tests @add () -> void = {
assert_eq(actual: add(a: 1, b: 2), expected: 3)
}
// Floating test - only runs via `ori test`
@integration_suite tests _ () -> void = {
let result = full_pipeline("input")
assert_ok(result: result)
}
Motivation
Problem 1: Ambiguous Floating Tests
Current spec: floating tests are identified by naming convention (test_ prefix without tests @target).
// Is this a test or a helper?
@test_helper () -> void = setup_data()
// This is a floating test (by naming convention)
@test_integration () -> void = {...}
// This is a function (no test_ prefix)
@integration_check () -> void = {...}
Problems:
- Naming convention is implicit, easy to mistake
- Helper functions in test files might accidentally start with
test_ - No syntactic marker that something is a test
Problem 2: Forgetting to Run Tests
Developers (and AI assistants) often:
- Make changes to code
- Forget to run tests
- Or run the wrong tests
- Or run all tests when only a few are affected
The compiler already knows the dependency graph. The tests @target syntax explicitly declares which tests cover which functions. This information exists but isn’t used for execution.
The Insight
The tests keyword creates an edge in the dependency graph:
@test_tokenize tests @tokenize
│
▼
@tokenize (changed)
│
▼
@parse_token (dependency)
When @tokenize changes, the compiler knows @test_tokenize is affected. Why not run it automatically?
Design
Part 1: Explicit Floating Tests with tests _
All tests must use the tests keyword. Floating tests use _ as the target:
// Attached test
@test_add tests @add () -> void = {...}
// Multiple targets
@test_roundtrip tests @parse tests @format () -> void = {...}
// Floating test (explicit)
@test_integration tests _ () -> void = {...}
Grammar
test = [ attribute ] "@" identifier "tests" test_targets "()" "->" "void" "=" expression .
test_targets = "_" | test_target { "tests" test_target } .
test_target = "@" identifier .
Semantics
tests @fn— attached test, covers@fnfor test requirementtests _— floating test, covers no function
The _ token is consistent with its use elsewhere:
- Pattern matching:
_ -> default(match anything) - Lambdas:
(_, b) -> b(ignore parameter) - Tests:
tests _(targets nothing specific)
No Migration Period
The tests keyword is required from the start. The test_ naming convention is no longer special:
// This is NOT a test (no `tests` keyword)
@test_integration () -> void = {...}
// This IS a test (explicit floating)
@test_integration tests _ () -> void = {...}
Part 2: Incremental Test Execution
During compilation, attached tests whose targets (or transitive dependencies) have changed are automatically executed.
Compilation Flow
$ ori check src/parser.ori
Compiling...
✓ @parse_token (changed)
✓ @tokenize (depends on @parse_token)
Running affected tests...
✓ @test_parse_token (2 assertions)
✓ @test_tokenize (3 assertions)
✗ @test_precedence (expected 23, got 35)
src/parser.ori:47
Build succeeded with 1 test failure.
Which Tests Run?
| Test Type | When It Runs |
|---|---|
Attached (tests @fn) | During compilation if @fn or its dependencies changed |
Floating (tests _) | Only via explicit ori test |
The dependency graph determines “affected”:
Change @helper
↓ (used by)
@process uses @helper
↓ (tested by)
@test_process tests @process ← runs
@test_e2e tests _ ← does NOT run (floating)
Non-Blocking by Default
Test failures are reported but don’t block compilation:
$ ori check src/math.ori
Compiling...
✓ @add (changed)
Running affected tests...
✗ @test_add (expected 5, got 6)
Build succeeded with 1 test failure.
For strict mode (CI, pre-commit):
$ ori check --strict src/math.ori
Build failed: 1 test failure.
Caching
The compiler tracks:
- Hash of each function’s normalized AST
- Test results from previous runs
- Dependency edges
On incremental compile:
- Compute changed functions (hash mismatch)
- Walk dependency graph to find affected tests
- Run only affected attached tests
- Cache results keyed by input hashes
Cache Storage
Test results are cached in .ori/cache/test/:
- Function hashes:
.ori/cache/test/hashes.bin - Test results:
.ori/cache/test/results.bin
The .ori/ directory should be added to .gitignore.
Part 3: Performance Expectations
Attached tests run during compilation, so they should be fast.
// Good: attached test is fast and focused
@test_parse_int tests @parse_int () -> void = {
assert_eq(actual: parse_int("42"), expected: Some(42))
assert_eq(actual: parse_int("abc"), expected: None)
}
// Good: slow test is floating
@test_full_compile_cycle tests _ () -> void = {
let source = read_file("large_program.ori")
let result = compile_and_{source}
assert_ok(result: result)
}
Compiler Warning
If an attached test exceeds a threshold (configurable, default 100ms):
warning: attached test @test_parse took 250ms
--> src/parser.ori:45
|
| Attached tests run during compilation.
| Consider making this a floating test: tests _
|
= hint: attached tests should complete in <100ms
Threshold Configuration
The slow test threshold is configurable via ori.toml:
[testing]
slow_test_threshold = "100ms"
Supported units: ms, s, m. Default is 100ms.
Examples
Basic Usage
@add (a: int, b: int) -> int = a + b
@multiply (a: int, b: int) -> int = a * b
// Targeted: runs when @add changes
@test_add tests @add () -> void = {
assert_eq(actual: add(a: 2, b: 3), expected: 5)
}
// Targeted: runs when @multiply changes
@test_multiply tests @multiply () -> void = {
assert_eq(actual: multiply(a: 2, b: 3), expected: 6)
}
// Floating: only runs via `ori test`
@test_math_integration tests _ () -> void = {
let result = add(a: multiply(a: 2, b: 3), b: 1)
assert_eq(actual: result, expected: 7)
}
Transitive Dependencies
@helper (x: int) -> int = x * 2
@process (x: int) -> int = helper(x) + 1
// Runs when @helper OR @process changes
@test_process tests @process () -> void = {
assert_eq(actual: process(5), expected: 11)
}
Multiple Targets
@parse (s: str) -> Ast = ...
@format (a: Ast) -> str = ...
// Runs when @parse OR @format changes
@test_roundtrip tests @parse tests @format () -> void = {
let ast = parse("x + 1")
let output = format(ast)
assert_eq(actual: output, expected: "x + 1")
}
Test Organization
// src/tokenizer.ori
@tokenize (input: str) -> [Token] = ...
// Fast unit test - runs during compilation
@test_tokenize_basic tests @tokenize () -> void = {
assert_eq(
actual: tokenize("1 + 2")
expected: [Int(1), Plus, Int(2)]
)
}
// Slow integration test - runs only via `ori test`
@test_tokenize_large_file tests _ () -> void = {
let input = read_file("fixtures/large.ori")
let tokens = tokenize(input)
assert(condition: len(collection: tokens) > 10000)
}
CLI Changes
ori check (default)
Compiles and runs affected attached tests:
$ ori check src/
Compiling 3 files...
Running 5 affected tests...
✓ @test_parse (2 assertions)
✓ @test_tokenize (3 assertions)
...
Build succeeded. 5 tests passed.
ori check --no-test
Compile only, skip test execution:
$ ori check --no-test src/
Compiling 3 files...
Build succeeded.
ori check --strict
Fail build on test failure (for CI):
$ ori check --strict src/
Compiling...
Running affected tests...
✗ @test_parse (assertion failed)
Build FAILED: 1 test failure.
ori test
Run all tests (attached and floating):
$ ori test
Running all tests...
✓ @test_parse (2 assertions)
✓ @test_tokenize (3 assertions)
✓ @test_integration (5 assertions) // floating runs here
...
42 passed, 0 failed.
ori test --only-attached
Run only attached tests (useful for quick check):
$ ori test --only-attached
Running attached tests...
✓ @test_parse (2 assertions)
...
38 passed, 0 failed. (4 floating tests skipped)
Benefits
For Developers
- No forgetting tests — they run automatically on compile
- Fast feedback — only affected tests run, not the entire suite
- Clear distinction —
tests @fnvstests _is unambiguous
For AI Assistants
- Built-in correctness — can’t “forget” to run tests
- Immediate feedback — see test failures alongside compile errors
- No guessing — don’t need to figure out which tests to run
For CI
- Faster builds — incremental test caching
- Strict mode —
--strictfails on any test failure - Same behavior — CI runs same tests as local development
Implementation Notes
Compiler Changes
- Parser: Require
testskeyword for all tests, accept_as target - AST:
TestDef.targetsbecomesenum { Targeted(Vec<Name>), FreeFloating } - Dependency graph: Index tests by target function
- Incremental: Track function hashes, compute affected tests
- Execution: Run affected tests after type checking
Test Runner Changes
- Accept filter for attached-only vs all tests
- Report timing per test for threshold warnings
- Cache test results keyed by dependency hashes
Alternatives Considered
1. Keep Naming Convention
Status quo: test_ prefix indicates floating test.
Rejected: Implicit, easy to confuse with helper functions.
2. Use tests void Instead of tests _
@test_integration tests void () -> void = ...
Rejected: void is a type, overloading it is confusing. _ is the established “don’t care” token.
3. Separate @test Declaration
@test test_integration () -> void = ...
Rejected: Inconsistent with attached test syntax, requires new keyword position.
4. Attribute for Free-Floating
#[free]
@test_integration tests () -> void = ...
Rejected: More verbose, attributes are for modifiers not core semantics.
5. Optional Test Execution
Make incremental test execution opt-in.
Rejected: The value is in being automatic. Opt-in means people forget.
Summary
This proposal makes Ori’s testing system:
- Explicit —
tests _clearly marks floating tests - Automatic — affected tests run during compilation
- Fast — only changed code’s tests run
- Non-intrusive — failures shown but don’t block by default
The tests keyword becomes the universal marker for “this is a test”, with the target indicating scope:
| Syntax | Meaning | Runs During |
|---|---|---|
tests @fn | Tests specific function | Compilation (when affected) |
tests _ | Tests nothing specific | ori test only |
// Change this function...
@parse (input: str) -> Ast = ...
// ...and this test runs automatically
@test_parse tests @parse () -> void = {...}
// ...but this one waits for explicit `ori test`
@test_e2e tests _ () -> void = {...}