A integration test is an external test of the public API of a library.
Unit tests (seen in the previous article) live in the same file as the code. They have a very specific advantage: they can see and test private functions.
Integration Tests are different. They live outside your source code. Rust treats them exactly as if they were external code (another Crate) that is importing your library.
- They can only call
pubfunctions. - They verify that the public API is convenient and works correctly.
- If you change the internal implementation but not the API, these tests should not break.
The tests/ folder
To create integration tests, we need a specific folder at the same level as src.
my_project/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/ <--- HERE
└── integration.rsCargo automatically knows that every .rs file inside tests/ is an integration test. No configuration is needed.
Writing the first integration test
Suppose our library (src/lib.rs) has this:
// src/lib.rs
pub fn add_two(a: i32) -> i32 {
add_internal(a, 2)
}
// Private function (not accessible from integration tests)
fn add_internal(a: i32, b: i32) -> i32 {
a + b
}Now we create the file tests/my_test.rs:
// tests/my_test.rs
// 1. We must import our library as if it were external "use crate_name"
use my_project;
#[test]
fn public_api_test() {
// We can only access what is 'pub'
assert_eq!(my_project::add_two(2), 4);
}When you run cargo test, you will see a new section:
Running tests/my_test.rs (target/debug/deps/my_test-...)
test public_api_test ... okKey differences
- We don’t need
#[cfg(test)]: Files intests/are only compiled when we run tests, so the module annotation is not necessary. - Each file is a Crate: Rust compiles each file inside
tests/as if it were an independent binary. This is important for understanding how to share code.
Sharing setup code
Imagine you have 5 test files and all of them need a setup function (for example, create_test_user()).
If you create a file tests/common.rs, Rust will think it is another test and will try to run it, which we don’t want.
The correct way to do this is by using Rust’s module structure: Create a subfolder.
File structure:
tests/
├── login_test.rs
└── common/
└── mod.rs <--- Helper codeBy placing it in common/mod.rs, Rust does not consider it an independent test, but a module that other tests can import.
In tests/common/mod.rs:
pub fn setup() {
// Code to prepare database, etc.
println!("Setting up test environment...");
}In tests/login_test.rs:
use my_project;
mod common; // Import the helper module
#[test]
fn login_works() {
common::setup(); // Call the setup
assert!(true);
}Testing binaries (main.rs)
Here is a small limitation of Rust: Integration tests are designed to test Libraries (lib.rs).
If your project is just a binary (src/main.rs), you cannot use my_project from the tests/ folder, because binaries do not expose functions.
A common structure:
If the program grows, we can move the logic to lib.rs and leave main.rs as a thin layer that calls the library.
// src/main.rs
use my_project::start;
fn main() {
// The main is so simple it doesn't need testing
start();
}This way you can test all the logic of start and its components via integration tests in the library.