Skip to content

Testing

Philosophy

Tests verify behavior, not implementation. Every test follows the Arrange-Act-Assert pattern and reads like a specification.

Test Layers

LayerLocationDependenciesSpeed
Unit#[cfg(test)] mod tests in source filesNone< 5s total
Integrationcrates/*/tests/*.rsPostgres (real), AWS (mocked)< 60s total
E2Etests/e2e/Running Mailman instanceVariable

Unit Tests

Pure domain logic — threading, MIME building, subject normalization, webhook signatures. No I/O.

rust
#[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.

CrateTest Files
httpattachments, auth, health, messages, rate_limit, reports, send, threads, webhooks
http (scenarios)scenario_auth, scenario_delivery, scenario_onboarding, scenario_threading (+ scenario_helpers)
bin-workerinbound, telemetry, webhook

E2E Tests

Black-box HTTP tests against any running Mailman instance. Self-cleaning, environment-agnostic.

bash
MAILMAN_E2E_BASE_URL=http://localhost:8080 cargo test --test e2e

Running Tests

bash
# Unit + integration tests (default, used by `make check`)
make test

# E2E tests — black-box HTTP against running API
make test-e2e

# Everything
make test-all

To run a specific crate or single test, use cargo nextest run directly:

bash
# Specific crate
cargo nextest run --all-features -p mailman-http

# Single test by name
cargo nextest run thread_rejects_message

Database Test Isolation

Each integration test gets its own PostgreSQL schema:

  1. Generate random schema name (e.g., test_a1b2c3d4)
  2. CREATE SCHEMA, SET search_path, run migrations
  3. Execute test
  4. DROP SCHEMA CASCADE

Tests run in parallel without interfering with each other.

Test Utilities (test-utils crate)

HelperPurpose
TestAppSpins up Axum test server with real Postgres, mocked AWS
E2EClientHTTP client for black-box API testing
FixturesFactory functions for test domains, inboxes, messages, threads
Mock adaptersIn-memory S3, SES, SQS implementations

TestApp Example

rust
#[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.rs for the full TestAppBuilder API 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) and MAILMAN_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:

rust
fn unique_subject(prefix: &str) -> String {
    format!("{}-{}", prefix, Uuid::new_v4())
}

HTTP Status Expectations

EndpointSuccessMissing AuthInvalid AuthNot FoundValidation
GET /health200200 (no auth)200--
POST /send202401401-400/422
GET /threads200401401--
GET /threads/:id200401401404-
DELETE /threads/:id204401401404-
GET /messages/:id200401401404-

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:

rust
let path = format!("/messages/{}", urlencoding::encode(&message_id));

Webhook Testing

Wiremock for HTTP Mocking

Integration tests use wiremock = "0.6" for async HTTP mocking:

rust
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:

sql
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