One of the many controversial aspects of software development is the way we test. Thanks to containers and the idea of one database per microservice, testing a running application that’s connected to a database is now an easy task, considered by many a best practice, one that allows greater confidence in the deployable.
Available tools #
Once again, rocket and sqlx have the right tools to do the job but we’re missing a bit of support or compatibility between both as well as documentation on how to build an integration testing setup using these crates.
SQLx test macro #
We’ll use the sqlx::test
macro, which provides all the boilerplate required for integration testing:
- Creates a disposable database for each test case
- Provides utilities to connect to those databases
- Cleans up the used test databases
- Executes database migrations if needed
This looks almost perfect as it pretty much does everything we need outside testing, but the actual problem is wiring these test databases with the rocket testing framework.
Rocket local client #
Rocket tests use the Local Client, which takes an initialized rocket instance as a parameter. However, we usually configure a static database address in one of the configuration sources (by default env
vars or Rocket.toml
file) and we can’t override rocket_db_pools
with the connections created by sqlx::test
.
Creating a special instance for tests #
Fortunately, one of the test modes supported by sqlx::test
take the connection details as a parameter, instead of the connection pool itself. We can use that testing mode to get the generated database name and create a dynamic configuration that will be passed to the rocket initialization.
Declaring test cases #
As mentioned above, we’ll be using the testing mode where the database connection details are passed to the test instead of the connection itself. We’ll then pass those details to a function that will produce a local client and a rocket instance that is connected to the database provided by sqlx::test
. This functionality must live on a separate function so that we can reuse it to create a new rocket instance on each test case.
There is one caveat in this approach though: For sqlx::test
to orchestrate the testing databases, a DATABASE_URL
variable needs to be declared in a .env
file for postgres and mysql as mentioned in the docs so that it knows how to connect. We’ll be using postgres in this article, which may have a slightly different implementation from mysql and sqlite.
#[sqlx::test]
async fn my_test_case(
_pg_pool_options: PgPoolOptions,
pg_connect_options: PgConnectOptions,
) -> sqlx::Result<()> {
let client = async_client_from_pg_connect_options(pg_connect_options).await;
...
Building a new rocket config #
The next step is to create a new connection string and build a configuration object that can be used by rocket to connect to the database created by sqlx::test
. PgConnectOptions
only allows us to get the database name (using the get_database()
method), so we’ll have to hardcode the rest of the connection string or read it from the .env
file. This only applies to the testing setup, so it should be an acceptable tradeoff.
With the new connection string (db_url
) we can use the rocket::config
API to create a config map that is understood by rocket (db_config
). We then merge it with rocket::Config::figment()
, which is the default configuration that is read from the env
and Rocket.toml
.
pub async fn async_client_from_pg_connect_options(
pg_connect_options: PgConnectOptions,
) -> Client {
let db_url = format!(
"postgres://postgres:example@localhost:5432/{}",
pg_connect_options.get_database().unwrap()
);
let db_config: Map<_, Value> = map! {
"url" => db_url.into(),
};
let figment = rocket::Config::figment()
.merge(("databases", map!["mydb" => db_config]));
let client = Client::tracked(rocket_from_config(figment))
.await
.expect("valid rocket instance");
return client;
}
Once the configuration object is built, the only thing left to be done is to create a new rocket instance using this new config, which is what the rocket_from_config
method will do.
Creating a rocket instance with custom config #
The hardest part is done, we now just need to change the way we create the rocket instance from using rocket::build
to using rocket::custom
. This can be done in many different ways, but to make sure that the app under test is as close as possible to the production one, the best we can do is to have a single function rocket_from_config
handle the initialization code, and then the default rocket
launcher passing Config::figment()
, which is the default figment used by rocket::build()
.
#[launch]
fn rocket() -> Rocket<Build> {
rocket_from_config(Config::figment())
}
fn rocket_from_config(figment: Figment) -> Rocket<Build> {
rocket::custom(figment)
.attach(MyDb::init())
.attach(AdHoc::try_on_ignite("SQLx Migrations", run_migrations))
.mount(
"/",
routes![
...
],
)
}
Voila! Our integration testing setup is complete and ready to use. Once the tests are run, sqlx will start creating sqlx_test_xx
numbered databases, and each test deletes the previous one, so a quick way to validate that it worked, is just to check for a database with a name like that on the local db instance. Happy Testing!