Skip to content

Latest commit

 

History

History
417 lines (308 loc) · 18.5 KB

12_unit_testing.md

File metadata and controls

417 lines (308 loc) · 18.5 KB

Unit & Integration Testing Guide

This document defines the philosophy, infrastructure, and patterns for unit and integration tests across all ModKit modules. Module-specific test plans (which test cases to add, gap analysis, domain-specific assertions) live in each module's docs/features/ folder.


Philosophy

What Is a Unit Test in This Project

A unit test calls a Rust function directly — a service method, a value object constructor, an error conversion — and verifies the result. No network I/O, no running server.

This is the primary line of defense. Every domain invariant, every validation rule, every error path is covered here. E2E tests (see 13_e2e_testing.md) exist only to verify integration seams that unit tests cannot see.

Database Usage in Unit Tests

Ideally, unit tests do not touch a real database. The preference is to mock dependencies at the appropriate level:

  • Pure logic (validation, value objects, error mapping, DTO conversions) — #[test], no DB, no mocks needed
  • Service-level tests — mock the repository trait to return canned data; the service logic runs against the mock
  • Handler-level tests — mock the service; test routing, deserialization, and response shape in isolation

At the lowest level — between the repository implementation and the actual DB — using a real SQLite :memory: database is acceptable and often preferable. Running migrations against an in-memory SQLite (~1ms per DB) gives confidence that SQL is correct without the complexity of maintaining mock data structures.

Using a real DB in unit tests is undesirable but not forbidden. The final decision is left to the developer's judgment based on what produces cleaner, more readable, and more maintainable tests. A well-written service test against SQLite :memory: is better than a poorly-written test against a brittle mock. When in doubt: mock higher-level collaborators, use real SQLite only where the SQL itself needs to be verified.

Three Questions Before Adding a Test

Every test must pass all three:

  1. "Does this test verify deterministic domain logic?" If yes — it belongs here. Type validation, hierarchy invariants, field constraints, error mapping — all deterministic, all testable without HTTP.

  2. "Is this test atomic and fast?" One #[test] = one scenario. No sleep(), no timeout(), no retry loops. Each test creates its own SQLite DB and service instances. Tests run in parallel (cargo test -j N). Target: entire suite < 5 seconds.

  3. "Does removing this test reduce confidence in domain correctness?" If not — the test is redundant. Every test must guard a specific behavior that, if broken, would allow data corruption, invariant violation, or silent failure.

What Belongs in Unit Tests

Layer What How
Domain services Entity CRUD, lifecycle operations, business invariants #[tokio::test] with SQLite :memory: + mocked AuthZ
Domain validation Format validation, invariant enforcement, field length limits #[test] pure logic, no DB
Value objects Parsing, normalization, serde round-trip #[cfg(test)] in-source
Error chains DomainError → module error → RFC 9457 Problem, external error → DomainError #[test] pure logic
DTO conversions From impls, serde attributes (rename, skip_serializing_if, default, camelCase) #[cfg(test)] in-source
OData fields FilterField name/kind mapping, OData mapper field→column #[cfg(test)] in-source
Seeding Idempotent bootstrap logic: create/skip/update semantics #[tokio::test] with DB assertion
Surrogate ID non-exposure API responses contain string identifiers, never internal numeric IDs REST-level Router::oneshot

What Does NOT Belong Here

  • HTTP routing, middleware wiring, header serialization → E2E tests
  • PostgreSQL-specific behavior (FK RESTRICT, SERIALIZABLE isolation, domain types) → E2E tests
  • Real AuthN/AuthZ pipeline with tokens → E2E tests
  • MTLS certificate verification → E2E tests (p2 — deferred, not implemented yet)
  • Cursor codec encode/decode over HTTP → E2E tests
  • Performance, load, concurrency under contention → out of scope

All of the above requires a running server with real PostgreSQL. Unit tests use SQLite and mock AuthZ — they cannot catch these bugs.

Relationship to E2E Tests

Unit and E2E tests form a complementary pair with zero overlap:

Concern Unit tests E2E tests
Domain invariants Yes (primary) No
Field validation Yes (service-level) No
Table row correctness Yes (SQLite) Yes (PostgreSQL dialect)
Error response format DomainError→Problem mapping HTTP headers + Content-Type
Tenant isolation AccessScope construction + scoped queries Real tokens + real WHERE
JSON wire format DTO serde attrs in-source Full HTTP roundtrip
OData $filter FilterField name/kind Full parse→SQL→result chain

