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.
🦀 Index of the Complete Series.
Rust: retrofit integration tests to an existing actix-web application. |
🚀 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
The actix-web learning application mentioned above has been discussed in the following two (2) previous posts:
- Rust web application: MySQL server, sqlx, actix-web and tera.
- Rust: learning actix-web middleware 01.
Detail of the five (5) public routes are:
- 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"}
. - JSON response route
http://0.0.0.0:5000/data/employees/%chi/%ak
-- methodGET
. - 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
. - HTML response route
http://0.0.0.0:5000/ui/employees/%chi/%ak
-- method:GET
. - 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
– 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:
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
- Implementing Integration Tests
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"
...
⓶ 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:
#[actix_web::test]
async fn dummy_test() {
let b: bool = true;
assert_eq!(b, true);
}
🚀 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() {
...
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`
⓶ ✔️ 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() {
...
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
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:
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>,
}
⓶ 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::de::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)
✔️ 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 {
...
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"]}
...
❷ 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:
...
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)
}
...
Note the following about the run()
method:
- the return value has been changed to
Result<Server, std::io::Error>
. - 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:
use learn_actix_web::run;
#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
run()?.await
}
❹ 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:
use learn_actix_web::run;
pub fn spawn_app() {
let server = run().expect("Failed to create server");
let _ = tokio::spawn(server);
}
...
❺ 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:
#[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.");
}
}
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 removespawn_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 asthread 'post_employees_html1' panicked at 'Failed to create server: Os { code: 98,
. This error has been discussed in ZERO TO PRODUCTION IN RUST -- we need to implement dynamic port to address this problem.
kind: AddrInUse, message: "Address already in use" }', tests/common.rs:8:24
❼ 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:
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)
}
...
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:
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
}
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:
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)
}
⓸ 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();
...
}
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:
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:
- https://www.omgubuntu.co.uk/2022/09/ubuntu-2210-kinetic-kudu-default-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://www.rust-lang.org/
- https://www.pngitem.com/download/ibmJoR_rust-language-hd-png-download/