DEV Community

Cover image for Rust: retrofit integration tests to an existing actix-web application.
Be Hai Nguyen
Be Hai Nguyen

Posted on

Rust: retrofit integration tests to an existing actix-web application.

We've previously built an actix-web “application”, which has five (5) public POST and GET routes. We didn't implement any test at all. We're now retrofitting proper integration tests for these existing 5 (five) public routes.

🚀 Please note, complete code for this post can be downloaded from GitHub with:

git clone -b v0.3.0 https://github.com/behai-nguyen/rust_web_01.git
Enter fullscreen mode Exit fullscreen mode

The actix-web learning application mentioned above has been discussed in the following two (2) previous posts:

  1. Rust web application: MySQL server, sqlx, actix-web and tera.
  2. Rust: learning actix-web middleware 01.

Detail of the five (5) public routes are:

  1. JSON response route http://0.0.0.0:5000/data/employees -- method: POST; content type: application/json; request body: {"last_name": "%chi", "first_name": "%ak"}.
  2. JSON response route http://0.0.0.0:5000/data/employees/%chi/%ak -- method GET.
  3. HTML response route http://0.0.0.0:5000/ui/employees -- method: POST; content type: application/x-www-form-urlencoded; charset=UTF-8; request body: last_name=%chi&first_name=%ak.
  4. HTML response route http://0.0.0.0:5000/ui/employees/%chi/%ak -- method: GET.
  5. HTML response route http://0.0.0.0:5000/helloemployee/%chi/%ak -- method: GET.

The code we're developing in this post is a continuation of the code from the second post above. 🚀 To get the code of this second, please use the following command:

git clone -b v0.2.0 https://github.com/behai-nguyen/rust_web_01.git
Enter fullscreen mode Exit fullscreen mode

-- Note the tag v0.2.0.

This post introduces a new module src/lib.rs, and a new directory tests/ to the project. The final directory layout's in the screenshot below:

093-01.png

It takes me several iterations to finally figure how to get the test code to work. In this post, I organise the process into logical steps rather than the steps which I have actually tried out.

It turns out there're quite a bit of refactorings to do, in order to get the existing application code into a state where it makes sense to add integration tests. This is a consequence of not having tests in the first place.

Table of contents

Code Refactoring in Readiness for Integration Tests


❶ Fixing application crate name and verifying test module gets recognised.

“The Book”, chapter 10, Writing Automated Tests discusses testing, section Test Organization discusses directory structure for integration tests.


⓵ Before starting this post, I reread this chapter, and realise that the package name in Cargo.toml is not right: it uses hyphens -- where underscores should be used:

[package]
name = "learn_actix_web"
...
Enter fullscreen mode Exit fullscreen mode


⓶ Also, the above chapter illustrates a simple integration test. I'm not certain if it'll work for this project. I have to test it out.

Create a new tests/ directory at the same level as src/. And then in this tests/ directory create a new file test_handlers.rs, add a dummy test to verify that the new test module gets recognised.

Content of tests/test_handlers.rs:
Enter fullscreen mode Exit fullscreen mode
#[actix_web::test]
async fn dummy_test() {
    let b: bool = true;
    assert_eq!(b, true);
}
Enter fullscreen mode Exit fullscreen mode

🚀 Run a test with the command cargo test, dummy_test() passes. 👎 But the three (3) existing Doc-tests fail, we'll come back to these in a later section.


❷ Referencing (importing) the application crate learn_actix_web.


⓵ Add use learn_actix_web; to tests/test_handlers.rs:

//...
//...
use learn_actix_web;

