loading...

Practical Rust Web Development - Testing

werner profile image Werner Echezuría ・7 min read

Tests are one of the most important parts of the application and mystore will not be an exception. Actix web provides a few tools we can use to perform unit and integration tests.

I'll be covering integration tests, because in my opinion are more useful, they goes from input to output with a simulation of user behavior. We should avoid mocking as much as we can, because in this way we can be certain our code will work in a similar environment.

We'll start with a new file to store our database configuration, we probably will use it for other things.

tests/common/db_connection.rs:

use diesel::pg::PgConnection;
use diesel::r2d2::{ Pool, ConnectionManager, PoolError };

pub type PgPool = Pool<ConnectionManager<PgConnection>>;

fn init_pool(database_url: &str) -> Result<PgPool, PoolError> {
    let manager = ConnectionManager::<PgConnection>::new(database_url);
    Pool::builder().build(manager)
}

pub fn establish_connection() -> PgPool {
    init_pool(dotenv!("DATABASE_URL_TEST")).expect("Failed to create pool")
}

tests/common/mod.rs:

pub mod db_connection;

We need a database just for testing, so in .env file add a new variable.

src/.env:

DATABASE_URL_TEST=postgres://postgres:@localhost/mystore_test

Let's create our test database:

$ sudo su - postgres
$ createdb mystore_test

Run the migrations:

DATABASE_URL=postgres://postgres:@localhost/mystore_test  diesel migration run

In order to run our integration tests we will need a new file, lib.rs, this is because Rust treats integration tests as a separate crate and we need a way to share the code we created in tests.

Edit the Cargo.toml file with a new section indicating the name of the library we're going to be using and a dependency section for our tests:

src/Cargo.toml:

[lib]
name = "mystore_lib"
path = "src/lib.rs"

[dev-dependencies]
bytes = "0.4"
actix-http-test = "0.2.0"

src/lib.rs:

#[macro_use]
extern crate diesel;
extern crate dotenv;
extern crate serde;
extern crate serde_json;
#[macro_use] 
extern crate serde_derive;

extern crate actix;
extern crate actix_web;
extern crate bcrypt;
extern crate jsonwebtoken as jwt;
extern crate csrf_token;

#[macro_use]
extern crate dotenv_codegen;

#[macro_use] extern crate log;
extern crate env_logger;

extern crate actix_http;

pub mod schema;
pub mod db_connection;
pub mod models;
pub mod handlers;
pub mod errors;
pub mod utils;

I moved some lines from main.rs to lib.rs, now we can use the same code in integration tests. Now we can test our product endpoint, let's create a new file called product_test.rs.

tests/product_test.rs:

#[macro_use]
extern crate dotenv_codegen;

mod common;

mod test{
    use actix_http::HttpService;
    use actix_http_test::TestServer;
    use actix_web::{http, App, web};

    use std::str;
    use crate::common::db_connection::establish_connection;

    #[test]
    fn test() {
        let mut srv = TestServer::new(|| 
            HttpService::new(
                App::new()
                    .data(establish_connection())
                    .service(
                        web::resource("/products")
                            .route(web::get().to(::mystore_lib::handlers::products::index))
                    )
            )
        );

        let request = srv.get("/products");
        let mut response = srv.block_on(request.send()).unwrap();
        assert!(response.status().is_success());

        assert_eq!(
            response.headers().get(http::header::CONTENT_TYPE).unwrap(),
            "application/json"
        );

        let bytes = srv.block_on(response.body()).unwrap();
        let body = str::from_utf8(&bytes).unwrap();
        assert_eq!(body, "[]");
    }
}

Let's use TestServer to set up a simple server for our tests, we then can assert our response was successful and gives us the expected result.

If we run the tests with cargo test they will fail because we still need a way to authenticate an user.

Let's add a couple of functions, one for creating our user and the other to login.

