Testing

Roadster provides various utilities to make it easier to test your app, including insta snapshot utilities, temporary DB creation, and the run_test* methods that allow running tests against a fully initialized app.

Snapshot utilities

insta is a popular crate that enables writing snapshot tests. insta allows configuring some settings for how snapshots are generated. Roadster provides some default settings via the TestCase struct, which in turn can be customized via the TestCaseConfig struct. TestCase automatically applies the configured insta settings to the current insta test context when it's created.

Redacting sensitive or dynamic fields

Snapshot testing relies on the content remaining the same between test runs. This means that dynamic data, such as database IDs, need to be substituted with constant placeholder in order for the snapshot tests to pass.

In addition, because snapshots are checked in to source-control, it's also important that any sensitive data is redacted from the snapshot in order to avoid leaking secrets.

Insta allows defining filters to redact data from snapshots based on regexes, and Roadster provides some default filters via the TestCase struct, including redacting bearer tokens and postgres connection details and normalizing UUIDs and timestamps.

Examples

Creating a snapshot with a db url

#[test]
fn redact_sensitive_db_url() {
    let _case = TestCase::default();

    let db_url = "postgres://example.com:1234";
    assert_snapshot!(db_url);
}

Results in the following snapshot file:

---
source: book/examples/testing/src/snapshots/redact_db_url.rs
expression: db_url
---
postgres://[Sensitive]

Creating a snapshot with a uuid

#[test]
fn normalize_dynamic_uuid() {
    let _case = TestCase::default();

    let uuid = Uuid::new_v4();
    assert_snapshot!(uuid);
}

Results in the following snapshot file:

---
source: book/examples/testing/src/snapshots/redact_uuid.rs
expression: uuid
---
[uuid]

insta + rstest

The rstest crate allows writing tests using a concept known as test fixtures. Normally, insta's default logic for generating snapshot names doesn't work well with rstest -- insta uses the test name as the snapshot name, and rstest causes the same test to run multiple times. This means each rstest-based test needs to have a different snapshot name in order to avoid each insta invocation overwriting a previous snapshot for the test.

Roadster's TestCase struct allows customizing insta's logic for generating snapshot names in a way that works well with rstest. Roadster's logic appends either the rstest case number or name/description as a suffix to the snapshot name. This allows insta create unique snapshot files for each rstest case.

Examples

Using a TestCase when using insta together with rstest to write parameterized snapshot tests

// File path: <crate_name>/src/snapshots/rstest.rs

#[fixture]
fn case() -> TestCase {
    Default::default()
}

#[rstest]
#[case(1)]
#[case(2)]
#[case::case_name(3)]
fn normalize_dynamic_uuid(_case: TestCase, #[case] value: u32) {
    assert_snapshot!(value);
}

Generates the following snapshot files:

<crate_name>__snapshots__rstest__normalize_dynamic_uuid@case_01.snap
<crate_name>__snapshots__rstest__normalize_dynamic_uuid@case_02.snap
<crate_name>__snapshots__rstest__normalize_dynamic_uuid@case_name.snap

Testing with an initialized app

A majority of an app's test coverage may come from small, targeted unit tests. These are generally faster to run and easier to write because they test, for example, only a specific function's behavior and use fake/mock data for everything else. However, an app will usually want some level of end-to-end (E2E) testing, where entire API endpoints are tested via their request and response. An app may also want to write tests that interact with an actual DB, such as testing the ORM's model for a table in the DB.

For these cases, Roadster provides the run_test and run_test_with_result methods to run a test with a fully initialized app. Both methods will initialize the app before running the provided test closure, and tear down the app when the test closure completes. Note, however, that if the test closure panics, the app may not be torn down. If it's vital that the app is town down on test failure, either set the testing.catch-panic config to true, or use run_test_with_result and take care not to panic inside the test closure.

#[tokio::test]
async fn ping() {
    run_test(build_app(), PrepareOptions::test(), async |app| {
        let http_service = app.service_registry.get::<HttpService>().unwrap();
        let router = http_service.router().clone();

        let request: Request<Body> = Request::builder()
            .uri("/api/_ping")
            .body(().into())
            .unwrap();

        let response = router.oneshot(request).await.unwrap();

        assert_eq!(response.status(), StatusCode::OK);
    })
    .await
    .unwrap()
}

Test isolation

In order to maintain an efficient test suite, it's important for tests to be stable and parallelizable. For tests that work with the app's resources, such as a database, this requires special care to ensure tests do not conflict with each other. For example, if two tests try to create a user with the same email in parallel, one of the tests may fail if the database has a unique email constraint.

Randomized data

Probably the easiest way to ensure database-related tests do not conflict is to use randomized data for all fields using a crate such as fake. This crate allows creating plausible but fake/randomized data for various types of data, such as emails, usernames, and passwords. Compared to the other approaches mentioned below, this approach has the benefit of being the most efficient as no additional resources need to be initialized. However, this approach requires diligence to ensure hard-coded/conflicting data is not used in tests. If a more fool-proof approach is desired, the below approaches may be preferred. See the fake docs for examples.

Temporary DB

Creating a temporary DB for each test virtually guarantees that DB-related tests will never conflict with each other. The downside is there is a small performance hit for each test due to the DB operations needed to initialize the temporary DB. However, this may be worth the performance impact for the benefit of having stable, non-conflicting tests. The temporary DB connection is made available via the normal DB connection methods on the AppContext.

When using the run_test* methods, Roadster allows creating a temporary DB for testing. If enabled, Roadster will create a new DB for each test using the original DB connection details. If the database.temporary-test-db-clean-up config is set to true, the temporary DB will be deleted when the test completes. Note, however, that if the closure passed to the run_test* method(s) panics, the DB will not be deleted.

Note: This feature is only supported on Postgres and Mysql at the moment.

Examples

Example config to enable creating a temporary db in tests that use the run_test* methods

[database]
# Hard-coded DB uri for demonstration purposes only
uri = "postgres://localhost:1234/example_test"
# Defaults to `false`; set to `true` to enable creating a test DB when
# using the `run_test*` methods.
temporary-test-db = true
# Defaults to true; optionally set to `false` to prevent deleting test DBs.
# Only has an effect if `temporary-test-db` is set to `true`
temporary-test-db-clean-up = false

Test Containers

In addition to the above temporary DB approach, temporary DBs (or any other external docker-based resource) can be created and automatically torn down for each test using Test Containers. Roadster provides built-in support for initializing a DB, Redis, or SMTP server instance via test containers. The test container connection is made available via the normal DB connection methods on the AppContext.

Note that compared to the temporary DB solution discussed above, test containers have an additional performance hit due to the operations needed to initialize a new docker container for each test container instance. This means that this is the slowest option for ensuring tests are isolated. However, this solution supports other resources that your tests may need to interact with besides just databases (e.g. Redis and SMPT servers).

Examples

Example config to enable Test Containers in tests that use the run_test* methods

[database.test-container]
enable = true
tag = "15.3-alpine"

[service.sidekiq.redis.test-container]
enable = true
tag = "7.2-alpine"