Files
lux/docs/testing.md
Brandon Lucas 8c7354131e docs: update documentation to match current implementation
- SKILLS.md: Update roadmap phases with actual completion status
  - Phase 0-1 complete, Phase 2-5 partial, resolved design decisions
- OVERVIEW.md: Add HttpServer, Test effect, JIT to completed features
- ROADMAP.md: Add HttpServer, Process, Test effects to done list
- VISION.md: Update Phase 2-3 tables with current status
- guide/05-effects.md: Add Time, HttpServer, Test to effects table
- guide/09-stdlib.md: Add HttpServer, Time, Test effect docs
- reference/syntax.md: Fix interpolation syntax, remove unsupported literals
- testing.md: Add native Test effect documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 02:56:42 -05:00

7.4 KiB

Testing in Lux

This guide explains how to write and run tests for Lux programs.

Native Test Effect

Lux provides a built-in Test effect for writing tests:

fn runTests(): Unit with {Test, Console} = {
    // Basic assertions
    Test.assert(1 + 1 == 2, "basic math")

    // Equality checks
    Test.assertEqual(List.length([1, 2, 3]), 3, "list length")

    // Boolean assertions
    Test.assertTrue(String.contains("hello", "ell"), "contains check")
    Test.assertFalse(List.isEmpty([1]), "non-empty list")
}

// Run with the test handler
fn main(): Unit with {Console} = {
    run runTests() with { Test = testReporter }
}

let result = run main() with {}

Test Effect Operations

Operation Purpose
Test.assert(condition, message) Assert condition is true
Test.assertEqual(expected, actual, message) Assert values are equal
Test.assertTrue(condition, message) Assert condition is true (alias)
Test.assertFalse(condition, message) Assert condition is false

Running Tests

# Run a test file
lux tests/my_tests.lux

# Run with the test runner
lux test tests/

Test File Structure

Lux uses a simple test framework based on comparing program output against expected results. Tests are organized in the tests/ directory.

Expected Output Files

Each .lux file in examples/ can have a corresponding .expected file in tests/expected/ that contains the expected output:

examples/
  hello.lux           # The program
tests/
  expected/
    hello.expected    # Expected output

Running Tests

Running All Tests

# Run the full test suite
cargo test

# Run with output
cargo test -- --nocapture

Running a Single Program

# Run a specific file
./result/bin/lux examples/hello.lux

# Or with cargo
cargo run -- examples/hello.lux

Writing Tests

Basic Program Test

Create a program in examples/ and its expected output in tests/expected/:

// examples/my_test.lux
fn main(): Unit with {Console} = {
    Console.print("Hello, Test!")
    Console.print("1 + 2 = " + toString(1 + 2))
}

let output = run main() with {}

Expected output (tests/expected/my_test.expected):

Hello, Test!
1 + 2 = 3

Testing Pure Functions

For testing pure functions, create a test file that prints results:

// examples/test_math.lux
fn add(a: Int, b: Int): Int = a + b
fn multiply(a: Int, b: Int): Int = a * b

fn assertEqual(expected: Int, actual: Int, name: String): Unit with {Console} =
    if expected == actual then Console.print(name + ": PASS")
    else Console.print(name + ": FAIL - expected " + toString(expected) + ", got " + toString(actual))

fn main(): Unit with {Console} = {
    assertEqual(5, add(2, 3), "add(2, 3)")
    assertEqual(6, multiply(2, 3), "multiply(2, 3)")
    assertEqual(0, add(-1, 1), "add(-1, 1)")
}

let output = run main() with {}

Testing with Effects

Test effect handlers by providing mock implementations:

// Example: Testing with a mock Console
effect Console {
    fn print(message: String): Unit
}

// Real implementation would output to terminal
// Test implementation captures output to a list
handler testConsole(output: List<String>): Console {
    fn print(message) = {
        // In a real test, we'd capture this
        resume(())
    }
}

fn testableFunction(): Unit with {Console} = {
    Console.print("Test output")
}

