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.
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.
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.
Every test must pass all three:
-
"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.
-
"Is this test atomic and fast?" One
#[test]= one scenario. Nosleep(), notimeout(), 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. -
"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.
| 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 |
- 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.
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.
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.
- Atomic: each test verifies exactly one behavior. No "also check this while we're here".
- Fast: no
sleep, notimeout, notokio::time::*, no polling, no retries. Target: entire suite < 5s. - Independent: no shared state. Each test creates its own
SQLite :memory:DB and fresh service instances. Tests run in any order and in parallel. - Synchronous where possible: pure logic tests are
#[test], not#[tokio::test]. Async only when DB or service layer is involved. - Direct DB queries for state verification: use the concrete entity's
find()method (e.g.,MyEntity::find()withuse 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).
// 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));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).
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/.
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
# 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
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.
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");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). |
// 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 leakedDo not rely only on the return value. Mandatory complementary assertions:
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. |
| 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. |
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
For error-path REST tests via Router::oneshot:
- HTTP status code matches expected
Content-Typeheader containsapplication/problem+json- Response body has
status,title,detailfields (RFC 9457) - No
stack,trace,backtracefields (no internal leaks)
Use P1/P2/P3 to prioritize test implementation in module-specific plans:
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)
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 (
Fromimpls,#[serde(default)]) - OData mapper field→column mapping
- Boundary conditions (max length, empty list, boundary values)
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)
- All unit tests pass (
cargo test -p <module> -p <module>-sdk) — 0 failed - Full suite completes in < 5 seconds
- Zero
sleep,timeout, ortokio::timeusage in tests - Every domain invariant from module acceptance criteria is covered by at least one test
make all(ormake fmt && make lint && make test) passes with zero errors