Proposal: Test Attribute Syntax
Status: Draft Author: Eric Created: 2026-01-22 Related: Simplified Attribute Syntax
Summary
Replace the tests keyword syntax with a #test attribute for declaring test functions. This improves grepability, aligns with the attribute system, and simplifies the grammar.
// Before (keyword syntax)
@test_try_basic tests @safe_divide tests @try_basic () -> void = run(
assert_eq(try_basic(), Ok(5)),
)
// After (attribute syntax)
#test(@safe_divide, @try_basic)
@test_try_basic () -> void = run(
assert_eq(try_basic(), Ok(5)),
)
Motivation
The Problem
The current tests keyword syntax has several issues:
-
Hard to grep — Searching for tests requires complex patterns:
# Current: awkward regex needed grep "tests @" *.ori # Proposed: simple prefix search grep "^#test" *.ori -
Buried in function signature — The
testskeyword appears mid-declaration, making it easy to miss:@test_complex_name tests @target1 tests @target2 () -> void = ... // ^^^^^^^^^^^^^ ^^^^^^^^^^^^^ easy to overlook -
Inconsistent with other metadata — Attributes like
#skipand#deriveare prefixed, but test declarations use inline keywords. -
Grammar complexity — The
testskeyword requires special parsing rules and can chain indefinitely. -
Tooling friction — IDEs and linters need special logic to identify test functions; an attribute is immediately recognizable.
Why an Attribute?
Test declaration is metadata about a function:
- “This function is a test”
- “It tests these targets”
This is exactly what attributes express. Other languages use similar patterns:
| Language | Test Declaration |
|---|---|
| Rust | #[test] |
| Python | @pytest.mark.test or naming convention |
| Go | func TestFoo(t *testing.T) |
| Java | @Test |
An attribute-based approach aligns Ori with industry conventions.
Design
Syntax
TestAttribute = "#test" "(" TargetList ")"
TargetList = Target { "," Target }
Target = "@" Identifier
The #test attribute takes one or more function references as arguments:
// Single target
#test(@calculate_sum)
@test_sum () -> void = run(
assert_eq(calculate_sum(1, 2), 3),
)
// Multiple targets
#test(@parse_int, @validate_input)
@test_parsing () -> void = run(
assert_eq(parse_int("42"), Ok(42)),
assert(validate_input("hello")),
)
Semantic Rules
-
At least one target required — Every test must specify what it tests:
// Error: #test requires at least one target #test() @orphan_test () -> void = ... -
Targets must exist — Referenced functions must be defined in scope:
// Error: @nonexistent is not defined #test(@nonexistent) @bad_test () -> void = ... -
No circular testing — A function cannot test itself:
// Error: @test_self cannot test itself #test(@test_self) @test_self () -> void = ... -
Return type must be void — Test functions don’t return values:
// Error: test functions must return void #test(@foo) @test_foo () -> int = 42
Combining with Other Attributes
Attributes stack naturally:
#skip("flaky on CI")
#test(@network_fetch)
@test_network () -> void = run(
let result = network_fetch("https://example.com"),
assert(is_ok(result)),
)
#skip("not yet implemented")
#test(@future_feature)
@test_future () -> void = run(
assert(false),
)
Order doesn’t matter, but convention is #skip before #test:
// Preferred
#skip("reason")
#test(@target)
// Also valid
#test(@target)
#skip("reason")
Migration
Automatic Migration
The ori fmt tool will automatically convert old syntax to new:
// Input (old syntax)
@test_foo tests @bar tests @baz () -> void = run(...)
// Output (new syntax)
#test(@bar, @baz)
@test_foo () -> void = run(...)
Migration Path
- Phase 1: Accept both syntaxes, emit deprecation warning for old
- Phase 2:
ori fmtauto-converts old to new - Phase 3: Remove old syntax support
Regex for Finding Old Syntax
# Find all uses of old tests keyword
grep -E "@[a-z_]+ tests @" **/*.ori
Examples
Before and After
Simple test:
// Before
@test_add tests @add () -> void = run(
assert_eq(add(1, 2), 3),
)
// After
#test(@add)
@test_add () -> void = run(
assert_eq(add(1, 2), 3),
)
Multiple targets:
// Before
@test_math tests @add tests @subtract tests @multiply () -> void = run(
assert_eq(add(1, 2), 3),
assert_eq(subtract(5, 3), 2),
assert_eq(multiply(2, 3), 6),
)
// After
#test(@add, @subtract, @multiply)
@test_math () -> void = run(
assert_eq(add(1, 2), 3),
assert_eq(subtract(5, 3), 2),
assert_eq(multiply(2, 3), 6),
)
With skip:
// Before
#skip("waiting on parser fix")
@test_parser tests @parse () -> void = run(...)
// After
#skip("waiting on parser fix")
#test(@parse)
@test_parser () -> void = run(...)
Real-world example (from try.ori):
// Before
@test_try_returns_final tests @parse_int tests @try_multi () -> void = run(
assert_eq(try_multi(), Ok(4)),
)
// After
#test(@parse_int, @try_multi)
@test_try_returns_final () -> void = run(
assert_eq(try_multi(), Ok(4)),
)
Implementation
Parser Changes
Remove the tests keyword handling from function parsing. Instead, #test is parsed as a regular attribute with the attribute parser from the simplified-attributes proposal.
Before (function parser):
fn parse_function(&mut self) -> Function {
let attrs = self.parse_attributes();
self.expect(Token::At);
let name = self.parse_identifier();
// Special tests parsing
let mut targets = vec![];
while self.current() == Token::Tests {
self.advance();
self.expect(Token::At);
targets.push(self.parse_identifier());
}
// ... rest of function
}
After (function parser):
fn parse_function(&mut self) -> Function {
let attrs = self.parse_attributes(); // #test handled here
self.expect(Token::At);
let name = self.parse_identifier();
// No special tests parsing needed
// ... rest of function
}
AST Changes
Before:
struct Function {
name: Identifier,
test_targets: Vec<Identifier>, // Special field
// ...
}
After:
struct Function {
name: Identifier,
attributes: Vec<Attribute>, // #test is just an attribute
// ...
}
// Test targets extracted from attributes when needed
fn get_test_targets(func: &Function) -> Option<Vec<Identifier>> {
func.attributes.iter()
.find(|a| a.name == "test")
.map(|a| a.args.clone())
}
Semantic Analysis
The type checker validates #test attributes:
fn check_test_attribute(&mut self, func: &Function, attr: &Attribute) {
// Must have at least one target
if attr.args.is_empty() {
self.error("#test requires at least one target function");
}
// All targets must be function references
for arg in &attr.args {
match arg {
Expr::FunctionRef(name) => {
if !self.scope.has_function(name) {
self.error(format!("@{} is not defined", name));
}
}
_ => self.error("#test arguments must be function references (@name)"),
}
}
// Function must return void
if func.return_type != Type::Void {
self.error("test functions must return void");
}
}
Test Runner Changes
The test runner finds tests by attribute instead of AST field:
fn find_tests(module: &Module) -> Vec<&Function> {
module.functions.iter()
.filter(|f| f.attributes.iter().any(|a| a.name == "test"))
.collect()
}
fn get_coverage_targets(test: &Function) -> Vec<Identifier> {
test.attributes.iter()
.find(|a| a.name == "test")
.map(|a| extract_function_refs(&a.args))
.unwrap_or_default()
}
Grammar Changes
Remove
FunctionDecl = ... [ TestClause ] ...
TestClause = "tests" "@" Identifier { "tests" "@" Identifier }
Add
The #test attribute follows standard attribute grammar:
Attribute = "#" Identifier [ "(" ArgumentList ")" ]
// #test specifically:
TestAttribute = "#test" "(" FunctionRefList ")"
FunctionRefList = FunctionRef { "," FunctionRef }
FunctionRef = "@" Identifier
Comparison
| Aspect | Old Syntax | New Syntax |
|---|---|---|
| Grepability | grep "tests @" (false positives) | grep "^#test" (precise) |
| Visual | Buried in signature | Clearly prefixed |
| Grammar | Special tests keyword | Standard attribute |
| Tooling | Custom detection | Attribute detection |
| Multi-target | Chain tests @a tests @b | List @a, @b |
| Alignment | Unique to Ori | Similar to Rust, Java |
Design Rationale
Why Not #[test] (Rust-style)?
Per the simplified attributes proposal, Ori uses #name() not #[name()]. The brackets add noise without value.
Why Require Targets?
Ori’s philosophy is explicit testing. Every test must declare what it tests. This enables:
- Coverage analysis
- Dead code detection
- Documentation of test intent
An empty #test() would defeat this purpose.
Why @name References?
The @ ori identifies functions. Using #test(@foo) is consistent with how functions are referenced elsewhere:
@foo()— call function#test(@foo)— test functionuse './mod' { @foo }— import function
Why Not Named Arguments?
We considered:
#test(.targets: [@foo, @bar])
Rejected because:
- More verbose for common case
- Single unnamed argument list is clearer
- No other metadata to name (just targets)
Summary
Replacing tests keyword with #test attribute:
- Improves grepability —
grep "#test"finds all tests - Cleaner syntax — Targets listed once, not chained
- Consistent — Follows attribute conventions
- Simpler grammar — No special keyword handling
- Better tooling — Standard attribute detection
- Maintains semantics — Same test coverage requirements
The change is syntactic sugar with identical runtime behavior.