#[actix_web::test]
async fn dummy_test() {
...
Enter fullscreen mode Exit fullscreen mode

The compiler complains:

error[E0432]: unresolved import `learn_actix_web`
 --> tests\test_handlers.rs:3:5
  |
3 | use learn_actix_web;
  |     ^^^^^^^^^^^^^^^ no external crate `learn_actix_web`
Enter fullscreen mode Exit fullscreen mode


⓶ ✔️ To fix this error, simply create a new empty file src/lib.rs.

The build command cargo build should now run successfully.


❸ Referencing (importing) an application module.

Now, in tests/test_handlers.rs, change use learn_actix_web; to use learn_actix_web::config;, i.e.:

//...
//...
use learn_actix_web::config;

#[actix_web::test]
async fn dummy_test() {
...
Enter fullscreen mode Exit fullscreen mode

The compiler complains:

error[E0432]: unresolved import `learn_actix_web::config`
 --> tests\test_handlers.rs:3:5
  |
3 | use learn_actix_web::config;
  |     ^^^^^^^^^^^^^^^^^^^^^^^ no `config` in the root
Enter fullscreen mode Exit fullscreen mode

src/main.rs is the binary. src/lib.rs is the library, it's the root for the crate / package learn_actix_web, (I do hope I've this correctly); we need to carry out the following steps to fix this error.

⓵ Move all existing mod imports and struct AppState in src/main.rs to src/lib.rs and make them all public.

Content of src/lib.rs:
Enter fullscreen mode Exit fullscreen mode
use sqlx::{Pool, MySql};

pub mod config;
pub mod database;
pub mod utils;
pub mod models;
pub mod handlers;

pub mod middleware;

pub struct AppState {
    db: Pool<MySql>,
}
Enter fullscreen mode Exit fullscreen mode

⓶ Update src/main.rs:

● remove use sqlx::{Pool, MySql};

● add use learn_actix_web::{config, database, AppState, middleware, handlers};

The compiler should now accept use learn_actix_web::config; import in tests/test_handlers.rs.


❹ Fixing the existing three (3) Doc-tests errors mentioned in step ❶.⓶ above.


⓵ In src/config.rs, update /// mod config; to /// use learn_actix_web::config;.


⓶ In src/database.rs, update /// mod database; to /// use learn_actix_web::database;.


⓷ In src/models.rs, there're several updates:

● Replace /// mod models; and /// use models::get_employees; with /// use learn_actix_web::models::get_employees;.

● Bug fix. Change /// let query_result = task::block_on(get_employees(pool, "nguy%", "be%")); to /// let query_result = task::block_on(get_employees(&pool, "nguy%", "be%"));; i.e. update parameter pool to &pool.

● Update /// mod database; to /// use learn_actix_web::database;.

All tests should now pass.


❺ In src/models.rs, enable deserialisation for the struct Employee.

Some of the routes return employee data in JSON format. Some of the tests would require deserialising JSON data into struct Employee, which does not yet implement the serde:🇩🇪:Deserialize trait.

If we just add the Deserialize macro to struct Employee, the compiler will complain:

error[E0425]: cannot find function `deserialize` in module `utils::australian_date_format`
  --> src\models.rs:17:26
   |
17 |   #[derive(FromRow, Debug, Deserialize, Serialize)]
   |                            ^^^^^^^^^^^ help: a function with a similar name exists: `serialize`
   |
  ::: src\utils.rs:17:5
   |
17 | /     pub fn serialize<S>(
18 | |         date: &Date,
19 | |         serializer: S,
20 | |     ) -> Result<S::Ok, S::Error>
...  |
26 | |         serializer.serialize_str(&s)
27 | |     }
   | |_____- similarly named function `serialize` defined here
   |
   = note: this error originates in the derive macro `Deserialize` (in Nightly builds, run with -Z macro-backtrace for more info)
Enter fullscreen mode Exit fullscreen mode

✔️ To address this, update src/utils.rs. Implement pub fn deserialize<'de, D>(deserializer: D,) -> Result<Date, D::Error> for mod australian_date_format.

Employee can now implement both Deserialize and Serialize, we also throw in Debug:

