You already know why a test should not read the wall clock, and your service
takes a Clock as a dependency. Every test pins it. Nothing sleeps, nothing
retries, and no suite has ever failed because it ran across midnight.
Then somewhere there is a statement like this, and the discipline stops at the connection:
update outbox set published_at = now() where id = :id
How it shows up, which is not as flakiness
A second clock does not make tests flaky. Flaky would be easier: you would notice.
It shows up when something compares a time you set against a time the database set. Here is the one that caught me. A retention job deletes rows published longer ago than a window. The test pins the clock to a Monday in March, marks a row published, and asserts the row is deleted:
expected: 1
but was: 0
The pruner asked for rows published before March minus seven days. The row carried the real date, six months later. Nothing was ever going to be deleted, the assertion pointed at the pruner, and the pruner was correct.
That is the flavour of this bug. Not a race, not a rare failure. A comparison between two clocks that will never agree, failing the same way every run, in a place that looks like arithmetic.
The list is longer than you think
Injecting Clock covers the code you wrote. These are the ones that write a time
without asking:
-
now(),current_timestamp,localtimestampin any statement - a column with
default now() -
@CreationTimestampand@UpdateTimestamp - a trigger that stamps a row on insert or update
-
updated_atmaintained by an ORM’s auditing support - the broker’s own timestamp on a message, if you ever read it back
Each of those is a clock. Each is invisible to a test that pins yours, and each is fine until something compares it to a value your code produced.
What to do instead
Pass the instant in. It is a smaller change than it sounds, because the code that knows what time it is already has the clock.
// the port takes the moment, so one clock decides
void markPublished(UUID id, Instant at);
// and the caller has the injected one
this.outbox.markPublished(stored.id(), this.clock.instant());
There is a real objection: the database’s clock is the same everywhere, and yours is per process, so on several instances they can differ by whatever your NTP drift is. That is true and it is usually the smaller problem. Drift between hosts is bounded and measurable; a value no test can control is neither.
If you genuinely need the database’s time for correctness, take it deliberately: select it, pass it around like any other value, and let a test replace it. What you are avoiding is not the database’s clock. It is the second one.
The half of this that is about reading tests
My first fix was wrong in an instructive way. I made the timestamp injectable, re-ran, and the same test still failed, so I assumed the fix was incomplete.
It was not. The test was wrong. Retention is measured from delivery, not from when the event happened, so a row delivered a minute ago is kept however old its event is. That is the behaviour you want, because it protects a consumer that has been down for a week rather than deleting its backlog for being old. I had written an assertion for a rule I had not thought through.
A failing assertion has two sides and only one of them is usually examined. This is the same instinct as reaching for a script before an agent: when something disagrees with you, find out which half is the authority before changing either.
If your team has a suite that passes and a system that does not, that’s the work I do.