I was chasing a bug that, by every dashboard I trusted, shouldn't have existed. The code path was covered. There was a test with its name right on it, and the suite was green. So I opened the spec expecting to find a wrong assertion — a stale expected value, an off-by-one. What I found was worse, and stranger: the assertion was fine. It just never ran.
The expect sat inside a subscribe callback, and in that test the observable never emitted, so the callback never fired, so the expectation never executed. A test can't fail on a check it never reaches. Green didn't mean the code was right. It meant nothing had thrown — which is a very different, much weaker claim than the one I'd been trusting.
That's a smoke detector wired to its power light and nothing else. The green LED is on, so you feel covered, but the only thing it's telling you is that the unit has power. Whether it would ever actually sound for smoke is a question no one asked, because the light looked reassuring on the ceiling.
Green means "nothing threw," not "I checked"
The trap is that an asynchronous test has two jobs, and the failing one is invisible. The first job is to drive the async work far enough that the result exists. The second is to assert on that result somewhere the test runner is still watching. When the assertion lives in a callback the run never reaches, the first job silently didn't happen — and because nothing threw, the second job looks done. The test passes for the same reason an unasked question is never answered wrong.
Coverage tooling makes this easier to miss, not harder — though the mechanism isn't the one you'd guess. The expect line itself isn't the problem child; it lives in a callback that never ran, so a coverage report would show it uncovered, not green. The real trap is one level down: the code under test — the service, the mapped stream — does run during the subscription, so its lines light up covered and the feature looks exercised. Coverage tells you which lines ran. It has nothing to say about whether a single thing was asserted about them.
The broken test, the fix, and what each one does when the code is wrong
Here's the shape of what I found — an assertion parked inside a subscription, with no one advancing the async work and no one confirming it happened:
it('emits the mapped total', () => {
service.total$.subscribe((total) => {
expect(total).toBe(42); // never runs if nothing emits in this tick
});
});
The fix is to make time explicit and to insist the assertion actually fired. fakeAsync gives the test a virtual clock, tick() advances it so the emission lands during the run, and expect.assertions() fails the test if the expectation is skipped — turning a silent pass into a loud failure:
it('emits the mapped total', fakeAsync(() => {
expect.assertions(1);
let received: number | undefined;
service.total$.subscribe((total) => (received = total));
tick(); // let the async work complete inside virtual time
expect(received).toBe(42);
}));
The difference only shows itself when the code is wrong, which is the entire job of a test:
service returns the wrong total (7, not 42):
broken test → still PASSES (the subscribe body never ran; nothing threw)
fixed test → FAILS: "expected 7 to be 42"
service silently emits nothing at all:
broken test → PASSES (no callback, no assertion, no failure)
fixed test → FAILS: "expected one assertion, but received zero"
That second row is the one that changed how I write these. The expect.assertions(1) guard catches the exact failure I'd been burned by — not a wrong value, but a check that never happened. It's the Vitest guard (this project runs Vitest); Jasmine and Karma have no direct equivalent, so on that stack you rely on the captured-value assertion instead — expect(received).toBe(42) already fails when nothing arrived and received stays undefined. Either way, it's cheap insurance against a test that protects nothing while looking like it protects a line.
fakeAsync isn't always the right instrument, and I don't want to oversell it. It shines when the async work is timers, debounces, or microtasks I want to control down to the millisecond, and it deliberately refuses real network calls — inside it you flush a mocked response through HttpTestingController instead. When the thing under test is genuinely asynchronous I/O I'd rather let settle on its own, async/await with the fixture's whenStable() reads better and stays honest. A bare done callback works too, but it fails open in the worst way: forget to call it and the test hangs until it times out — or, if the assertion sits in a callback that never fires, the spec can finish without it ever running. The same class of bug I was trying to leave behind. One caveat I'd flag rather than assert too firmly: as Angular moves toward zoneless, some of fakeAsync's zone-based behavior shifts, and awaiting real async is increasingly the safer default — worth checking against your Angular version rather than taking my word for it.
A Test You Can't Watch Fail Isn't Protecting You
A green suite is a claim, and it's easy to hear it as a stronger claim than it makes. All it says on its own is that nothing threw — and an assertion that never runs can't throw. The protection comes from the failing case: a test earns its place only if it goes red when the code goes wrong. Drive the async work to completion where the runner can see it, and guard that your expectation actually fired, and the suite starts making the promise you assumed it was making all along. Until then, the green light only tells you the detector has power.