#[derive(FromRow, Debug, Deserialize, Serialize)]
pub struct Employee {
...
Enter fullscreen mode Exit fullscreen mode

Please note also, in src/models.rs, two (2) unit tests have also been added fn test_employee_serde() and fn test_employee_serde_failure().

We are now ready to implement actual integration tests. All test methods will be in tests/test_handlers.rs.

Implementing Integration Tests

My original plan is to follow actix-web's instructions in section Integration Testing For Applications.


I start off implementing the first test method async fn get_helloemployee_has_data(), which tests the route http://0.0.0.0:5000//helloemployee/{last_name}/{first_name}.

Eventually, it becomes clear that we need to have the App object in the test code to run tests!

I don't want to create the App object in every test! For me personally, this might introduce bugs in the tests, and this would defeat the purpose of testing.

I attempt to refactor the code so that both the application and the test code could just call some method and have the App object ready: this would guarantee the same App object code is in the application proper and the tests.

-- But this proves to be difficult! On Jun 13, 2022, someone has tried this and has also given up, please see this StackOverflow post Actix-web integration tests: reusing the main thread application. This first answer basically suggests that in the tests, we just run the application proper as is, then use reqwest crate to send requests and receiving responses from the application.

This approach has been discussed in detail by Luca Palmieri in the sample 59-page extract of his book ZERO TO PRODUCTION IN RUST. I abandon actix-web's integration test framework, and use the just mentioned approach.


❶ We'd need tokio and reqwest crates, but only for testing. It makes sense to add them to the [dev-dependencies] section of Cargo.toml:

...
[dev-dependencies]
tokio = {version = "1", features = ["full"]}
reqwest = {version = "0.11", features = ["json"]}
...
Enter fullscreen mode Exit fullscreen mode


❷ The next step is refactoring async fn main() -> std::io::Result<()> into a callable method which both the application and test code can call. This method should return an actix_web::dev::Server instance. Function src/main.rs's main() moved to src/lib.rs, and renamed to run():

src/lib.rs with run() method added:
Enter fullscreen mode Exit fullscreen mode
...
pub fn run() -> Result<Server, std::io::Error> {
    ...
    let server = HttpServer::new(move || {
            // Everything remains the same.
    })
    .bind(("0.0.0.0", 5000))?
    .run();

    Ok(server)
}
...
Enter fullscreen mode Exit fullscreen mode

Note the following about the run() method:

  • the return value has been changed to Result.
  • it isn't async, therefore no .await call.
  • if no error occurs, it returns Ok(server) (of course!)


❸ Now main() should now call run().

Content of src/main.rs:
Enter fullscreen mode Exit fullscreen mode
use learn_actix_web::run;

#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
    run()?.await
}
Enter fullscreen mode Exit fullscreen mode


❹ The next step is the spawn_app() method which evokes the application server during tests. I anticipate having more integration test modules in the future, so I have spawn_app() in tests/common.rs, among other smaller helper methods.

tests/common.rs with spawn_app() method:
Enter fullscreen mode Exit fullscreen mode
use learn_actix_web::run;

pub fn spawn_app() {
    let server = run().expect("Failed to create server");
    let _ = tokio::spawn(server);
}
...
Enter fullscreen mode Exit fullscreen mode


❺ Now, we can finish off the first integration test method async fn get_helloemployee_has_data(), which has been attempted previously:

tests/test_handlers.rs with get_helloemployee_has_data() method:
Enter fullscreen mode Exit fullscreen mode


#[actix_web::test]
async fn get_helloemployee_has_data() {
    let root_url = "http://localhost:5000";

    spawn_app();

    let client = reqwest::Client::new();

    let response = client
        .get(make_full_url(root_url, "/helloemployee/%chi/%ak"))
        .send()
        .await
        .expect("Failed to execute request.");    

    assert_eq!(response.status(), StatusCode::OK);

    let res = response.text().await;
    assert!(res.is_ok(), "Should have a HTML response.");

    // This should now always succeed.
    if let Ok(html) = res {
        assert!(html.contains("Hi first employee found"), "HTML response error.");
    }
}
Enter fullscreen mode Exit fullscreen mode

