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"