vanilla-test 2.1.0

Minimal, dependency-free testing for native Rust and browser WebAssembly
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../RUST.md")]

use std::collections::HashSet;
use std::error::Error;
use std::fmt::{self, Write as _};
use std::sync::Arc;

/// An immutable summary produced by [`VanillaTest::report`].
#[derive(Debug, PartialEq, Eq)]
pub struct TestResult {
    /// Numbered descriptions that passed, in test order.
    pub passed: Box<[String]>,
    /// Numbered descriptions that failed, in test order.
    pub failed: Box<[String]>,
    /// Total number of completed tests.
    pub total: usize,
    /// Number of failed tests.
    pub failure_count: usize,
    /// `true` exactly when `failure_count` is zero.
    pub ok: bool,
    /// Plain-text report containing the summary and both outcome lists.
    pub report: String,
}

/// A lifecycle error that leaves the suite unchanged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TestError {
    /// A reported suite cannot start another test.
    SuiteAlreadyReported,
    /// Only one test can be active at a time.
    TestAlreadyActive,
    /// Descriptions are exact, case-sensitive, and unique within a suite.
    DuplicateDescription,
    /// A decision or completion was requested without an active test.
    NoActiveTest,
    /// The active test must be completed before reporting.
    ActiveTestNotDone,
}

impl fmt::Display for TestError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::SuiteAlreadyReported => "this vanilla-test suite has already reported",
            Self::TestAlreadyActive => "a test is already active; call done() first",
            Self::DuplicateDescription => "test descriptions must be unique",
            Self::NoActiveTest => "there is no active test; call expects() first",
            Self::ActiveTestNotDone => {
                "the active test is not complete; call done() before report()"
            }
        })
    }
}

impl Error for TestError {}

#[derive(Clone, Copy)]
enum Decision {
    Passed,
    Failed,
}

struct Case {
    number: usize,
    description: Arc<str>,
}

struct ActiveCase {
    case: Case,
    decision: Option<Decision>,
}

/// A single-use, sequential test suite.
///
/// The normal lifecycle is `expects` → `pass` or `fail` → `done`, repeated as
/// needed, followed by one `report`. Calling `done` without a decision records
/// a failure. Repeated decisions preserve the first outcome.
#[derive(Default)]
pub struct VanillaTest {
    active: Option<ActiveCase>,
    descriptions: HashSet<Arc<str>>,
    passed: Vec<Case>,
    failed: Vec<Case>,
    result: Option<TestResult>,
}

impl VanillaTest {
    /// Creates an empty suite.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Starts one uniquely described test.
    pub fn expects(&mut self, description: &str) -> Result<(), TestError> {
        if self.result.is_some() {
            return Err(TestError::SuiteAlreadyReported);
        }
        if self.active.is_some() {
            return Err(TestError::TestAlreadyActive);
        }
        let description: Arc<str> = Arc::from(description);
        if !self.descriptions.insert(Arc::clone(&description)) {
            return Err(TestError::DuplicateDescription);
        }
        self.active = Some(ActiveCase {
            case: Case {
                number: self.passed.len() + self.failed.len() + 1,
                description,
            },
            decision: None,
        });
        Ok(())
    }

    /// Records the active test as passed. The first decision wins.
    pub fn pass(&mut self) -> Result<(), TestError> {
        self.decide(Decision::Passed)
    }

    /// Records the active test as failed. The first decision wins.
    pub fn fail(&mut self) -> Result<(), TestError> {
        self.decide(Decision::Failed)
    }

    /// Completes the active test, failing it if no decision was recorded.
    pub fn done(&mut self) -> Result<(), TestError> {
        let active = self.active.take().ok_or(TestError::NoActiveTest)?;
        match active.decision.unwrap_or(Decision::Failed) {
            Decision::Passed => self.passed.push(active.case),
            Decision::Failed => self.failed.push(active.case),
        }
        Ok(())
    }

    /// Seals the suite and returns its cached immutable result.
    ///
    /// Repeated calls return the same result. Reporting an empty suite succeeds.
    pub fn report(&mut self) -> Result<&TestResult, TestError> {
        if self.result.is_none() {
            if self.active.is_some() {
                return Err(TestError::ActiveTestNotDone);
            }

            self.descriptions = HashSet::new();
            let passed = render_cases(std::mem::take(&mut self.passed));
            let failed = render_cases(std::mem::take(&mut self.failed));
            let total = passed.len() + failed.len();
            let failure_count = failed.len();
            let report = render_report(&passed, &failed);
            self.result = Some(TestResult {
                passed,
                failed,
                total,
                failure_count,
                ok: failure_count == 0,
                report,
            });
        }

        let Some(result) = self.result.as_ref() else {
            unreachable!("the result is initialized above")
        };
        Ok(result)
    }

    fn decide(&mut self, decision: Decision) -> Result<(), TestError> {
        let active = self.active.as_mut().ok_or(TestError::NoActiveTest)?;
        if active.decision.is_none() {
            active.decision = Some(decision);
        }
        Ok(())
    }
}