fn runTest(): Unit with {Console} = {
    // Run with test handler
    run testableFunction() with {
        Console = testConsole([])
    }
    Console.print("Test completed")
}

let output = run runTest() with {}

Testing Result Types

Use pattern matching to test functions that return Result:

fn testResults(): Unit with {Console} = {
    let success = Ok(42)
    let failure = Err("Something went wrong")

    match success {
        Ok(n) => Console.print("Success with " + toString(n)),
        Err(e) => Console.print("Unexpected error: " + e)
    }

    match failure {
        Ok(n) => Console.print("Unexpected success"),
        Err(e) => Console.print("Expected error: " + e)
    }
}

let output = run testResults() with {}

Testing Option Types

fn findEven(list: List<Int>): Option<Int> =
    List.find(list, fn(x: Int): Bool => x % 2 == 0)

fn testOptions(): Unit with {Console} = {
    match findEven([1, 3, 5]) {
        None => Console.print("No even number found: PASS"),
        Some(_) => Console.print("FAIL - found unexpected even")
    }

    match findEven([1, 2, 3]) {
        None => Console.print("FAIL - should find 2"),
        Some(n) => Console.print("Found even: " + toString(n) + (if n == 2 then " PASS" else " FAIL"))
    }
}

let output = run testOptions() with {}

Test Patterns

Assertion Helpers

Create reusable assertion functions:

fn assertEq<T>(expected: T, actual: T, desc: String): Unit with {Console} =
    Console.print(desc + ": " + (if expected == actual then "PASS" else "FAIL"))

fn assertTrue(condition: Bool, desc: String): Unit with {Console} =
    Console.print(desc + ": " + (if condition then "PASS" else "FAIL"))

fn assertFalse(condition: Bool, desc: String): Unit with {Console} =
    Console.print(desc + ": " + (if condition then "FAIL" else "PASS"))

Grouping Tests

Organize related tests into groups:

fn testGroup(name: String): Unit with {Console} = {
    Console.print("")
    Console.print("=== " + name + " ===")
}

fn runAllTests(): Unit with {Console} = {
    testGroup("List Operations")
    testListHead()
    testListTail()
    testListMap()

    testGroup("String Operations")
    testStringLength()
    testStringSplit()

    Console.print("")
    Console.print("All tests completed")
}

Example Test Files

Look at the example projects for comprehensive test patterns:

  • projects/json-parser/main.lux - Tests recursive parsing
  • projects/todo-app/main.lux - Tests ADTs and list operations
  • projects/mini-interpreter/main.lux - Tests complex pattern matching
  • projects/markdown-converter/main.lux - Tests string manipulation

CI/CD Integration

The test suite runs automatically on every commit. To run locally:

# Build and test
nix build
nix develop --command cargo test

# Test all example programs
for f in examples/standard/*.lux; do
    echo "Testing $f"
    ./result/bin/lux "$f" > /tmp/out.txt 2>&1
    if [ $? -eq 0 ]; then
        echo "  OK"
    else
        echo "  FAILED"
        cat /tmp/out.txt
    fi
done

Debugging Tests

Verbose Output

Add debug prints to trace execution:

fn debug(msg: String): Unit with {Console} =
    Console.print("[DEBUG] " + msg)

fn myFunction(x: Int): Int with {Console} = {
    debug("Input: " + toString(x))
    let result = x * 2
    debug("Output: " + toString(result))
    result
}

Type Checking Only

Check types without running:

# Type check only (when available)
./result/bin/lux --check examples/myfile.lux

Best Practices

  1. One concern per test - Each test should verify one specific behavior
  2. Descriptive names - Use clear test function names that describe what's being tested
  3. Expected output - Create .expected files for reproducible testing
  4. Test edge cases - Include tests for empty lists, zero values, error conditions
  5. Keep tests fast - Avoid complex computations in tests
  6. Test effects separately - Mock effects to isolate behavior being tested