If a bug is catchable by calling a Rust function directly, it lives in unit tests. If it requires HTTP + PostgreSQL, it lives in E2E tests.


Reliability Principles

Atomic — one #[test] = one behavior. No compound "test everything" functions.

Fast — no sleep, no timeout, no tokio::time::*, no polling. SQLite :memory: is ~1ms. Target: full suite < 5s.

Independent — no shared state. Each test creates its own DB. cargo test -j N runs in parallel.

Synchronous where possible — pure logic uses #[test], not #[tokio::test]. Async only when DB is involved.

Direct DB assertions — do not rely solely on service-layer reads (they go through AccessScope). Use the concrete entity type (e.g., UserEntity::find() with use sea_orm::EntityTrait; in scope) directly to verify table state.

No new crate dependencies for testing — follow project conventions: assert!(matches!(err, Variant { .. }), "msg: {err:?}") not assert_matches!. Manual vec![] + loop for table-driven tests, not rstest. Plain async fn helpers, not fixtures.

No retry testing — SERIALIZABLE retry loops are an implementation detail. Tests do not simulate contention.


Test Infrastructure

Core Principles

  1. Atomic: each test verifies exactly one behavior. No "also check this while we're here".
  2. Fast: no sleep, no timeout, no tokio::time::*, no polling, no retries. Target: entire suite < 5s.
  3. Independent: no shared state. Each test creates its own SQLite :memory: DB and fresh service instances. Tests run in any order and in parallel.
  4. Synchronous where possible: pure logic tests are #[test], not #[tokio::test]. Async only when DB or service layer is involved.
  5. Direct DB queries for state verification: use the concrete entity's find() method (e.g., MyEntity::find() with use sea_orm::EntityTrait; in scope) directly to inspect table state. Do NOT rely solely on service-layer reads to verify writes (service reads go through AccessScope which may filter).

Anti-Patterns (DO NOT)

// BAD: timer in test
tokio::time::sleep(Duration::from_millis(100)).await;

// BAD: retry/poll loop
for _ in 0..10 { if check() { break; } sleep(50ms); }

// BAD: compound test
#[tokio::test]
async fn test_everything() {
    // creates entity, moves it, deletes it, checks seeding...
}

// BAD: verifying write via scoped read only
let item = svc.get(&ctx, id).await?; // goes through AccessScope!
// This does NOT prove the DB state — a scope bug could hide the item

// GOOD: direct DB assertion
use sea_orm::EntityTrait;
let model = my_entity::Entity::find_by_id(id).one(&conn).await?.unwrap();
assert_eq!(model.parent_id, Some(new_parent));

Assertion & Parameterization Patterns

Error variant checks — use assert!(matches!(...)) with descriptive message:

let err = result.unwrap_err();
assert!(
    matches!(err, DomainError::NotFound { .. }),
    "Expected NotFound, got: {err:?}"
);
assert!(err.to_string().contains("not found"));

Table-driven tests — use manual vec![] + loop (NOT rstest):

#[test]
fn domain_errors_map_to_correct_status_codes() {
    let cases: Vec<(DomainError, StatusCode)> = vec![
        (DomainError::not_found("x"), StatusCode::NOT_FOUND),
        (DomainError::validation("x"), StatusCode::BAD_REQUEST),
        (DomainError::conflict("x"), StatusCode::CONFLICT),
    ];
    for (err, expected_status) in cases {
        let problem: Problem = err.into();
        assert_eq!(problem.status, expected_status, "for error: {problem:?}");
    }
}

Value object validation — table-driven with loop:

#[test]
fn value_object_valid_cases() {
    let valid = vec!["valid-input-1", "valid-input-2"];
    for input in valid {
        assert!(MyValueObject::new(input).is_ok(), "should be valid: {input}");
    }
}

#[test]
fn value_object_invalid_cases() {
    let invalid = vec![
        ("", "empty input"),
        ("bad format", "wrong format"),
    ];
    for (input, reason) in invalid {
        assert!(MyValueObject::new(input).is_err(), "should reject ({reason}): {input}");
    }
}

Setup helpers — plain async fn in tests/common/mod.rs (NOT fixtures, NOT rstest).

