#engineering

A test suite you can trust: false greens and false reds ruled out by construction

For a backend that moves money, we designed tests and pipeline so that a green truly means “safe to release”: the known causes of false greens and false reds are ruled out by the shape of the code, by the types and by the pipeline, not left to the team’s discipline.

Software quality · TypeScript · Vitest · PostgreSQL · Valkey · GitHub Actions

Almost every project has tests. Far less often does the team actually trust them. A suite that sometimes fails for no reason teaches people to re-run the pipeline instead of reading the error. A suite built on mocks that mirror the implementation breaks at every refactoring, even when the behavior has not changed, and stays green when a defect hides between two components. A suite that needs a hand-prepared environment does not run in continuous integration, and ends up not running at all.

For a backend that manages balances, orders and money movements, we built the testing strategy on a single criterion: if the tests pass, the change is safe. It is easy to state and hard to uphold, because a suite can betray you in two ways: with false reds, which teach people to ignore it, and with false greens, which reassure exactly when they should not. A rule that lives only in the team’s memory gives way at the first tight deadline. So for every known cause we looked for a way to rule it out by construction, instead of leaving it to discipline: through the shape of the code, the types, the configuration or a check in the pipeline.

Every choice is documented together with the literature it rests on (Khorikov, Meszaros, Software Engineering at Google, Seemann, Ousterhout) and, where there were technical alternatives to choose from, together with the measurements that settled the choice.

A test earns its keep when it survives a refactoring

The most widespread form of unit test isolates every class from its collaborators with mocks. It produces fast suites and high coverage, but it checks how the code works, not what it produces: reorganizing the implementation is enough to turn dozens of tests red without any change in behavior. It is the most common false red, and it teaches that a failing test does not point to a defect.

Instead, we follow the “classical” school, described by Fowler in “Mocks Aren’t Stubs” and developed by Khorikov: a unit test verifies a unit of behavior, quickly and in isolation from other tests, not necessarily from all of the code’s collaborators. Every test is judged on protection against regressions, resistance to refactoring, fast feedback and ease of maintenance. It follows that not all code deserves the same kind of test:

Kind of code Typical examples Strategy
Complex logic, few collaborators fee calculation, codecs, parsers, atomic functions on the data exhaustive unit tests
Complex logic mixed with lots of I/O eligibility rules inside a handler, stages of a multi-step process split a pure decision, tested as a table, from a thin shell
Little logic, many collaborators most handlers and tasks, once the decision is extracted flow tests: the main path and what unit tests cannot reach
Trivial code handler registration, wiring no tests

Unit tests cover the rules exhaustively; flow tests cover the wiring, with one main path per use case and the forbidden paths that can be reached from outside. The division makes diagnosis immediate: if the unit tests are green and a flow test fails, the defect is in the wiring, not in the rule.

A testable rule without breaking the code into fragments

Many real handlers mix a business rule with reads and writes on the database and the cache, so the rule can only be verified by starting everything. The instinctive fix is to split it into many small functions, but the remedy makes the code worse: the flow becomes unreadable and defects end up between the functions, where nobody sees them from the call site. Ousterhout calls this the problem of shallow modules; Carmack, speaking of code that is called only once, advises keeping it inline.

We adopted the shape Seemann calls the impureim sandwich (read, decide, act), with a single pure function per use case, not one per step. It may be long and linear, it lives in the same file as its handler and it is exported for the test. It contains no I/O, no clock and no configuration: the current time, durations and limits are arguments. And without a real rule, nothing is extracted: a single comparison stays inline. An example written for this article:

type CancelDecision =
  | { action: 'cancel' }
  | { action: 'schedule'; at: Date }
  | { action: 'reject'; reason: 'already-settled' }

export const decideCancellation = (order: Order, now: Date, grace: Duration): CancelDecision => {
  if (order.status === 'SETTLED') return { action: 'reject', reason: 'already-settled' }
  const deadline = new Date(order.createdAt.getTime() + grace.milliseconds)
  return now < deadline ? { action: 'cancel' } : { action: 'schedule', at: deadline }
}

// In the handler: read, decide, act.
const decision = decideCancellation(order, new Date(), config.grace)
switch (decision.action) {
  case 'cancel':
    /* … */ break
  case 'schedule':
    /* … */ break
  case 'reject':
    /* … */ break
  default:
    decision satisfies never
}

Two details make the shape useful, not merely tidy. The first is the default branch: the decision returns a discriminated union, and a new outcome does not compile until every caller handles it. An oversight that a test might not cover becomes a compiler error. The second is the test, a test.each table in which every row has a name in plain language, such as “an already settled order cannot be cancelled”. Read from top to bottom, the table is the specification of the rule; product questions that are still open go into it too, as a test.todo with the question written out in full.

