Romain Delamare

January 18, 2023

Fast Acceptance Tests with Rust and PostgreSQL

I explain how I write fast acceptance tests in Rust, specifically when using a PostgreSQL database.

I have recently started writing a lot of acceptance tests. As I work using a test-driven design approach, they specify behavior while unit tests specify specific implementations. Another advantage of acceptance tests is that their description can be written and read by domain experts without requiring technical skills.

An acceptance test describes a scenario that describes the initial state, what happens, and what is the expected behavior. This scenario is executed from an entry point of your application. So in a hexagonal architecture, an acceptance test exercises the adapters, for instance a command line or http adapter.

Structuring your tests

The rust documentation uses the term integration test for any test that can only access the public interface of your library or application. This definition of integration tests encompasses acceptance tests and might not be what you are used to, but I will stick to it to keep it simple.

The recommended test organization is to have unit tests residing with the code of your application (in submodules with the #[cfg(test)] attribute) and integration tests in the tests directory. For instance if you have a TODO list application, you project structure could look like this:

Project file structure
📁 todo
├─ 📁 src
│  ├─ 📄 main.rs
│  ├─ 📄 persistence.rs
│  └─ …
├─ 📁 tests
│  ├─ 📄 delete_todo.rs
│  └─ …
├─ 📄 Cargo.lock
└─ 📄 Cargo.toml

The src directory contains the application code along with unit tests. The tests directory contains the integration tests, with each file being a different compilation target. If you need multiple files for a test, you can create a module directory with a main.rs file and the other files.

You can also create integration tests in other directories by adding new integration test build targets in the Cargo configuration:

Cargo.toml
[[test]]
name = "acceptance-tests"
path = "acceptance-tests/acceptance_tests.rs"

This will create a new test target, that can have its own submodules. There are other options, and you can even disable the default test harness, but that out of scope for this article.

The integration tests depend on all the crates defined in dependencies and dev-dependencies in the Cargo.toml file, as well as the code of your application or library (you can import it by using its name, e.g., use todo::persistence::*). If you are testing an application, you cannot import code from src/main.rs, but you can move this code to a src/lib.rs file and import it both from the main file of your application and from the integration tests.

Writing acceptance tests

An acceptance test executes a scenario, typically described with a sentence like “given ‹initial state›, when ‹event›, then ‹expected result›”. The scenarios can be written by or in collaboration with domain experts, so that actual and expected features are tested.

To set up the initial state of a scenario, you will need to design your application with dependency injection, for instance to allow setting the current time for the test. If “dependency injection” gives you a bad “object-oriented bloated magical framework” taste in your mouth, do not worry, it can be a parameter. For instance, you can add a function that gives the current time as a parameter for your application initialization:

src/lib.rs
impl TodoApplication {
	fn new(get_time: Box<dyn Fn() -> OffsetDateTime>) -> Self {
		Self { … }
	}
}

For your application you can just use the system current time:

src/app.rs
fn main() {
	let app = TodoApplication::new(Box::new(OffsetDateTime::now_utc));
	…
}

In an acceptance test you can define a function that will give a fixed time:

tests/acceptance_test.rs
#[test]
fn test_creation() {
	// Given an empty application at 1PM on January 9, 2023
	let now = datetime!(2023-01-09 13:00 UTC);
	let app = TodoApplication::new(Box::new(move || now));
	…
}

Dealing with persistence

If your application persists data in a database (or some other way), you have two choices for your acceptance tests: mock your persistence interface or use an actual database. While the former may seem easier and leads to faster tests, I tend to prefer the latter to test my actual persistence layer. You could write the acceptance tests with mocks and test your persistence layer with dedicated integration tests, but in my experience this is more tedious as you are going to write some test cases multiple times.

Using an actual database for your acceptance tests can lead to performance issues. If you create a new database from scratch for each test, the execution time per test will be very long. If you re-use the same database for each test you cannot have concurrent executions and you need to be very careful to properly reset the database between each test.

After several iterations, I have come up with a solution that allows for fast concurrent acceptance test with an actual PostgreSQL database. First you need to create a test database with the common initial state for all your tests, then for each test:

  1. Create a copy of the test database with a random name
  2. execute the test with that copy of the test database
  3. delete the database copy

The default Rust test harness does not provide common test utility functions, such as before_each or after_each. To remedy that I create an AcceptanceTest structure. The AcceptanceTest::new function will create the database for the test, then instantiate the applications and anything that is required to execute the test. The AcceptanceTest implements the Drop trait to execute the test cleanup methods at the end of the test.

For instance, for the TODO list application, the structure could look like this:

tests/acceptance_test.rs
struct AcceptanceTest {
	app: TodoApplication,
	db_name: String,
}

impl AcceptanceTest {
	fn new(get_time: Box<dyn Fn() -> OffsetDateTime) -> Self {
		let db_name = generate_random_name();
		// copy database
		…
		Self {
			app: TodoApplication::new(get_time),
			db_name,
		}
	}
}

impl Drop for AcceptanceTest {
	fn drop(&mut self) {
		// drop database
		…
	}
}

The AcceptanceTest::drop method will be called at the end of your test, even if it panics. You can also add helper methods on AcceptanceTest to simplify your tests.

Copying the test database

In PostgreSQL, you can copy a database by creating a new database with the test database as template:

create database new_db with template test_db;

With this strategy, I have decreased the execution time of my acceptance test to an average of 100ms, down from around 1s initially (on a laptop with an AMD Ryzen 7 4750U CPU and 32GB of RAM). This allows me to use a test-driven approach with acceptance tests, and to run all my tests frequently to avoid regressions.