Hands-on Rust Test Series: #2 – Supercharge Your Test Suites with Rstest

The Context

In the POC project, particularly in foo_service.rs, we often need to test the same logic against multiple inputs. Writing a separate test function for every single variation (happy path, unhappy path, edge cases) quickly becomes a maintenance nightmare.


The Problem

Standard Rust tests can lead to significant code duplication. If you have five different inputs that should all trigger the same error, writing five separate functions violates the best practice that test cases should not include duplicated code. Furthermore, “magic numbers” or hardcoded values inside multiple test functions make the suite harder to refactor.


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.

The rstest crate introduces fixture and parametrization support to Rust. As highlighted in the unit test checklist, using the “parametrized feature” is a best practice to avoid duplication. It allows you to define a single test logic and run it against a table of cases.


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: Parametrized Cases

Instead of writing multiple tests, use #[rstest] with #[case(…)]. For example, you can pass different strings to a DTO validator in the Arrange phase to ensure it handles both valid and invalid formats in one go.


// src/service/foo_service.rs
/// Scenario:
/// Executes get_result_from_third_party_api when 3rd party API response is not success
/// Expectation:
/// An [Err(())] should be returned
#[rstest]
#[case(302)]
#[case(400)]
#[case(401)]
#[case(404)]
#[case(500)]
#[tokio::test]
async fn when_get_result_from_third_party_api_and_response_is_not_success_should_return_error(
	#[case] test_case_status: u16,
) {
    ... implementation here ...
}

Pro-tip #2: Leveraging Fixtures

Rstest allows you to define fixtures—functions that set up common state. This is perfect for the Arrange step when you need a pre-configured mock or a standard JSON structure for every test, keeping your test functions small and maintainable:


// src/service/foo_service.rs
async fn when_get_result_from_third_party_api_and_response_is_not_success_should_return_error(
	#[case] test_case_status: u16,
) {
    ... implementation here ...
	mock_server
		.register(create_mock_endpoint(MOCK_URL_PATH, test_case_status, None))
		.await;
    ... implementation here ...
}

Pro-tip #3: Clean Assertions

When using rstest, your Assert phase remains clean because the expected value is passed as a parameter to the function. This follows the rule of having one clear assert statement per result value while covering multiple scenarios.


// src/service/foo_service.rs
/// Scenario:
/// Executes get_result_from_third_party_api when 3rd party API response is success
/// Expectation:
/// A [String] should be returned
#[rstest]
#[case(200)]
#[case(201)]
#[case(202)]
#[case(203)]
#[tokio::test]
async fn when_get_result_from_third_party_api_should_return_string(
	#[case] test_case_status: u16,
) {
	let mock_server = wiremock::MockServer::start().await; // creates with random port
	mock_server
		.register(create_mock_endpoint(
			MOCK_URL_PATH,
			test_case_status,
			Some(MOCK_API_RESPONSE),
		))
		.await;

	let mock_server_host = mock_server.uri();
	let mock_api_url = format!("{mock_server_host}/{MOCK_URL_PATH}");

	let foo_service = FooService {};
	let result = foo_service
		.get_result_from_third_party_api(mock_api_url)
		.await;

	assert_eq!(MOCK_API_RESPONSE, result.unwrap_or_default());

	mock_server.reset().await; // reset mock server and clean the context
}

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

  1. Add rstest to your Cargo.toml.
  2. Replace #[test] with #[rstest].
  3. Define #[case] attributes with input parameters and the expected result.
  4. Use the parameters in the Act and Assert phases.


The Results

By applying rstest, you achieve higher code coverage with less effort. It transforms a repetitive test suite into a concise, readable set of requirements, making it easier to detect changes that might break a design contract.



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.


Comments

Leave a Reply

Discover more from Gabo Gil

Subscribe now to keep reading and get access to the full archive.

Continue reading