Skip to content

ADR 0005 — Test mocking policy

  • Status: Accepted
  • Date: 2026-04-28
  • Decision drivers: test fidelity, refactor-friendliness, avoiding the "tests pass while production is broken" failure mode
  • Resolves: Open Question 2 in wiki/plans/login.md (Hangfire test strategy)

Context

Every test has to decide what to replace with fakes/stubs/mocks and what to leave real. The choice has long-term consequences:

  • All-mocked is fast but tests mostly verify call wiring; tests can pass while real behavior breaks.
  • All-real is rigorous but slow and brittle if it requires third-party services we don't operate.
  • Selectively faked — real for code we own, fake for what genuinely can't run in test — is the pragmatic middle, but only if the boundary is principled, not ad-hoc.

Without a stated rule, the boundary drifts: someone mocks DbContext for "unit purity," someone else stubs IBackgroundJobClient because it's "easier than running Hangfire," and the suite slowly stops testing the system.

Decision

Mock only true external dependencies. Use real implementations for everything else.

A dependency is "external" in Molib's context when it:

  • Lives outside our process boundary AND
  • Is owned/operated by a third party we cannot control in test, OR has side effects we cannot reproduce or assert against in test (sending email, charging a card, calling a remote API).

A dependency is internal — and gets a real implementation in tests — when:

  • It runs in our process, OR
  • Its persistence is our database, OR
  • It is a library we control with deterministic behavior, OR
  • We can stand it up reproducibly via the existing dev infrastructure (e.g. Postgres on the db service).

Current dependency inventory

Dependency Classification In tests
IEmailService / SMTP External FakeEmailService captures sends in memory
MolibDbContext / Postgres Internal Real EF Core against molib_test database on the db service
IBackgroundJobClient / Hangfire Internal Real Hangfire.PostgreSql storage; deterministic drain helper
Bcrypt password hashing Internal Real hashing (work factor may be lowered in Test env if measurably slow)
JWT signing Internal Real signing

Anti-patterns explicitly refused

  • Mocking DbContext or DbSet<T>. Use the real database with Respawn between tests.
  • EF Core in-memory provider as a substitute for Postgres. It has materially different semantics (no constraint enforcement, no SQL translation, no concurrency tokens). Banned.
  • Mocking our own service classes (JwtIssuer, RefreshTokenService, VerificationEmailJob, etc.) when they are the unit under test or part of an integration path. In higher-level tests, they run real.
  • Stubbing libraries we ship and operate (Hangfire, MailKit, Npgsql) just to avoid setup work. Either it stands up cleanly in test, or its abstraction interface is the seam we mock — not the library itself.

Hangfire-specific note (verify enqueueing, don't execute)

We don't run AddHangfireServer() in tests, and we don't execute Hangfire jobs from controller tests either. Two reasons split the responsibilities cleanly:

  • Controller tests verify that the right job entry was added to Hangfire's real storage. The HangfireQueueInspector reads JobStorage.Current.GetMonitoringApi().EnqueuedJobs(...) and asserts on the job type, method name, and arguments. This exercises Hangfire's serialization and storage path against the real Postgres hangfire schema.
  • Job behavior tests (e.g. VerificationEmailJobTests) instantiate the job class directly via DI and call its method. No Hangfire scheduling is involved — that is not the unit under test for the job's own behavior.

This split keeps each test focused on one concern (controller correctly enqueues vs. job correctly does its work) and avoids both flakiness from background threads and over-coupling between controller tests and job side effects.

Consequences

Positive:

  • A passing suite means real behavior works: real DB constraints, real EF query translation, real Hangfire serialization, real bcrypt, real JWT signing.
  • Refactors don't invalidate tests just because the call shape changed — tests assert on observable outcomes, not on internal wiring.
  • Adding a new external integration forces a clean seam (interface + Fake) up-front, by policy.

Negative / things to watch:

  • Tests are slower than fully-mocked alternatives. Acceptable until measured pain. When that pain arrives, the response is faster real infra (per-class test DB, parallelism), not more mocks.
  • Schema reset (Respawn) and DB lifecycle become first-class infrastructure concerns. Per the login plan amendment, Respawn is scoped to the public schema; hangfire lives alongside.
  • Every external integration must arrive behind an interface (IEmailService-style) so the Fake* counterpart can replace it. PRs that bypass an interface for a new external service should be rejected.

Triggers for revisiting

  • A new external integration is added — confirm it sits behind an interface and ships with a Fake* implementation alongside the production one.
  • Test suite duration becomes a measurable problem (e.g. > 60s for the backend suite). The response is one of: per-class test database, real parallelism, lowered bcrypt cost in Test environment — never more mocks.
  • A future need to test a system-level scheduler property (e.g. delayed jobs, recurring jobs) — at that point, run the real Hangfire server in a dedicated test class with a real wait/poll.