fn render_cases(cases: Vec<Case>) -> Box<[String]> {
    cases
        .into_iter()
        .map(|case| format!("{}) .expects {}", case.number, case.description))
        .collect()
}

fn render_report(passed: &[String], failed: &[String]) -> String {
    let mut report = String::new();
    write!(
        report,
        "\n\nResult : {}\n\nTest Total : {}\nPassed : {}\nFailed : {}\n\nFAILED TESTS :\n",
        if failed.is_empty() {
            "PASSED"
        } else {
            "FAILED"
        },
        passed.len() + failed.len(),
        passed.len(),
        failed.len()
    )
    .expect("writing to a String cannot fail");

    for test in failed {
        writeln!(report, "{test}").expect("writing to a String cannot fail");
    }
    report.push_str("\nPASSED TESTS :\n");
    for test in passed {
        writeln!(report, "{test}").expect("writing to a String cannot fail");
    }
    report
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_report_is_passing_cached_and_final() {
        let mut test = VanillaTest::new();
        let first = test.report().unwrap();
        assert!(first.ok);
        assert_eq!(first.total, 0);
        assert_eq!(first.failure_count, 0);
        assert!(first.passed.is_empty());
        assert!(first.failed.is_empty());
        assert!(first.report.contains("Result : PASSED"));
        let first = first as *const TestResult;

        assert_eq!(test.report().unwrap() as *const TestResult, first);
        assert_eq!(
            test.expects("too late"),
            Err(TestError::SuiteAlreadyReported)
        );
    }

    #[test]
    fn lifecycle_preserves_first_decisions_order_and_report() {
        let mut test = VanillaTest::new();

        test.expects("first passes").unwrap();
        test.pass().unwrap();
        test.fail().unwrap();
        test.done().unwrap();

        test.expects("second fails").unwrap();
        test.fail().unwrap();
        test.pass().unwrap();
        test.done().unwrap();

        test.expects("undecided fails").unwrap();
        test.done().unwrap();

        let result = test.report().unwrap();
        assert_eq!(result.total, 3);
        assert_eq!(result.failure_count, 2);
        assert!(!result.ok);
        assert_eq!(&*result.passed, ["1) .expects first passes"]);
        assert_eq!(
            &*result.failed,
            ["2) .expects second fails", "3) .expects undecided fails"]
        );
        assert_eq!(
            result.report,
            "\n\nResult : FAILED\n\nTest Total : 3\nPassed : 1\nFailed : 2\n\nFAILED TESTS :\n2) .expects second fails\n3) .expects undecided fails\n\nPASSED TESTS :\n1) .expects first passes\n"
        );
    }

    #[test]
    fn invalid_transitions_are_typed_and_do_not_advance_numbering() {
        let mut test = VanillaTest::new();
        assert_eq!(test.pass(), Err(TestError::NoActiveTest));
        assert_eq!(test.fail(), Err(TestError::NoActiveTest));
        assert_eq!(test.done(), Err(TestError::NoActiveTest));

        test.expects("unique").unwrap();
        assert_eq!(test.expects("blocked"), Err(TestError::TestAlreadyActive));
        assert_eq!(test.report(), Err(TestError::ActiveTestNotDone));
        test.pass().unwrap();
        test.done().unwrap();

        assert_eq!(test.expects("unique"), Err(TestError::DuplicateDescription));
        test.expects("next").unwrap();
        test.done().unwrap();
        let result = test.report().unwrap();
        assert_eq!(&*result.failed, ["2) .expects next"]);
    }

    #[test]
    fn suites_isolate_unicode_descriptions_and_outcomes() {
        let mut first = VanillaTest::new();
        let mut second = VanillaTest::new();

        first.expects("same ✓").unwrap();
        first.pass().unwrap();
        first.done().unwrap();
        second.expects("same ✓").unwrap();
        second.fail().unwrap();
        second.done().unwrap();

        assert!(first.report().unwrap().ok);
        let result = second.report().unwrap();
        assert!(!result.ok);
        assert_eq!(&*result.failed, ["1) .expects same ✓"]);
    }

    #[test]
    fn error_messages_are_stable() {
        let cases = [
            (
                TestError::SuiteAlreadyReported,
                "this vanilla-test suite has already reported",
            ),
            (
                TestError::TestAlreadyActive,
                "a test is already active; call done() first",
            ),
            (
                TestError::DuplicateDescription,
                "test descriptions must be unique",
            ),
            (
                TestError::NoActiveTest,
                "there is no active test; call expects() first",
            ),
            (
                TestError::ActiveTestNotDone,
                "the active test is not complete; call done() before report()",
            ),
        ];

        for (error, message) in cases {
            assert_eq!(error.to_string(), message);
        }
    }

    #[test]
    fn public_types_are_safe_to_move_between_threads() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<VanillaTest>();
        assert_send_sync::<TestResult>();
        assert_send_sync::<TestError>();
    }
}