RFC 007: Inline Tests¶
Status: Planned
Created: 2024-12-10
Summary¶
Allow @test functions to live alongside production code in the same file, combining Python's familiar decorator syntax
with Rust's proximity benefits. Test functions are automatically stripped from production builds.
Motivation¶
The Problem with Separate Test Files¶
Python's convention of tests/test_*.py files works, but has friction:
- Context switching — Edit
utils.py, switch totest_utils.py, lose mental context - Drift — Tests get out of sync when code moves/renames
- Discoverability — New contributors don't see tests alongside code
- Small modules — Creating a separate test file for 10 lines of code feels heavyweight
Rust's Solution¶
Rust embeds unit tests in the same file:
fn add(a: i64, b: i64) -> i64 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
}
This is stripped from release builds via #[cfg(test)].
Incan's Opportunity¶
Combine Python's ergonomics with Rust's proximity:
def add(a: int, b: int) -> int:
return a + b
@test
def test_add() -> None:
assert_eq(add(1, 2), 3)
Design¶
Basic Syntax¶
Any function decorated with @test is a test function:
# src/math.incn
def factorial(n: int) -> int:
if n <= 1:
return 1
return n * factorial(n - 1)
@test
def test_factorial_base() -> None:
assert_eq(factorial(0), 1)
assert_eq(factorial(1), 1)
@test
def test_factorial_recursive() -> None:
assert_eq(factorial(5), 120)
Running Tests¶
# Run tests in a specific file
incan test src/math.incn
# Run all tests in directory (finds @test in all .incn files)
incan test src/
# Run tests matching pattern
incan test -k "factorial" src/
Build Behavior¶
| Command | @test functions |
|---|---|
incan build |
Stripped (not compiled) |
incan run |
Stripped (not included) |
incan test |
Included and executed |
incan --check |
Type-checked but not emitted |
Inline Tests with Fixtures¶
Fixtures work naturally with inline tests:
# src/database.incn
class Database:
def connect(url: str) -> Database:
...
def query(self, sql: str) -> List[Row]:
...
@fixture
def test_db() -> Database:
db = Database.connect("sqlite::memory:")
yield db
db.close()
@test
def test_query(test_db: Database) -> None:
result = test_db.query("SELECT 1")
assert_eq(len(result), 1)
Private Function Testing¶
Inline tests can access private (non-pub) functions:
# src/parser.incn
def parse(source: str) -> AST:
tokens = _tokenize(source) # private helper
return _build_ast(tokens)
def _tokenize(source: str) -> List[Token]:
# Private implementation detail
...
def _build_ast(tokens: List[Token]) -> AST:
# Private implementation detail
...
# Tests can access private functions!
@test
def test_tokenize() -> None:
tokens = _tokenize("1 + 2")
assert_eq(len(tokens), 3)
This mirrors Rust's use super::* pattern where inline tests access module internals.
Mixing Inline and Separate Tests¶
Both approaches coexist:
project/
├── src/
│ ├── math.incn # Has @test functions inline
│ └── complex_module.incn # No inline tests
└── tests/
└── test_complex.incn # Separate test file for complex cases
Compilation Strategy¶
Detection¶
During parsing, track which functions have @test decorator:
pub struct FunctionDecl {
pub name: String,
pub is_test: bool, // true if @test decorated
// ...
}
Conditional Compilation¶
In codegen, check compilation mode:
fn emit_function(&mut self, func: &FunctionDecl) {
// Skip @test functions in production builds
if func.is_test && !self.test_mode {
return;
}
// Emit test attribute when in test mode
if func.is_test {
self.emitter.line("#[test]");
}
// ... rest of function emission
}
Generated Rust¶
Incan:
def add(a: int, b: int) -> int:
return a + b
@test
def test_add() -> None:
assert_eq(add(1, 2), 3)
Production build (incan build):
fn add(a: i64, b: i64) -> i64 {
a + b
}
// test_add is NOT emitted
Test build (incan test):
fn add(a: i64, b: i64) -> i64 {
a + b
}
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
Examples¶
Simple Unit Tests¶
# src/strings.incn
def reverse(s: str) -> str:
return s[::-1]
def is_palindrome(s: str) -> bool:
return s == reverse(s)
@test
def test_reverse() -> None:
assert_eq(reverse("hello"), "olleh")
assert_eq(reverse(""), "")
@test
def test_palindrome() -> None:
assert_true(is_palindrome("radar"))
assert_false(is_palindrome("hello"))
With Parametrize¶
# src/validators.incn
def is_valid_email(email: str) -> bool:
return "@" in email and "." in email
@test
@parametrize("email, expected", [
("user@example.com", true),
("invalid", false),
("no-at.com", false),
("user@domain", false),
])
def test_email_validation(email: str, expected: bool) -> None:
assert_eq(is_valid_email(email), expected)
Error Case Testing¶
# src/division.incn
def safe_divide(a: int, b: int) -> Result[int, str]:
if b == 0:
return Err("division by zero")
return Ok(a / b)
@test
def test_divide_success() -> None:
result = safe_divide(10, 2)
assert_true(result.is_ok())
assert_eq(result.unwrap(), 5)
@test
def test_divide_by_zero() -> None:
result = safe_divide(10, 0)
assert_true(result.is_err())
assert_eq(result.unwrap_err(), "division by zero")
Test Discovery¶
The test runner discovers tests by:
- Scanning files — Find all
.incnfiles in target path - Parsing — Look for
@testdecorated functions - Filtering — Apply
-kpattern if provided - Executing — Run matching tests
$ incan test src/
Discovered 12 tests in 4 files:
src/math.incn: 3 tests
src/strings.incn: 2 tests
src/validators.incn: 4 tests
src/division.incn: 3 tests
Running tests...
✓ test_factorial_base (0.001s)
✓ test_factorial_recursive (0.001s)
...
12 passed, 0 failed
Alternatives Considered¶
1. Rust-style test Module¶
def add(a: int, b: int) -> int:
return a + b
@test_module
module tests:
def test_add() -> None:
assert_eq(add(1, 2), 3)
Rejected: More syntax to learn, less Pythonic. The @test decorator is simpler.
2. Doctest Only¶
def add(a: int, b: int) -> int:
"""
>>> add(1, 2)
3
"""
return a + b
Rejected: Good for simple examples, but insufficient for complex tests with setup/teardown.
3. Separate Files Only (Python-style)¶
Keep all tests in tests/ directory.
Rejected: Loses Rust's proximity benefits. Can still use this for integration tests.
Implementation Plan¶
Phase 1: Parser Changes¶
- Recognize
@testdecorator - Mark
FunctionDecl.is_test = true - Track in AST for later filtering
Phase 2: Test Runner Changes¶
- Scan source files (not just
tests/directory) - Discover
@testfunctions in any.incnfile - Include in test execution
Phase 3: Codegen Changes¶
- Add
test_mode: boolto codegen context - Skip
@testfunctions whentest_mode = false - Emit
#[test]attribute whentest_mode = true
Phase 4: CLI Integration¶
incan build— strips tests (default)incan test path/— discovers and runs inline testsincan test -k pattern— filters tests
Checklist¶
- [ ] Parser: recognize
@testdecorator on functions - [ ] AST: track
is_testonFunctionDecl - [ ] Codegen: conditional compilation based on
test_mode - [ ] Test runner: scan source files for
@testfunctions - [ ] Test runner: merge inline tests with
tests/directory tests - [ ] CLI:
incan test src/works for inline tests - [ ] Documentation and examples
- [ ] Verify fixtures work with inline tests
References¶
- Rust Testing
- pytest Discovery
- RFC 001: Test Fixtures
- RFC 002: Parametrize