tests/product_test.rs:

    fn create_user() {
        use diesel::RunQueryDsl;
        use ::mystore_lib::schema::users;
        use ::mystore_lib::models::user::{ NewUser, User };
        use chrono::Local;

        let connection = establish_connection();
        let pg_pool = connection.get().unwrap();

        diesel::delete(users::table).execute(&pg_pool).unwrap();

        diesel::insert_into(users::table)
            .values(NewUser {
                email: "jhon@doe.com".to_string(),
                company: "My own personal enterprise".to_string(),
                password: User::hash_password("12345678".to_string()).unwrap(),
                created_at: Local::now().naive_local()
            })
            .get_result::<User>(&pg_pool).unwrap();
    }

    fn login(mut srv: RefMut<TestServerRuntime>) -> (HeaderValue, Cookie) {
        let request = srv
                          .post("/auth")
                          .header(header::CONTENT_TYPE, "application/json")
                          .timeout(std_duration::from_secs(600));
        let response =
            srv
                .block_on(request.send_body(r#"{"email":"jhon@doe.com","password":"12345678"}"#))
                .unwrap();
        let csrf_token = response.headers().get("x-csrf-token").unwrap();
        let cookies = response.cookies().unwrap();
        let cookie = cookies[0].clone().into_owned().value().to_string();

        let request_cookie = Cookie::build("mystorejwt", cookie)
                                         .domain("localhost")
                                         .path("/")
                                         .max_age(Duration::days(1).num_seconds())
                                         .secure(false)
                                         .http_only(false)
                                         .finish();
        (csrf_token.clone(), request_cookie.clone())
    }

We perform a post request against auth with a json content type, we add a longer timeout to tell the test to wait for the response, then we pass the body, the authenticated user, finally we get the csrf_token and the cookie from the response to use it in the rest of the tests.

You probably noted that TestServerRuntime is wrapped with a type called RefMut, that is because Rust does not allow mutation of a variable more than once, and we're going to mutate the server variable for every request, we need to do that for the headers, cookies and attachment of the body in posts and patches, just take a look for the definition for headers, can you see the mut self in the function parameter?, that's our sign to know the server will be mutating.

Let's see an example of a forbidden mutation:

struct Books {
    name: String,
    author: String,
    subject: String,
    book_id: i32
}

fn main() {
    let mut book1 = Books { 
        name: "Rust Programming".to_string(),
        author: "Jhon Doe".to_string(),
        subject: "Programming".to_string(),
        book_id: 12
    };

    let name = change_name(&mut book1);
    change_author(&mut book1);

    println!("{}", name);
}

fn change_name(book: &mut Books) -> &String {
    book.name = "Great Rust Programming".to_string();
    &book.name
}

fn change_author(book: &mut Books) {
    book.author = "Fulano".to_string();
}

If you try to compile the previous example you will get:

error[E0499]: cannot borrow `book1` as mutable more than once at a time
  --> src/main.rs:17:19
   |
16 |     let name = change_name(&mut book1);
   |                            ---------- first mutable borrow occurs here
17 |     change_author(&mut book1);
   |                   ^^^^^^^^^^ second mutable borrow occurs here
18 |     
19 |     println!("{}", name);
   |                    ---- first borrow later used here

Let's add the rest of the functions we'll be needing to test the products endpoints.


    fn clear_products() {
        use diesel::RunQueryDsl;
        use ::mystore_lib::schema::products;

        let connection = establish_connection();
        let pg_pool = connection.get().unwrap();
        diesel::delete(products::table).execute(&pg_pool).unwrap();
    }

    fn create_a_product(mut srv: RefMut<TestServerRuntime>,
                            csrf_token: HeaderValue,
                            request_cookie: Cookie,
                            product: &NewProduct) -> Product {

        let request = srv
                          .post("/products")
                          .header(header::CONTENT_TYPE, "application/json")
                          .header("x-csrf-token", csrf_token.to_str().unwrap())
                          .cookie(request_cookie)
                          .timeout(std_duration::from_secs(600));

        let mut response =
            srv
                .block_on(request.send_body(json!(product).to_string()))
                .unwrap();

        assert!(response.status().is_success());

        let bytes = srv.block_on(response.body()).unwrap();
        let body = str::from_utf8(&bytes).unwrap();
        serde_json::from_str(body).unwrap()
    }

    fn show_a_product(mut srv: RefMut<TestServerRuntime>,
                          csrf_token: HeaderValue,
                          request_cookie: Cookie,
                          id: &i32,
                          expected_product: &Product) {

        let request = srv
                        .get(format!("/products/{}", id))
                        .header("x-csrf-token", csrf_token.to_str().unwrap())
                        .cookie(request_cookie);

        let mut response = srv.block_on(request.send()).unwrap();
        assert!(response.status().is_success());

        assert_eq!(
            response.headers().get(http::header::CONTENT_TYPE).unwrap(),
            "application/json"
        );

        let bytes = srv.block_on(response.body()).unwrap();
        let body = str::from_utf8(&bytes).unwrap();
        let response_product: Product = serde_json::from_str(body).unwrap();
        assert_eq!(&response_product, expected_product);
    }

    fn update_a_product(mut srv: RefMut<TestServerRuntime>,
                          csrf_token: HeaderValue,
                          request_cookie: Cookie,
                          id: &i32,
                          changes_to_product: &NewProduct) {

        let request = srv
                        .request(http::Method::PATCH, srv.url(&format!("/products/{}", id)))
                        .header(header::CONTENT_TYPE, "application/json")
                        .header("x-csrf-token", csrf_token.to_str().unwrap())
                        .cookie(request_cookie)
                        .timeout(std_duration::from_secs(600));

        let response =
            srv
                .block_on(request.send_body(json!(changes_to_product).to_string()))
                .unwrap();
        assert!(response.status().is_success());
    }

    fn destroy_a_product(mut srv: RefMut<TestServerRuntime>,
                          csrf_token: HeaderValue,
                          request_cookie: Cookie,
                          id: &i32) {
        let request = srv
                        .request(http::Method::DELETE, srv.url(&format!("/products/{}", id)))
                        .header(header::CONTENT_TYPE, "application/json")
                        .header("x-csrf-token", csrf_token.to_str().unwrap())
                        .cookie(request_cookie)
                        .timeout(std_duration::from_secs(600));

        let response =
            srv
                .block_on(request.send())
                .unwrap();
        assert!(response.status().is_success());
    }

    fn products_index(mut srv: RefMut<TestServerRuntime>,
                          csrf_token: HeaderValue,
                          request_cookie: Cookie,
                      mut data_to_compare: Vec<NewProduct>) {

        let request = srv
                        .get("/products")
                        .header("x-csrf-token", csrf_token.to_str().unwrap())
                        .cookie(request_cookie);

        let mut response = srv.block_on(request.send()).unwrap();
        assert!(response.status().is_success());

        assert_eq!(
            response.headers().get(http::header::CONTENT_TYPE).unwrap(),
            "application/json"
        );

        let bytes = srv.block_on(response.body()).unwrap();
        let body = str::from_utf8(&bytes).unwrap();
        let mut response_products: Vec<Product> = serde_json::from_str(body).unwrap();
        data_to_compare.sort_by_key(|product| product.name.clone());
        response_products.sort_by_key(|product| product.name.clone());

        // Can you see we're doing something weird here, 
        // we're comparing two different types, expected
        // to be the same, Vec<NewProduct> == Vec<Product>
        assert_eq!(data_to_compare, response_products);
    }

In the index function we expect that a Vec<Product> to be equal to a Vec<NewProduct>, how can we expect two vectors of different types to be the same?. We can use a trait for that, PartialEq, let's see the code in models/product.rs.

src/models/product.rs:

impl PartialEq<Product> for NewProduct {
    fn eq(&self, other: &Product) -> bool {
        let new_product = self.clone();
        let product = other.clone();
        new_product.name == Some(product.name) &&
        new_product.stock == Some(product.stock) &&
        new_product.price == product.price
    }
}

Now we're going to finish our main function:

    fn test() {

        create_user();

        let csrf_token_header =
            header::HeaderName::from_lowercase(b"x-csrf-token").unwrap();

        // RefCell is the type that allows us multiple mutations
        let srv = RefCell::new(TestServer::new(move || 
            HttpService::new(
                App::new()
                    .wrap(
                        IdentityService::new(
                            CookieIdentityPolicy::new(dotenv!("SECRET_KEY").as_bytes())
                                .domain("localhost")
                                .name("mystorejwt")
                                .path("/")
                                .max_age(Duration::days(1).num_seconds())
                                .secure(false)
                        )
                    )
                    .wrap(
                        cors::Cors::new()
                            .allowed_origin("localhost")
                            .allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"])
                            .allowed_headers(vec![header::AUTHORIZATION,
                                                  header::CONTENT_TYPE,
                                                  header::ACCEPT,
                                                  csrf_token_header.clone()])
                            .expose_headers(vec![csrf_token_header.clone()])
                            .max_age(3600)
                    )
                    .data(
                        CsrfTokenGenerator::new(
                            dotenv!("CSRF_TOKEN_KEY").as_bytes().to_vec(),
                            Duration::hours(1)
                        )
                    )
                    .data(establish_connection())
                    .service(
                        web::resource("/products")
                            .route(web::get()
                                .to(::mystore_lib::handlers::products::index))
                            .route(web::post()
                                .to(::mystore_lib::handlers::products::create))
                    )
                    .service(
                        web::resource("/products/{id}")
                            .route(web::get()
                                .to(::mystore_lib::handlers::products::show))
                            .route(web::delete()
                                .to(::mystore_lib::handlers::products::destroy))
                            .route(web::patch()
                                .to(::mystore_lib::handlers::products::update))
                    )
                    .service(
                        web::resource("/auth")
                            .route(web::post()
                                .to(::mystore_lib::handlers::authentication::login))
                            .route(web::delete()
                                .to(::mystore_lib::handlers::authentication::logout))
                    )

            )
        ));

        let (csrf_token, request_cookie) = login(srv.borrow_mut());
        clear_products();

        let shoe = NewProduct {
            name: Some("Shoe".to_string()),
            stock: Some(10.4),
            price: Some(1892)
        };

        let hat = NewProduct {
            name: Some("Hat".to_string()),
            stock: Some(15.0),
            price: Some(2045)
        };

        let pants = NewProduct {
            name: Some("Pants".to_string()),
            stock: Some(25.0),
            price: Some(3025)
        };
        let shoe_db = create_a_product(srv.borrow_mut(),
                                       csrf_token.clone(),
                                       request_cookie.clone(),
                                       &shoe);
        let hat_db = create_a_product(srv.borrow_mut(),
                                      csrf_token.clone(),
                                      request_cookie.clone(),
                                      &hat);
        let pants_db = create_a_product(srv.borrow_mut(),
                                        csrf_token.clone(), 
                                        request_cookie.clone(), 
                                        &pants);
        show_a_product(srv.borrow_mut(), 
                       csrf_token.clone(), 
                       request_cookie.clone(), 
                       &shoe_db.id, 
                       &shoe_db);
        let updated_hat = NewProduct {
            name: Some("Hat".to_string()),
            stock: Some(30.0),
            price: Some(3025)
        };
        update_a_product(srv.borrow_mut(), 
                         csrf_token.clone(), 
                         request_cookie.clone(), 
                         &hat_db.id, 
                         &updated_hat);
        destroy_a_product(srv.borrow_mut(), 
                          csrf_token.clone(), 
                          request_cookie.clone(), 
                          &pants_db.id);
        products_index(srv.borrow_mut(), 
                       csrf_token, 
                       request_cookie, 
                       vec![shoe, updated_hat]);
    }

Conclusion

I just test the best case scenario, it's up to you to go and write the failure tests to see how our software behaves before an error, you can take a look at the full source code here.

Posted on by:

werner profile

Werner Echezuría

@werner

Ruby on Rails developer and Rust enthusiast.

Discussion

pic
Editor guide
 

Hi again,

On this one you solved about 5 questions I had! :) . Did you have undesirable interactions between tests?, in tests/product_test.rs on create_user() you created a user with a specific email after cleaning all the old users. When I was making unittest I did something like that but sometimes my tests failed "randomly", then I figured out that as some tests ran in parallel apparently one tests was cleaning just after other just created their own user making it fail, and as it was just luck it happened just sometimes. When I made a special username (unique) for each tests random fails vanished but having to make usernames for each test got annoying really fast (after 3 actually) so I ended generating random usernames in a sort of "factory". That solver the problem. I'm not sure if all happened because of something I missed or if I just posted something potentially helpful.

 

Well, I just had the regular problems about the borrow checker, that's why I figured it out I would need RefCell. However, I think the test run synchronously, did you use the to method or to_async route method?

 

nope, just to(), but it was actually on unittest on Diesel only tasks, it's not about data races inside Rust but data races with the database is that makes sense, the unittests ran in parallel (that can be changed but would be slower) and as all tests used the same database, when 1 test cleared a table sometimes it did it just after another tests just created an entry for himself, other times 1 test tryied to create a user but other test had just created another one with the same name, etc. So I don't think is something relevant on runtime just on tests

Oh, ok, I understand, did you try to handle the parallelism to a continuous integration server? CircleCI is great with that.

no really, I'm gonna check it out, even tho with the random username got solved but I like the idea of running my tests outside to get my weakling PC a rest