The shape holds up in less linear cases too: when the decision needs intermediate reads, we use Khorikov’s trilemma (of completeness of the logic, its purity and performance, you can only have two) and choose explicitly, case by case, what to give up. And when we need to guarantee that “C never leads to B”, we prefer, in this order, to make that guarantee a row of the table, then to make the path unrepresentable (step B accepts only the type produced by A, and calling it with C’s outcome does not compile). Only when coordination spans time or several processes does the condition become part of the write, with a conditional update whose affected row count is checked. First you eliminate the problem, then you make it impossible to write, and only at the end do you keep watch over it.

No mocks of what we control: that is where false greens are born

A mock of your own database answers what its author expects from the database. If the expectation is wrong, code and test are wrong together, and the test stays green. So, again following Khorikov, we distinguish managed dependencies (our database, our cache, the functions that run inside the datastore) from unmanaged ones (payment providers, email services, third-party storage).

Managed dependencies are always real, never simulated: they are implementation details, and simulating them means writing tests that know the implementation. Unmanaged dependencies are the only place where a substitute is allowed, provided you own the boundary being replaced (“Only mock types you own”, Freeman and Pryce), and in Google’s order of preference: real, fake, stub, and mock only as a last resort. The email provider, for example, is replaced purely through configuration by a local service that keeps the messages sent.

A less obvious rule follows: never spy on the internals of an SDK. It is a classic trap: a spy on an ORM method can change the behavior of later calls, so the task “succeeds” without having written anything and the test stays green. It is the most dangerous false green, because it reassures.

The flip side is that tests stay out of production code (Meszaros, Test Logic in Production): no if (test), no fake implementations selected at runtime. Tests steer the application only through configuration, which points it at the test infrastructure, and the code verified in the pipeline is the code that runs in production, not a variant of it.

An architectural constraint becomes a test

All of this works only if the code lets the tests start it. A common obstacle in Node backends is singletons created at import time: modules that, as soon as they are imported, read environment variables, open a connection pool or create a server. With code like that, even the simplest pure function needs a complete environment to be tested.

The textbook answer is full dependency injection through every handler and every task. We deliberately rejected it: it would have touched the entire codebase for a benefit that an explicit composition root, in the sense of Seemann and van Deursen, already provides. loadConfig(env) validates the configuration, start(config) creates the database, the cache pool, the WebSocket server and the external clients, stop() closes them. Entrypoints and suite call the same start(), the suite with a test configuration typed against the production schema. Modules expose their resources as ESM bindings assigned by start(), so the code that imports them does not change.

Left as a convention, this property would be lost at the first careless change. That is why a unit test imports every module of the backend, entrypoints excluded, without starting anything, and fails if a module reads the configuration or creates a client at import time. A constraint that would otherwise live in the team’s memory becomes a test that no future change can silently get around. By the same logic, before any destructive operation a guard refuses to start unless the database and the cache are exactly the services of the test infrastructure.

The same engine as the cloud, not a convenient substitute

Once the database and the cache are real, it remains to decide how real. The well-known shortcuts, such as SQLite instead of PostgreSQL, an in-memory simulated cache or a standalone instance instead of the cluster, make the suite easier to start, but each one moves part of the behavior outside what the test verifies.

The cluster case is the most instructive. In a Valkey cluster, multi-key commands, transactions and scripts are allowed only if all the keys involved belong to the same hash slot; a standalone instance has no such constraint, and a test can pass with an operation that a cluster would reject. In the cloud environments the cache always runs as a cluster, so in the tests the cache always runs in cluster mode, with several masters: a sharding defect has to show up in the tests, not after the release. By the same criterion, PostgreSQL is at the same version as the managed database, and Valkey at the open-source release closest to that of the managed service. The other alternatives were rejected for equally concrete reasons, documented because the question will come back:

Alternative Why we rejected it
SQLite instead of PostgreSQL queries use PostgreSQL’s full-text search and jsonb operators; no SELECT … FOR UPDATE, exactly where a system that moves money is delicate
Key prefixes to isolate tests checked against the client library: some stream commands are not prefixed, and keys returned by the server come back already prefixed, so when they are reused they get the prefix twice
An in-memory simulated cache it supports neither stream consumer groups nor the libraries used by the server-side functions

Fidelity does not mean slowness or fragility. PostgreSQL runs on tmpfs with the “non-durable” settings documented by the project (fsync, synchronous_commit and full_page_writes turned off), because crash safety is of no use to a test database. The cluster nodes run in a single container: clients connect to the address each node announces, and the nodes use that same address to talk to each other, so with one container per node the announced address is either unreachable from the host on macOS or breaks the internal communication. In a single network namespace, 127.0.0.1 works for both, and the same Compose file runs identically on macOS, Linux and in CI.

Isolation between tests was also decided with numbers. Migrating a “template” database takes about 0.8 seconds and happens once per run; cloning it for each test worker takes about 11 milliseconds. Cloning a database for every single test would have cost about 27 milliseconds per case, and would have required a new ORM client each time: rejected. Emptying the tables and the cluster before each test costs about 11 milliseconds instead, and the end-of-test check that no work was left in the queue about 7. A complete flow test, from the HTTP request to the execution of every task, takes around 50 milliseconds. At these costs, real infrastructure is not a luxury to reserve for a few tests: it is the normal way to write a flow test.

