#fixture #fixtures #test #build #unit-testing

tested-fixture

Attribute macro for creating fixtures from tests

2 stable releases

1.0.1 May 3, 2024
1.0.0 May 2, 2024

#240 in Testing

MIT license

19KB
164 lines

tested-fixture

Crates.io Workflow Status

Attribute macro for creating fixtures from tests

Description

Sometimes a series of tests are progressive or incremental; that is to say one test builds on another. A multi-stage test might have complicated setup and verification processes for each step, but with clear boundaries between stages (test_1 verifies stage 1, test_2 verifies stage 2, etc. ). The problem arises when stages want to share data (i.e. test_2 wants to start where test_1 left off).

Common advice is to duplicate all the setup code across all tests, or alternatively to combine the tests into one large test. However the former approach can significantly slow down tests if setup is costly, and also introduces significant test maintenance costs if setup procedures change. The latter however can lead to large and unruly testing functions which are difficult to maintain, and doesn't solve the problem when dependencies cross multiple files (i.e. unit tests which test the full setup process for a Foo are difficult to combine with unit tests which test the setup process of a Bar which relies on a fully constructed Foo; should the "combined" test live near Foo or Bar? What if the tests needs to access internals to verify assertions?).

This crate provides an alternative approach by allowing a test to return a fixture which can be used in subsequent tests. Tests can opt in to this functionality by using a single attribute macro tested_fixture.

Usage

When writing tests for code like:

struct Foo {
    // ...
}

struct State {
    // ...
}

impl Foo {
    fn step_1() -> Self {
        Foo {
            // Complicated setup...
        }
    }

    fn step_2(&self) -> State {
        State {
            // Complicated execution...
        }
    }

    fn step_3(&self, v: &State) {
        // Complicated execution...
    }
}

An duplicated test setup would look something like

#[test]
fn step_1() {
    let foo = Foo::step_1();
    // Complicated assertions verify step 1...
}

#[test]
fn step_2() {
    let foo = Foo::step_1();
    // (Some?) Complicated assertions verify step 1...

    foo.step_2();
    // Complicated assertions verify step 2...
}

#[test]
fn step_3() {
    let foo = Foo::step_1();
    // (Some?) Complicated assertions verify step 1...

    let state = foo.step_2();
    // (Some?) Complicated assertions verify step 2...

    foo.step_3(&state);
    // Complicated assertions verify step 3...
}

As you can see, with a lot of steps, this can quickly get out of hand. To clean it up is straightforward by switching to use the tested_fixture attribute instead of the normal test.

// Save the fixture in a static variable called `STEP_1`
#[tested_fixture::tested_fixture(STEP_1)]
fn step_1() -> Foo {
    let foo = Foo::step_1();
    // Complicated assertions verify step 1...
    foo
}

#[tested_fixture::tested_fixture(STEP_2_STATE)]
fn step_2() -> State {
    let state = STEP_1.step_2();
    // Complicated assertions verify step 2...
    state
}

#[test]
fn step_3() {
    STEP_1.step_3(&STEP_2_STATE);
    // Complicated assertions verify step 3...
}

Note that when only step_2 is run, STEP_1 will be initialized on first access. Since the order of tests is not guaranteed, this actually can occur even if both tests are run. But since results are cached, the step_1 test should still succeed (or fail) regardless of if it is run first or not.

Advanced usage

The tested_fixture attribute supports attributes and a visibility level prefixing the identifier, as well as an optional : type suffix. This optional suffix can be used on tests returning a Result to specify that only Ok return values should be captured. For example:

#[tested_fixture::tested_fixture(
    /// Doc comment on the `STEP_1` global variable
    pub(crate) STEP_1: Foo
)]
fn step_1() -> Result<Foo, &'static str> {
    // ...
}

Limitations

Ordinary #[test] functions are able to return anything which implements std::process::Termination, including unlimited nestings of Results. While this crate does support returning nested Result wrappings, it only does so up to a fixed depth. Additionally it does not support returning any other Termination implementations besides Result.

As with all testing-related global state, it is recommended that tests don't mutate the state, as doing so will increase the risk of flaky tests due to changes in execution order or timing. Thankfully this is the default behavior, as all fixtures defined by this crate are only accessible by non-mutable reference.

Right now this crate does not support async tests.

License

Licensed under

Dependencies

~1.4–2MB
~41K SLoC