Part 5: Testing
Part 5: Testing
In this tutorial, we'll write automated tests using modern Rust testing tools: rstest for fixtures and TestContainers for database isolation.
Why Testing Matters
Tests help you:
- Save time: Automated tests catch bugs faster than manual testing
- Prevent bugs: Tests illuminate unexpected behavior before production
- Build confidence: Well-tested code is easier to modify and extend
- Enable collaboration: Tests protect against accidental breakage by teammates
Test Dependencies
Add testing dependencies to Cargo.toml:
[dev-dependencies]
rstest = { workspace = true }
reinhardt-test = { workspace = true }
testcontainers = { workspace = true }
tokio = { version = "1", features = ["full", "test-util"] }Writing Your First Test
Let's identify a bug in our was_published_recently() method. It returns true for questions whose pub_date is in the future, which is incorrect.
Create polls/tests.rs:
use super::models::Question;
use chrono::{Duration, Utc};
use rstest::*;
#[rstest]
fn test_was_published_recently_with_future_question() {
// Arrange
let future_date = Utc::now() + Duration::days(30);
let mut question = Question::new("Future question");
// Model fields are accessed via accessor methods generated by the `#[model]` macro
question.set_pub_date(future_date);
// Act
let result = question.was_published_recently();
// Assert
assert_eq!(result, false);
}
#[rstest]
fn test_was_published_recently_with_old_question() {
// Arrange
let old_date = Utc::now() - Duration::days(2);
let mut question = Question::new("Old question");
question.set_pub_date(old_date);
// Act
let result = question.was_published_recently();
// Assert
assert_eq!(result, false);
}
#[rstest]
fn test_was_published_recently_with_recent_question() {
// Arrange
let recent_date = Utc::now() - Duration::hours(23);
let mut question = Question::new("Recent question");
question.set_pub_date(recent_date);
// Act
let result = question.was_published_recently();
// Assert
assert_eq!(result, true);
}Run the tests:
cargo test --package pollsYou'll see that the first test fails. The bug is already fixed in our implementation from Part 2:
impl Question {
/// Check if this question was published recently (within the last day)
pub fn was_published_recently(&self) -> bool {
let now = Utc::now();
let one_day_ago = now - chrono::Duration::days(1);
// Fixed: Also check that pub_date is not in the future
self.pub_date >= one_day_ago && self.pub_date <= now
}
}Why TestContainers?
Reinhardt uses TestContainers for database testing to ensure test isolation and reliability. TestContainers automatically manages Docker containers for your tests.
Benefits:
Isolation - Each test gets a fresh database
- No shared state between tests
- Tests can run in parallel safely
- No cleanup code needed
Real Database - Tests use actual PostgreSQL/MySQL, not mocks
- Catches database-specific bugs (SQL syntax, transactions, indexes)
- Tests behavior matches production exactly
- No surprises when deploying to production
CI/CD Friendly - Works anywhere Docker is available
- GitHub Actions, GitLab CI, local development
- No manual database setup required
- Consistent behavior across environments
Automatic Cleanup - Containers are destroyed after tests
- No leftover data or processes
- No manual cleanup scripts needed
- Tests are self-contained
How it works:
Test starts → Docker container launches → Test runs → Container auto-destroyed
↓
Fresh PostgreSQL
with migrations appliedPrerequisites:
- Docker must be running (Docker Desktop on Mac/Windows, or Docker Engine on Linux)
- No manual database setup needed - TestContainers handles everything
Alternative (Not Recommended):
// ❌ Shared database - leads to test failures
let conn = DatabaseConnection::connect("postgres://localhost/test_db").await?;
// Multiple tests compete for same data
// Tests fail randomly due to race conditionsTestContainers Approach (Recommended):
// ✅ Isolated database per test
#[rstest]
#[tokio::test]
async fn test_user(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Each test gets its own PostgreSQL instance!
}For more details on testing infrastructure, see the project's testing standards documentation.
Testing with Database using rstest + TestContainers
Let's test database operations using rstest fixtures and TestContainers for isolation.
Understanding reinhardt-test Fixtures
Reinhardt provides shared fixtures in reinhardt-test/src/fixtures.rs:
use rstest::*;
use reinhardt_testkit::fixtures::testcontainers::postgres_container;
use testcontainers::{ContainerAsync, GenericImage};
use std::sync::Arc;
#[fixture]
async fn postgres_container() -> (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String) {
// Automatically starts PostgreSQL container
// Returns container handle, connection pool, port, and database URL
}Available fixtures:
postgres_container- PostgreSQL database in Docker containersqlite_fixture- SQLite in-memory databasemysql_fixture- MySQL database in Docker container
Using Fixtures in Tests
Create polls/tests/database_tests.rs:
use rstest::*;
use reinhardt_testkit::fixtures::testcontainers::postgres_container;
use testcontainers::{ContainerAsync, GenericImage};
use std::sync::Arc;
use chrono::Utc;
use crate::models::{Question, Choice};
#[rstest]
#[tokio::test]
async fn test_create_and_retrieve_question(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Create a question using the auto-generated new() function
// pub_date is auto-set by #[field(auto_now_add = true)]
let mut question = Question::new("What's your favorite language?");
question.save(&conn).await.unwrap();
let question_id = question.id();
// Retrieve it
let retrieved = Question::objects()
.get(question_id)
.first()
.await
.unwrap()
.expect("Question not found");
assert_eq!(retrieved.question_text(), "What's your favorite language?");
// Container is automatically cleaned up when test ends
}
#[rstest]
#[tokio::test]
async fn test_question_choices_relationship(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Create question
let mut question = Question::new("Test question");
question.save(&conn).await.unwrap();
let question_id = question.id();
// Add choices
let mut choice1 = Choice::new("Rust", 0, question_id);
choice1.save(&conn).await.unwrap();
let mut choice2 = Choice::new("Python", 0, question_id);
choice2.save(&conn).await.unwrap();
let mut choice3 = Choice::new("Go", 0, question_id);
choice3.save(&conn).await.unwrap();
// Retrieve choices using generated accessor
let choices_accessor = Choice::question_accessor().reverse(&question, &conn);
let choices = choices_accessor.all().await.unwrap();
assert_eq!(choices.len(), 3);
assert_eq!(choices[0].choice_text(), "Rust");
}
#[rstest]
#[tokio::test]
async fn test_increment_votes(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
let mut question = Question::new("Test");
question.save(&conn).await.unwrap();
let mut choice = Choice::new("Option A", 0, question.id());
choice.save(&conn).await.unwrap();
assert_eq!(choice.votes(), 0);
// Increment votes
choice.vote();
assert_eq!(choice.votes(), 1);
choice.vote();
assert_eq!(choice.votes(), 2);
}Key points:
#[rstest]- Enables fixture injection#[future]- Required for async fixtures.await- Don't forget to await the fixture!- Container cleanup is automatic via RAII
Using SQLite Fixtures (Alternative)
For projects without migrations (like examples-tutorial-basis), you can use SQLite fixtures with model-based table creation:
use rstest::*;
use reinhardt_testkit::fixtures::testcontainers::sqlite_with_models;
use reinhardt::db::backends::DatabaseConnection;
use std::sync::Arc;
use chrono::Utc;
use crate::models::{Question, Choice};
// Create custom fixture for polls app
#[fixture]
async fn polls_sqlite(
#[future] sqlite_with_models: Arc<DatabaseConnection>
) -> Arc<DatabaseConnection> {
sqlite_with_models.await
}
#[rstest]
#[tokio::test]
async fn test_create_question_sqlite(
#[future] polls_sqlite: Arc<DatabaseConnection>
) {
let conn = polls_sqlite.await;
// Create a question using the auto-generated new() function
// pub_date is auto-set by #[field(auto_now_add = true)]
let mut question = Question::new("What's your favorite language?");
question.save(&conn).await.unwrap();
assert!(question.id() > 0);
// Retrieve it
let retrieved = Question::objects()
.get(question.id())
.first()
.await
.unwrap()
.expect("Question not found");
assert_eq!(retrieved.question_text(), "What's your favorite language?");
}
#[rstest]
#[tokio::test]
async fn test_question_choices_relationship_sqlite(
#[future] polls_sqlite: Arc<DatabaseConnection>
) {
let conn = polls_sqlite.await;
// Create question
let mut question = Question::new("Test question");
question.save(&conn).await.unwrap();
// Add choices (choice_text, votes, question_id)
let mut choice1 = Choice::new("Rust", 0, question.id());
choice1.save(&conn).await.unwrap();
let mut choice2 = Choice::new("Python", 0, question.id());
choice2.save(&conn).await.unwrap();
// Retrieve choices using generated accessor
let choices_accessor = Choice::question_accessor().reverse(&question, &conn);
let choices = choices_accessor.all().await.unwrap();
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].choice_text, "Rust");
assert_eq!(choices[1].choice_text, "Python");
}Key Differences from PostgreSQL:
- In-memory database: SQLite runs entirely in memory (no container)
- Faster startup: No Docker container overhead
- Model-based tables: Tables created from model definitions, not migrations
- Simpler teardown: Database disappears when test ends
When to use SQLite vs PostgreSQL:
| Feature | SQLite | PostgreSQL |
|---|---|---|
| Speed | Very fast (in-memory) | Slower (container startup) |
| Isolation | Process-level | Container-level |
| Production parity | Low | High |
| Migrations | Not required | Required |
| Best for | Unit tests, simple integration tests | Full integration tests, pre-production validation |
Testing Views with rstest
Create polls/tests/view_tests.rs:
use rstest::*;
use reinhardt_testkit::fixtures::testcontainers::postgres_container;
use testcontainers::{ContainerAsync, GenericImage};
use reinhardt::http::{Request, Response};
use std::sync::Arc;
use chrono::Utc;
use bytes::Bytes;
use hyper::{HeaderMap, Method, Version};
use crate::models::Question;
use crate::views;
#[rstest]
#[tokio::test]
async fn test_index_no_questions(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Call index view with empty database
let response = views::index(conn).await.unwrap();
assert_eq!(response.status(), 200);
// Verify response body contains "No polls"
}
#[rstest]
#[tokio::test]
async fn test_index_with_questions(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Create test data
let mut q1 = Question::new("Test question 1");
q1.save(&conn).await.unwrap();
let mut q2 = Question::new("Test question 2");
q2.save(&conn).await.unwrap();
// Call index view
let response = views::index(conn.clone()).await.unwrap();
assert_eq!(response.status(), 200);
// Verify both questions appear in response
}
#[rstest]
#[tokio::test]
async fn test_detail_not_found(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
let mut request = Request::builder()
.method(Method::GET)
.uri("/")
.version(Version::HTTP_11)
.headers(HeaderMap::new())
.body(Bytes::new())
.build()
.unwrap();
request.path_params.insert("question_id".to_string(), "999".to_string());
let response = views::detail(request, conn).await;
// Should return 404 for non-existent question
assert!(response.is_err() || response.unwrap().status() == 404);
}Custom Fixtures
You can create your own fixtures for common test data:
use rstest::*;
#[fixture]
fn sample_question() -> Question {
// pub_date is auto-set by #[field(auto_now_add = true)]
Question::new("Sample question")
}
#[fixture]
async fn question_with_choices(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String),
) -> (Question, Vec<Choice>) {
let (_container, pool, _port, _db_url) = postgres_container.await;
let mut question = Question::new("Test");
question.save(&conn).await.unwrap();
let question_id = question.id();
let mut choice_a = Choice::new("A", 0, question_id);
choice_a.save(&conn).await.unwrap();
let mut choice_b = Choice::new("B", 0, question_id);
choice_b.save(&conn).await.unwrap();
let mut choice_c = Choice::new("C", 0, question_id);
choice_c.save(&conn).await.unwrap();
let choices = vec![choice_a, choice_b, choice_c];
(question, choices)
}
#[rstest]
#[tokio::test]
async fn test_with_custom_fixture(
#[future] question_with_choices: (Question, Vec<Choice>)
) {
let (question, choices) = question_with_choices.await;
assert_eq!(choices.len(), 3);
assert!(question.id() > 0);
}Testing Best Practices
1. Test Organization
polls/
├── lib.rs
├── models.rs
├── views.rs
└── tests.rs # Unit tests
├── database_tests.rs # Database integration tests
└── view_tests.rs # View integration tests2. Assertion Strictness
Use exact assertions, not loose matching:
// ✅ GOOD - Exact assertion
assert_eq!(question.question_text, "Expected text");
// ❌ BAD - Loose assertion
assert!(response.contains("text"));3. Test Isolation
Each test should be independent:
// ✅ GOOD - Each test gets its own database container
#[rstest]
#[tokio::test]
async fn test_a(#[future] postgres_container: ...) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Test code
}
#[rstest]
#[tokio::test]
async fn test_b(#[future] postgres_container: ...) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Different container, complete isolation
}4. Cleanup is Automatic
TestContainers handles cleanup via RAII:
#[rstest]
#[tokio::test]
async fn test_with_container(
#[future] postgres_container: (ContainerAsync<GenericImage>, Arc<sqlx::PgPool>, u16, String)
) {
let (_container, pool, _port, _db_url) = postgres_container.await;
// Use database
// ...
// No manual cleanup needed!
// Container is automatically stopped and removed when _container drops
}Running Tests
Run all tests:
cargo test --workspaceRun specific test file:
cargo test --package polls -- database_testsRun with output:
cargo test -- --nocaptureSummary
In this tutorial, you learned:
- How to use rstest for fixture-based testing
- How to use TestContainers for database isolation
- How to use reinhardt-testkit shared fixtures (postgres_container, etc.)
- How to test models with database operations
- How to test views with dependency injection
- How to create custom fixtures for common test data
- Best practices for test organization and isolation
- The importance of automatic cleanup via RAII
Note: The example project also includes WASM component tests in
tests/wasm/usingwasm-bindgen-test. These tests verify client-side rendering with mock infrastructure. For WASM testing patterns, see the reinhardt-pages documentation.
What's Next?
Now that we have a well-tested application, let's add static files (CSS, JavaScript, images) to improve the user interface.
Continue to Part 6: Static Files.