Testing
Philosophy
Tests verify behavior, not implementation. Every test follows the Arrange-Act-Assert pattern and reads like a specification.
Test Layers
| Layer | Location | Dependencies | Speed |
|---|---|---|---|
| Unit | #[cfg(test)] mod tests in source files | None | < 5s total |
| Integration | crates/*/tests/*.rs | Postgres (real), AWS (mocked) | < 60s total |
| E2E | tests/e2e/ | Running Mailman instance | Variable |
Unit Tests
Pure domain logic — threading, MIME building, subject normalization, webhook signatures. No I/O.
#[test]
fn thread_rejects_message_when_thread_id_mismatches() {
// Arrange
let mut thread = EmailThread::new(ThreadId::new());
let msg = EmailMessage::builder()
.thread_id(ThreadId::new())
.build();
// Act
let result = thread.add_message(msg);
// Assert
assert!(matches!(result, Err(ThreadError::ThreadMismatch)));
}Naming convention: {unit}_does_{behavior}_when_{condition}
Integration Tests
Test the full request → handler → repository → database flow. Uses real Postgres with schema-per-test isolation, mocked AWS.
| Crate | Test Files |
|---|---|
http | attachments, auth, health, messages, rate_limit, reports, send, threads, webhooks |
http (scenarios) | scenario_auth, scenario_delivery, scenario_onboarding, scenario_threading (+ scenario_helpers) |
bin-worker | inbound, telemetry, webhook |
E2E Tests
Black-box HTTP tests against any running Mailman instance. Self-cleaning, environment-agnostic.
MAILMAN_E2E_BASE_URL=http://localhost:8080 cargo test --test e2eRunning Tests
# Unit + integration tests (default, used by `make check`)
make test
# E2E tests — black-box HTTP against running API
make test-e2e
# Everything
make test-allTo run a specific crate or single test, use cargo nextest run directly:
# Specific crate
cargo nextest run --all-features -p mailman-http
# Single test by name
cargo nextest run thread_rejects_messageDatabase Test Isolation
Each integration test gets its own PostgreSQL schema:
- Generate random schema name (e.g.,
test_a1b2c3d4) CREATE SCHEMA,SET search_path, run migrations- Execute test
DROP SCHEMA CASCADE
Tests run in parallel without interfering with each other.
Test Utilities (test-utils crate)
| Helper | Purpose |
|---|---|
TestApp | Spins up Axum test server with real Postgres, mocked AWS |
E2EClient | HTTP client for black-box API testing |
| Fixtures | Factory functions for test domains, inboxes, messages, threads |
| Mock adapters | In-memory S3, SES, SQS implementations |
TestApp Example
#[sqlx::test]
async fn send_queues_message_and_returns_id(pool: PgPool) {
// Arrange
let state = TestAppBuilder::new(pool).build();
let app = router_with_state(state);
let payload = json!({
"to": [{"address": "user@example.com"}],
"subject": "Test",
"body_text": "Hello"
});
// Act
let request = authenticated_json_request(Method::POST, "/send", &payload);
let response = app.oneshot(request).await.unwrap();
// Assert
assert_eq!(response.status(), StatusCode::ACCEPTED);
}See
crates/test-utils/src/lib.rsfor the fullTestAppBuilderAPI and request helpers (authenticated_request,authenticated_json_request,unauthenticated_request).
E2E Testing
E2E tests are black-box tests via HTTP API only, located in tests/e2e/ as a separate workspace member. They have no internal crate dependencies.
E2EClient
- Configured via
MAILMAN_E2E_BASE_URL(default:http://localhost:8080) andMAILMAN_E2E_API_KEY - Auto-adds auth header; separate methods for unauthenticated requests
Test Isolation
Use Uuid::new_v4() in subjects and email addresses to prevent collision between parallel tests:
fn unique_subject(prefix: &str) -> String {
format!("{}-{}", prefix, Uuid::new_v4())
}HTTP Status Expectations
| Endpoint | Success | Missing Auth | Invalid Auth | Not Found | Validation |
|---|---|---|---|---|---|
| GET /health | 200 | 200 (no auth) | 200 | - | - |
| POST /send | 202 | 401 | 401 | - | 400/422 |
| GET /threads | 200 | 401 | 401 | - | - |
| GET /threads/:id | 200 | 401 | 401 | 404 | - |
| DELETE /threads/:id | 204 | 401 | 401 | 404 | - |
| GET /messages/:id | 200 | 401 | 401 | 404 | - |
NOTE
Axum returns 422 for missing required JSON fields (deserialization failure), 400 for validation errors after parsing. These are different.
URL Encoding
Message IDs contain <>@ characters. Always use urlencoding::encode() when building paths:
let path = format!("/messages/{}", urlencoding::encode(&message_id));Webhook Testing
Wiremock for HTTP Mocking
Integration tests use wiremock = "0.6" for async HTTP mocking:
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(header_exists("X-Mailman-Signature"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;TIP
Use header_exists("X-Foo") for presence checks, not header("X-Foo", any()) — the latter doesn't work with wiremock.
Signature Verification
Capture request body/headers in mock response closures, then verify the signature matches compute_signature(secret, timestamp, body). Use Arc<Mutex<>> for thread-safe state sharing in closures.
Database-Driven Retry Testing
Retry timing is controlled via the next_attempt_at column. For retry tests, manually update deliveries to be immediately eligible:
UPDATE webhook_deliveries SET next_attempt_at = NOW() WHERE ...What Not to Test
- Private functions — test through the public API
- Third-party libraries — trust serde, axum, sqlx
- Terraform — validated via
terraform plan - Exact error messages — test error types, not string contents