Test Discovery

Test discovery finds all test functions in a module and determines coverage.

Location

compiler/oric/src/test/discovery.rs (~310 lines)

Discovery Process

pub fn discover_tests(module: &Module) -> TestDiscovery {
    let mut tests = Vec::new();
    let mut targets: HashMap<Name, Vec<Name>> = HashMap::new();

    for item in &module.items {
        if let Item::Test(test) = item {
            tests.push(TestInfo {
                name: test.name,
                targets: test.targets.clone(),
                attributes: test.attributes.clone(),
                body: test.body,
            });

            // Track which functions are tested
            for target in &test.targets {
                targets.entry(*target)
                    .or_default()
                    .push(test.name);
            }
        }
    }

    TestDiscovery { tests, targets }
}

TestInfo Structure

pub struct TestInfo {
    /// Test function name
    pub name: Name,

    /// Functions this test targets
    pub targets: Vec<Name>,

    /// Test attributes
    pub attributes: TestAttributes,

    /// Test body
    pub body: ExprId,
}

pub struct TestAttributes {
    pub skip: Option<String>,
    pub compile_fail: Option<String>,
    pub should_fail: Option<String>,
}

Coverage Checking

pub fn check_coverage(module: &Module, discovery: &TestDiscovery) -> CoverageReport {
    let mut report = CoverageReport::new();

    for func in &module.functions {
        // Skip main and private functions
        if func.name.as_str() == "main" {
            continue;
        }

        if discovery.targets.contains_key(&func.name) {
            report.covered.push(func.name);
        } else {
            report.uncovered.push(func.name);
        }
    }

    report
}

pub struct CoverageReport {
    pub covered: Vec<Name>,
    pub uncovered: Vec<Name>,
}

impl CoverageReport {
    pub fn percentage(&self) -> f64 {
        let total = self.covered.len() + self.uncovered.len();
        if total == 0 {
            100.0
        } else {
            (self.covered.len() as f64 / total as f64) * 100.0
        }
    }
}

Mandatory Testing

Compilation fails if functions lack tests:

pub fn check_mandatory_tests(module: &Module, discovery: &TestDiscovery) -> Vec<Problem> {
    let mut problems = Vec::new();

    for func in &module.functions {
        // Exemptions
        if func.name.as_str() == "main" { continue; }
        if func.is_private() { continue; }  // Private functions can use :: import for testing

        if !discovery.targets.contains_key(&func.name) {
            problems.push(Problem::UntrestedFunction {
                name: func.name,
                span: func.span,
            });
        }
    }

    problems
}

Error message:

error: function `@process` has no test
 --> src/libsi:10:1
   |
10 | @process (data: Data) -> Result<Output, Error> = ...
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = help: add a test like:
     @test_process tests @process () -> void = run(...)

Filtering Tests

pub fn filter_tests(
    discovery: &TestDiscovery,
    filter: &TestFilter,
) -> Vec<&TestInfo> {
    discovery.tests.iter()
        .filter(|test| {
            // Skip skipped tests
            if test.attributes.skip.is_some() {
                return false;
            }

            // Name filter
            if let Some(pattern) = &filter.name {
                if !test.name.as_str().contains(pattern) {
                    return false;
                }
            }

            // Target filter
            if let Some(target) = &filter.target {
                if !test.targets.iter().any(|t| t == target) {
                    return false;
                }
            }

            true
        })
        .collect()
}

pub struct TestFilter {
    pub name: Option<String>,
    pub target: Option<Name>,
}

Test Ordering

Tests are ordered for deterministic output:

pub fn order_tests(tests: &mut [&TestInfo]) {
    tests.sort_by(|a, b| {
        // Sort by:
        // 1. Targeted tests before free-floating
        // 2. Alphabetically by name
        let a_targeted = !a.targets.is_empty();
        let b_targeted = !b.targets.is_empty();

        match (a_targeted, b_targeted) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.name.cmp(&b.name),
        }
    });
}

Compile-Fail Tests

Special handling for tests that should fail to compile:

pub fn handle_compile_fail_tests(
    module: &Module,
    discovery: &TestDiscovery,
) -> Vec<CompileFailResult> {
    discovery.tests.iter()
        .filter(|t| t.attributes.compile_fail.is_some())
        .map(|test| {
            // Try to compile just this test's body
            let result = try_compile_isolated(module, test.body);

            match result {
                Ok(_) => CompileFailResult::UnexpectedSuccess(test.name),
                Err(errors) => {
                    let expected = test.attributes.compile_fail.as_ref().unwrap();
                    if errors.iter().any(|e| e.message.contains(expected)) {
                        CompileFailResult::ExpectedFailure(test.name)
                    } else {
                        CompileFailResult::WrongError {
                            test: test.name,
                            expected: expected.clone(),
                            actual: errors,
                        }
                    }
                }
            }
        })
        .collect()
}