A flaky test is a bug, not a nuisance

A test that sometimes fails is the false red par excellence: after a few re-runs nobody reads the error any more, and on the day the failure is real it goes unnoticed. Automatically re-running failed tests hides the symptom and leaves the cause in place. The causes of flakiness, however, are well known, and for each one we have a precise rule that attacks it at the root.

  • State: every test starts from an empty database and an empty cluster; generated data is unique, and assertions concern only the test’s own entities, never global counts.
  • Asynchrony: no sleep, timeouts or polling. Asynchronous work is run explicitly, until the queue is empty, through the same pipeline as the production worker. If a test has to wait, it is the design that needs to change.
  • Time: pure decisions receive the current time as an argument, and scheduled jobs expose a run({ now }) without scheduling or leader election (Meszaros’s Humble Object).
  • Remote services: no test depends on the network or on third-party availability.
  • Residual flakiness: a flaky test, if one appears, is quarantined with a ticket the same day.

The end-of-test queue check also closes off a false green typical of asynchronous systems: work left incomplete makes the test fail instead of going unnoticed. And before the approach was adopted, stability was verified, not assumed: repeated runs of the integration suite and of the whole suite, with the test order shuffled at random, all green.

A pipeline that fails early and makes the rules mandatory

The pull request pipeline has to give fast feedback and turn the rules into checks. For speed, the delicate point is running only what the change touches: a filter on each monorepo application’s own paths alone would skip the tests precisely when something they all depend on changes. That is why shared dependencies (common libraries, the lockfile, the workspace configuration, dependency patches, the pipeline itself) are declared once and included in every application’s filter. A change to the admin interface does not start the backend integration tests; a change to the lockfile starts all of them.

The checks run from cheapest to most expensive, so a type error stops the job without waiting for containers and databases. Run strictly in sequence, though, the wait for the infrastructure would be paid in full at the end. That is why the test infrastructure starts in the background right after checkout, using GitHub Actions’ native background steps, and the wait step joins it only before the integration tests, inheriting its outcome.

Yes No Pull request Detect affected applications Personal data policy: always Frontend: typecheck, lint, build Backend Start test infrastructure in background Typecheck code and tests Lint Build Unit tests Wait for infrastructure to be ready Integration tests Job failed? Test service logs Checks passed

The pipeline’s second function underpins the whole strategy: making mandatory what elsewhere would be a good habit. A single typecheck covers production and test code, and the test configuration is checked with satisfies against the schema of the production one. Lint encodes project constraints: the ORM’s generated client has several entry files, and importing two of them means reloading the development .env file inside the tests, so a no-restricted-imports rule forbids the second entry and points to the right one. A subtle defect thus becomes impossible to introduce.

The same goes for personal data. On every pull request, a job verifies the policy that strips personal data and credentials from the events sent to the error-tracking system and from the logs. For the audited DTOs there is an exhaustive, type-level classification, field by field, redact or retain: when one of these DTOs gains a field, the typecheck fails until someone explicitly decides whether that field may leave the application. A privacy decision that usually gets forgotten becomes a compile error.

Feedback time is measured and fixed at the cause

A slow suite gets run less often, and a suite that runs less often protects less. That is why we treat time as a measured requirement.

The most instructive case concerns the plugin that turns TypeScript types into runtime validators at build time. A first cold run of the unit tests took 67 seconds. The quickest answers would have been to raise the timeouts or to give up the plugin. Digging into it, we found the cause instead: it built one TypeScript program per file and, because of the aliases passed by the build environment, resolved modules through a resolver with no cache, walking the whole import graph on disk again for every file. A patch of a few lines, applied through the package manager’s patch mechanism and therefore versioned and reproducible, makes all files share a resolution cache: from 67 to 19 seconds.

What a green means

The result is that a green pipeline has a precise meaning. Every business rule extracted into a pure decision is covered case by case by a table that reads as a specification. The wiring between API, queues, datastore and database is verified on the same engine and in the same cluster mode as the cloud environments. Asynchronous work left incomplete makes the test fail. And the constraints that hold all of this together are checked by the compiler, by lint or by a test, not remembered.

For the team, it means changing the code with confidence, with tests that survive refactoring and a guide that points, for every situation, to a reference test to copy. For the client, it means releasing more often and with less uncertainty, with personal data protection that the pipeline verifies on every change.

This work shows how we approach quality in general: a rule that matters does not remain a recommendation, but becomes something the code, the types or the pipeline enforce. That is how a suite stops being a ritual and becomes the tool used to decide whether to release.

The approach is designed to grow. For money-moving flows, the next step is stateful property-based testing: random sequences of real operations run against the system, with invariants checked after every step (a balance is never negative, money is conserved, a final state always comes from the one before it).