#Integration tests with Actix and Diesel
Rust’s compiler is doing an incredible job at catching bugs at compile-time. So much that I always feel confident about my code when it compiles. Although chances for runtime errors are really small in Rust (unless you are a fan of unsafe), we can still experience logical errors. To make sure we are handling those errors as well, we can create integration tests.
Since we already know how to create a REST API, we can go straight to creating some tests for that.
# Before running test
Before we run our tests we might want to initiate our database or other things. To do this we might be able to use custom_test_frameworks in the future, but for now, we will create a function that we need to at the beginning of each test. We also need to make sure that this function is only doing the initiation if the is not initiated by any other test yet.
// src/test.rs
use crate::db;
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use dotenv::dotenv;
lazy_static! {
static ref INITIATED: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
}
#[cfg(test)]
pub fn init() {
let mut initiated = INITIATED.lock().unwrap();
if *initiated == false {
dotenv().ok();
db::init();
*initiated = true;
}
}
Here we are using a lazy static to check if we have initiated the tests or not. Since tests in Rust will run in parallel by default, we also need make this variable thread-safe. To achieve that we are warping this variable in an Arc<Mutex>
.
Also, note the #[cfg(test)]
annotation which is telling the compiler to only compile this function if we are running tests.
We also need to add this module to our main file.
// src/main.rs
#[cfg(test)]
mod test;
// ..
# Creating our first tests
Note
test::read_body_json()
and test::TestRequest::send_request()
are new functions that is not currently part of actix-web. I have created a PR, so we will hopefully soon see them as a part of actix-web.
In the meantime you can add this too your Cargo.toml
actix-web = { version = "2.0", git = "https://github.com/thecloudmaker/actix-web", branch = "integration-tests" }
Now let’s define our first tests. We can define a test in Rust by creating a function and annotating it with #[test]
. This annotation does not work for async functions, so to support that we will be using the #[actix_rt::test]
annotation.
Now that we know how to create tests, we can create our first ones for our user endpoints.
// src/user/tests.rs
#[cfg(test)]
mod tests {
use crate::user::*;
use actix_web::{test::{self, TestRequest}, App};
use serde_json::json;
#[actix_rt::test]
async fn test_user() {
crate::test::init();
let request_body = json!({
"email": "[email protected]",
"password": "test",
});
let mut app = test::init_service(App::new().configure(init_routes)).await;
let resp = TestRequest::post().uri("/users").set_json(&request_body).send_request(&mut app).await;
assert!(resp.status().is_success(), "Failed to create user");
let user: User = test::read_body_json(resp).await;
let resp = TestRequest::post().uri("/users").set_json(&request_body).send_request(&mut app).await;
assert!(resp.status().is_client_error(), "Should not be possible to create user with same email twice");
let resp = TestRequest::get().uri(&format!("/users/{}", user.id)).send_request(&mut app).await;
assert!(resp.status().is_success(), "Failed to find user");
let user: User = test::read_body_json(resp).await;
assert_eq!(user.email, "[email protected]", "Found wrong user");
let request_body = json!({
"email": "[email protected]",
"password": "new",
});
let resp = TestRequest::put().uri(&format!("/users/{}", user.id)).set_json(&request_body).send_request(&mut app).await;
assert!(resp.status().is_success(), "Failed to update user");
let user: User = test::read_body_json(resp).await;
assert_eq!("new", user.password, "Failed to change password for user");
let resp = TestRequest::delete().uri(&format!("/users/{}", user.id)).send_request(&mut app).await;
assert!(resp.status().is_success(), "Failed to delete user");
let resp = TestRequest::get().uri(&format!("/users/{}", user.id)).send_request(&mut app).await;
assert!(resp.status().is_client_error(), "It should not be possible to find the user after deletion");
}
}
Here we are first initiating a test server and the routes for our user endpoints with test::init_service
. Then we can use TestRequest
the send test requests.
Our first test is to create a user, which we also are going to use as a base for the remaining tests. The second test is to check that we should not be able to create a second user with the same email address.
The following tests check that we are able to find the user, update it and then, in the end, delete it. Here we are also using the deletion test to clean up after ourselves. If we wouldn't have clean up after this test, we would run in to a problem the second time we run the test. This is since the first test then will be trying to create a user, that we have already created the first time we run the test.
Before we can run the tests we also need to remember to add the tests to the user module.
// src/user/mod.rs
mod tests;
// ..
Now let’s run the tests.
$ cargo test
# Is there an easier way to clean up between tests?
Let’s say we create a lot of data throughout our tests and that it does not make sense to create a test that cleans it up afterward. Which alternatives do we have then? In fact, we have several. And I am going to mention a few.
One way could be to drop all the databases before we run the tests. That will give us a blank slate since our init function is already will take care of running the migrations again before the tests run.
Another way could be to spin up a couple of Docker containers and run them inside of them. And then throw them away after each test.
The last option I will mention is to start a database transaction for our tests that will never be committed. I will show how we can use Diesel to help us with that.
Note
There is an open issue in Diesel which is causing problems for tests that are verifying that the database correctly is giving back an error message.
E.g. our test for making sure that we are not able to create a user with the same email twice. So we should comment out that test until this issue is solved.
Let’s start by commenting out our last test for deleting the user. If we now run the test twice you will notice that it will fail the second time, with the message “Failed to create user”. This is since we are trying to create a user that already exists.
Now let’s start the test transaction with begin_test_transaction()
. A transaction cannot be shared between multiple connections, so we also need to set the connection pool size to one, to make sure that all our tests are using the same connection.
// src/db.rs
use diesel::prelude::*;
// ..
lazy_static! {
static ref POOL: Pool = {
let db_url = env::var("DATABASE_URL").expect("Database url not set");
let manager = ConnectionManager::<PgConnection>::new(db_url);
let pool_size = match cfg!(test) {
true => 1,
false => 10,
};
r2d2::Builder::new().max_size(pool_size).build(manager).expect("Failed to create db pool")
};
}
pub fn init() {
info!("Initializing DB");
lazy_static::initialize(&POOL);
let conn = connection().expect("Failed to get db connection");
if cfg!(test) {
conn.begin_test_transaction().expect("Failed to start transaction");
}
embedded_migrations::run(&conn).unwrap();
}
Here we are using the cfg macro to start the test transaction and set the pool size to one. In general, I would advise to only use the #[cfg(test)]
attribute and cfg!(test)
macro with caution, except for the actual tests. The reason is that you can write code that will pass the test but might not even compile.
You can for example try to put the #[cfg(test)]
annotation on top of the database init function. If you now run cargo test
everything will seem like it works, but what if you now run cargo run
?
Anyway, you should now also be able to see that we now can run cargo test
multiple times, without failing since our data never will be committed to the database.