Shared Test Helpers (tests/common/mod.rs)

Extract duplicated setup code into a shared module:

// tests/common/mod.rs

/// SQLite in-memory DB with migrations. ~1ms per call.
pub async fn test_db() -> Arc<DBProvider<DbError>> { ... }

/// SecurityContext for given tenant.
pub fn make_ctx(tenant_id: Uuid) -> SecurityContext { ... }

/// AllowAll PolicyEnforcer (returns tenant-scoped AccessScope).
pub fn make_enforcer() -> PolicyEnforcer { ... }

Module-specific helpers (e.g., create_root_group, assert_closure_rows) live alongside the shared helpers in tests/common/.

Naming Convention

Tests follow the pattern {area}_{scenario} in snake_case:

entity_create_with_valid_parent_succeeds
entity_move_under_descendant_returns_cycle_detected
membership_add_duplicate_returns_conflict
seeding_idempotent_on_second_run
value_object_rejects_empty_string

Test File Organization

# In-source unit tests (pure logic, #[test] only — instant)
<module>-sdk/src/models.rs              # Value object validation, serde round-trip, SDK model shape
<module>-sdk/src/odata/<entity>.rs      # FilterField name/kind correctness
<module>/src/api/rest/dto.rs            # DTO From impls, serde rename, camelCase, skip_serializing_if
<module>/src/infra/storage/odata_mapper.rs  # OData mapper field→column mapping

# Integration tests (SQLite :memory: DB, #[tokio::test])
tests/
  common/mod.rs               # Shared helpers + assertion helpers
  domain_unit_test.rs         # DomainError construction, error chains, pure domain logic
  api_rest_test.rs            # REST layer: Router::oneshot, status codes, JSON shapes, PATCH
  authz_integration_test.rs   # PolicyEnforcer tenant scoping (mocked PDP)
  tenant_filtering_db_test.rs # AccessScope + scoped queries on SQLite
  tenant_scoping_test.rs      # AccessScope construction logic
  <entity>_service_test.rs    # Domain service: CRUD, invariants, error paths
  seeding_test.rs             # Idempotent seed: create/skip/update semantics

What to Assert in Every Test

The goal of a unit test is to verify as many observable facts as is reasonable in a single scenario — not just "it didn't crash". A test that only checks result.is_ok() provides almost no value.

The Four Assertion Dimensions

For every test, cover all dimensions that apply:

1. Primary outcome — did the operation succeed or fail with the expected result/error?

let result = svc.create_entity(&ctx, req).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
// or for error paths:
assert!(matches!(result.unwrap_err(), DomainError::NotFound { .. }));

2. Return value fields — not just the status, but the actual data returned. All fields that matter must be explicitly asserted:

let entity = result.unwrap();
assert_eq!(entity.name, "Expected Name");
assert_eq!(entity.tenant_id, ctx.tenant_id());
assert_eq!(entity.status, Status::Active);
assert!(entity.created_at <= Utc::now());
// Secondary fields matter too — don't skip them
assert!(entity.metadata.is_none(), "fresh entity should have no metadata");

3. Mock call verification — when using mocks, assert both how many times and with what arguments each mock was called:

// Verify the mock was called exactly once
mock_repo.assert_called_once();

// Verify it received the correct input — not just "it was called"
mock_repo.assert_called_with(CreateEntityRequest {
    name: "Expected Name".to_string(),
    tenant_id: tenant_id,
    ..
});

// Verify a mock was NOT called (for negative paths)
mock_notifier.assert_not_called();

4. Secondary artifacts — side effects beyond the return value. For DB-backed tests, verify the actual state in storage:

// Don't rely only on the service return value
let stored = MyEntity::find_by_id(entity.id).one(&conn).await?.unwrap();
assert_eq!(stored.parent_id, Some(parent_id));  // FK set correctly
assert_eq!(stored.status, "active");             // correct initial state
// Rows that should NOT exist
let orphans = RelatedEntity::find()
    .filter(Column::ParentId.eq(deleted_id))
    .count(&conn).await?;
assert_eq!(orphans, 0, "cascade delete must remove related rows");

What "Secondary Artifacts" Means in Practice

When an operation succeeds, many things happen beyond the return value. Each is a potential bug surface:

Operation What else to assert
Create entity All fields in DB match request (not just the returned struct). FK columns resolved to correct IDs. Timestamps set.
Update fields Changed fields updated in DB. Unchanged fields untouched — a partial update must not zero-out unrelated fields.
Delete entity Entity row gone. Related junction rows gone. Parent entity unaffected.
Service call with side effects Every observable side effect has an assertion. If a notification is sent, assert it. If a counter increments, assert it.
Error path Not only the error variant, but the error message contains expected context (entity name, field name, violating value).

The "Lazy Assert" Trap

// BAD — only checks it didn't error
let result = svc.update(&ctx, id, patch).await;
assert!(result.is_ok());

// BAD — checks the return but not DB state (scope bug could hide corruption)
let updated = result.unwrap();
assert_eq!(updated.name, "new name");
// Does NOT verify that unrelated fields were preserved in storage

// GOOD — checks return value AND DB state AND untouched fields
let updated = result.unwrap();
assert_eq!(updated.name, "new name");
let stored = Entity::find_by_id(id).one(&conn).await?.unwrap();
assert_eq!(stored.name, "new name");       // changed field
assert_eq!(stored.status, original_status); // untouched field preserved
assert_eq!(stored.tenant_id, original_tenant_id); // tenant not leaked

What to Verify Beyond Ok/Err

Do not rely only on the return value. Mandatory complementary assertions:

Table State (direct DB queries)

For every write operation, use the concrete entity's find() method (e.g., MyEntity::find() with use sea_orm::EntityTrait; in scope) to confirm:

Operation Required DB Assertions
Create entity Row exists with all fields matching request. FK columns resolved correctly.
Update fields Changed fields updated. Unchanged fields untouched.
Delete entity Row gone. Related rows (FK children) handled per cascade policy.
Seeding (create) Entity physically exists in DB. Junction rows present.
Seeding (unchanged) updated_at not modified on re-run.

Junction Tables

Operation Required DB Assertions
Create with references Junction rows COUNT = len(references). Each FK correctly resolved from string to surrogate ID.
Update (replace list) Old junction rows deleted. New rows match new list. COUNT = len(new_list).
Delete (CASCADE) Junction rows for deleted entity → 0.

Surrogate ID Non-Exposure (REST tests)

Every REST test response MUST verify:

  • No numeric surrogate ID fields in JSON (e.g., no type_id, parent_type_id)
  • Identifier fields are strings (UUIDs or GTS paths), not integers
  • Internal-only fields absent from response

Error Response Shape (REST tests)

For error-path REST tests via Router::oneshot:

  • HTTP status code matches expected
  • Content-Type header contains application/problem+json
  • Response body has status, title, detail fields (RFC 9457)
  • No stack, trace, backtrace fields (no internal leaks)

Priority Matrix

Use P1/P2/P3 to prioritize test implementation in module-specific plans:

P1 — Critical (business invariants)

Tests that prevent data corruption or violate core business rules:

  • All domain invariants from module acceptance criteria
  • Every error path that prevents illegal state (e.g., cascade, duplicate, constraint violation)
  • Value object validation (empty, invalid format, boundary)
  • Serde round-trip for SDK models (incorrect wire format breaks API clients silently)
  • OData FilterField name/kind (incorrect mapping breaks $filter silently)
  • Seeding create/skip/update (deployment-critical bootstrap)

P2 — Important (error paths, REST layer, edges)

Tests that cover secondary paths and REST-level verification:

  • Error conversion chains (ExternalError → DomainError → Problem)
  • REST endpoint coverage (PUT, PATCH, DELETE, hierarchy, force operations)
  • DTO conversion correctness (From impls, #[serde(default)])
  • OData mapper field→column mapping
  • Boundary conditions (max length, empty list, boundary values)

P3 — Nice to Have (boundary, cosmetic)

Tests that add confidence without blocking delivery:

  • Display + Into<String> for value objects
  • Empty-list edge cases for seeding
  • Cosmetic behavior (field ordering, default values)

Acceptance Criteria (module test suite)

  • All unit tests pass (cargo test -p <module> -p <module>-sdk) — 0 failed
  • Full suite completes in < 5 seconds
  • Zero sleep, timeout, or tokio::time usage in tests
  • Every domain invariant from module acceptance criteria is covered by at least one test
  • make all (or make fmt && make lint && make test) passes with zero errors