The Context
While unit tests prove individual modules work, integration testing ensures that these components work together in harmony. The POC project demonstrates this through foo_get_integration_test.rs (or foo_post_integration_test.rs), which validates the complete flow from the endpoint to the 3rd party API.
The Problem
A common mistake is thinking that if all unit tests pass, the system is fine. However, modules can collapse when put together due to inconsistent logic between different programmers or erroneous data formatting between layers. Integration tests are needed to “pinpoint where the issue lies” when multiple parts interact.
The Solution
Important Disclaimer: This is a Proof of Concept (PoC) built for a specific course need. If you find this code useful and decide to use it, you do so entirely at your own risk. The authors decline any responsibility for its use.
An effective integration test combines all the tools we’ve discussed: Mockall for internal logic boundaries, Wiremock for external APIs and Rstest for covering various data flows. By following the AAA pattern, we can validate a “happy-path” flow (at least) from end to end without needing a full production environment.
Before we dive in, the full source code is available on GitHub: 👉 https://github.com/gabo-gil-playground/rust-poc-unit-and-integration-test-sample
Here are the core dependencies in Cargo.toml:
[dev-dependencies] # development dependencies - should not be exported
# mock dependencies
mockall = { version = "=0.14.0" }
# parametrized test cases dependencies
rstest = { version = "=0.26.1" }
# run test sequencially
serial_test = { version = "=3.3.1" }
# unit and integration tests util / tools dependencies
tower = { version = "=0.5.3", features = ["util", "timeout"] }
# http mock server for external API calls
wiremock = { version = "=0.6.5" }
Pro-tip #1: Strategic Mocking
In integration tests, you should use fewer mocks than in unit tests. While you might still mock a database with an in-memory SQLite instance, you want to test the actual interaction between your controllers and services. Use Wiremock for the true boundaries of your system (the network).
// tests/integration/foo_get_integration_test.rs
/// Scenario:
/// Executes map_get_foo flow with valid parameters
/// Expectation:
/// A [StatusCode::OK] value should be returned
#[tokio::test]
#[serial] // run in order just to avoid race conditions in the context
async fn when_map_get_foo_should_return_http_ok_status() {
let mock_server = wiremock::MockServer::start().await; // creates with random port
mock_server
.register(create_mock_endpoint(
MOCK_3RD_PARTY_API_PATH,
200,
Some(MOCK_3RD_PARTY_API_RESPONSE),
))
.await;
let mock_server_host = mock_server.uri();
let mock_api_url = format!("{mock_server_host}/{MOCK_3RD_PARTY_API_PATH}");
let http_request = Request::builder()
.uri(format!(
"{API_FOO_MAIN_PATH}{API_FOO_GET_ALL}?third_party_url={mock_api_url}"
))
.method(Method::GET)
.body(axum::body::Body::empty())
.unwrap();
let mut http_response = FooController::config_endpoints()
.oneshot(http_request)
.await
.unwrap();
// http status assertion
assert_eq!(StatusCode::OK, http_response.status());
// http body as json assertion
let body_as_bytes = http_response.body_mut().collect().await.unwrap().to_bytes();
let body_as_string = String::from_utf8(body_as_bytes.to_vec()).unwrap_or_default();
assert_eq!(MOCK_3RD_PARTY_API_RESPONSE, body_as_string);
mock_server.reset().await; // reset mock server and clean the context
}
/// Creates [Mock] HTTP wire-mock endpoint based on [&str] path, [u16] expected status
/// and [Option<&str>] expected response values
pub fn create_mock_endpoint(path: &str, status: u16, response: Option<&str>) -> Mock {
let mock_response =
ResponseTemplate::new(status).set_body_bytes(response.unwrap_or_default());
Mock::given(matchers::path_regex(path)).respond_with(mock_response)
}
Pro-tip #2: Avoid Control Flow Logic
A strict rule for both unit and integration tests is to avoid if, for, or while loops inside the test function. If you need to test different branches of a flow, split them into separate test cases. This ensures each test asserts “one and only one flow scenario”.
Pro-tip #3: Repeatability and Cleanup
Integration tests often involve shared resources. Be extremely careful with side effects. Use the “Close” step (even if not strictly part of AAA) to tear down resources and ensure that running the suite N times in the CI/CD pipeline always yields the same “Green” result.
// tests/integration/foo_get_integration_test.rs
...
#[serial] // run in order just to avoid race
...
To run the code, you just need to run cargo run via your terminal or favorite IDE (I personally recommend RustRover). For more details, please, check the README.md file.
Summary of Steps
- Arrange: Set up the HTTP mock server (Wiremock) and any required in-memory state.
- Act: Use a client like tower or reqwest to trigger the actual API endpoint of your app.
- Assert: Verify the final response and check that all internal state changes occurred as expected.
- Close: Ensure all servers and resources are properly shut down.
The Results
alidating end-to-end flows gives the team “improved confidence in the development cycle”. By catching bugs during the testing phase rather than maintenance, you avoid the 100x cost increase associated with late-stage defects.
Do you need help modernizing your legacy applications, optimizing your architecture or improving your development workflows? Let’s connect on LinkedIn or check out my services on Upwork.
Leave a Reply