Crate reqwest is feature rich. It seems to handle all HTTP methods, all request content types as well as all response content types. To finish off the other integration test methods, I have to spend times reading the documentation, but they follow pretty much the same pattern. I won't list out the rest of the code, please see them for yourself.


❻ 💥 I would like to point out the following.

  • spawn_app() behaves like having the actual application server running. That is, if we remove spawn_app() from the test code, and run the application server instead, the tests would still run. (This should be apparent also from how reqwest is used.)
  • In total, there are six (6) integration tests. During a test run, spawn_app() gets called 6 (six) times. On Windows 10, this does not appear to be an issue. On Ubuntu 22.10, test methods fail at random with error messages such as thread 'post_employees_html1' panicked at 'Failed to create server: Os { code: 98,
    kind: AddrInUse, message: "Address already in use" }', tests/common.rs:8:24
    . This error has been discussed in ZERO TO PRODUCTION IN RUST -- we need to implement dynamic port to address this problem.


❼ Implement dynamic port using std::net::TcpListener.


⓵ The run() method should receive a ready to use instance of std::net::TcpListener:

src/lib.rs with run() method updated:
Enter fullscreen mode Exit fullscreen mode
use std::net::TcpListener;
...
pub fn run(listener: TcpListener) -> Result<Server, std::io::Error> {
    ...
    let server = HttpServer::new(move || {
            // Everything remains the same.
    })
    .listen(listener)?
    .run();

    Ok(server)
}
...
Enter fullscreen mode Exit fullscreen mode

Not a significant change, apart from the additional parameter, the listen(...) method is used instead of the bind(...) method.


⓶ The main() method must then instantiate an instance of std::net::TcpListener:

Content of src/main.rs:
Enter fullscreen mode Exit fullscreen mode
use std::net::TcpListener;
use learn_actix_web::run;

#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
    let listener = TcpListener::bind("0.0.0.0:5000").expect("Failed to bind port 5000");
    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();
    println!("Server is listening on port {}", port);

    run(listener)?.await
}
Enter fullscreen mode Exit fullscreen mode

For the application, we want a fixed port, we use the current port 5000, as before.


⓷ Similar to main(), spawn_app() must also instantiate an instance of std::net::TcpListener.

-- But we want the system to dynamically allocate port on the run, so we bind to port 0.

In addition, it should also formulate the root URL using the dynamically assigned port so that test methods can use this root URL to talk to the test application server.

tests/common.rs with spawn_app() updated:
Enter fullscreen mode Exit fullscreen mode
use std::net::TcpListener;
use learn_actix_web::run;

pub fn spawn_app() -> String {
    let listener = TcpListener::bind("0.0.0.0:0")
        .expect("Failed to bind random port");

    // We retrieve the port assigned to us by the OS
    let port = listener.local_addr().unwrap().port();

    let server = run(listener).expect("Failed to create server");
    let _ = tokio::spawn(server);

    format!("http://127.0.0.1:{}", port)
}
Enter fullscreen mode Exit fullscreen mode


⓸ Next, all integration test methods must be updated to use the root URL returned by spawn_app(). For example, async fn get_helloemployee_has_data() above gets a single update:

#[actix_web::test]
async fn get_helloemployee_has_data() {
    let root_url = &spawn_app();

    ...
}
Enter fullscreen mode Exit fullscreen mode

That is, instead of let root_url = "http://localhost:5000";, the root URL is now the returned value of the spawn_app() method.

All tests now pass on both Windows 10 and Ubuntu 22.10:

093-02.png
093-03.png



From this point onwards, new functionalities and their integration tests can be developed and tested at the same.

I've learned a lot during this process. I hope you find the information in this post helpful. Thank you for reading and stay safe as always.

✿✿✿

Feature image source:

Top comments (0)