We ramble about code, technology and life. Sometimes we actually code...


Rust Testing Context

by Kyluke McDougall on 2 March 20212 min read

Rust is not trivial and neither is testing your code. How you approach your testing can depend on your perspective.

If you're coming from the OOP mindset, you probably want to mock all objects and almost see that as a given. Some people with a functional programming background are already going wtf, this is a code smell. However, in both cases, at some point you'll find yourself trying to test code that interacts with another system.

If you need to define functions in rust that need to return different values (maybe something akin to a mock?) when writing tests, the #[cfg(test)] / cfg(not(test))] annotations can be really useful. Let's use an integration test as an example.

Integration Test

I'll use the rust sqlite package as an example for this. It has an interesting feature that allows you to open up a database in memory. This is different to other setups where you would usually provide a database connection string as an environment variable or a config. This also allows us to create and destroy the database purely for testing purposes.

Let's say you're trying to code that does a database read/write. During automated testing you want to create the database in memory, but in production, it would need to open a connection to the production database.

Rust allows us to annotate functions, imports, structs etc with #[cfg(test)] or #[cfg(not(test))]. Depending on the context of the compile and runtime of the application, rust will decide which one of these annotations is currently contextual and will execute the code beneath.

Example:

Here we define two connection methods. One for the tests and one for, well, not test.

#[cfg(not(test))]
pub fn connect_to_database(location: &str) -> Result<Connection, Error> {
    Connection::open(location)
}
#[cfg(test)]
pub fn connect_to_database(_: &str) -> Result<Connection, Error> {
    Connection::open_in_memory()
}

Running cargo test will execute:

#[cfg(test)]
pub fn connect_to_database(_: &str) -> Result<Connection, Error> {
    Connection::open_in_memory()
}

And running cargo build #( with or without --release ) will execute

#[cfg(not(test))]
pub fn connect_to_database(location: &str) -> Result<Connection, Error> {
    Connection::open(location)
}

Splitting by context like this can be used in other areas of your code as well. Maybe there are specific things that should only be executed in test scenarios.

Use this wisely.