Testing state-heavy async logic without flakiness
The previous lesson turned connection status into a state machine with a pure transition(state, event) function — this lesson is the payoff. It shows how to test that function directly with zero mocking, fake the WebSocket at the I/O boundary to drive it, and fake timers to collapse minutes of exponential backoff into milliseconds, turning a slow, flaky integration test into a fast and deterministic one.
Testing state-heavy async logic without flakiness
Modeling connection state as a machine, not booleans ended with a specific shape: connection status collapsed into one variable with a small set of named states, and every state change routed through a single function, transition(state, event), that takes wherever you are and whatever just happened and returns wherever you should be next. That lesson sold the shape on correctness grounds — no more impossible combinations of booleans, no more reconstructing intent from isConnected && !isReconnecting. This lesson sells it on a different axis: that exact shape is what makes a genuinely hairy piece of async logic testable at all, quickly, and without the test suite itself becoming a source of flakiness.
The governing insight: a pure function needs no setup beyond its own inputs. Everything that makes testing connection logic feel hard — sockets, servers, timers, retries — turns out to be solvable by testing three separate things in three separate ways, and only one of them is hard.
Here's the map. First, the transition function tested completely on its own, with table-driven cases and nothing else running. Then the boundary where the state machine meets a real socket, faked so you can trigger exactly the server behavior you want. Then the exponential backoff timing from the previous lesson, tested by fast-forwarding a fake clock instead of waiting on a real one. And finally, why doing all three together is what actually buys you a fast, trustworthy suite, rather than three separate tricks that happen to coexist.
Testing the transition function: nothing to set up
Start with what transition looks like, because its shape is the entire reason this section is short.
function transition(state, event) {
switch (state) {
case "idle":
if (event === "connect") return "connecting";
return state;
case "connecting":
if (event === "onopen") return "open";
if (event === "onerror" || event === "onclose") return "reconnecting";
return state;
case "open":
if (event === "onclose") return "reconnecting";
if (event === "onerror") return "reconnecting";
return state;
case "reconnecting":
if (event === "onopen") return "open";
if (event === "give_up") return "failed";
return state;
case "failed":
if (event === "connect") return "connecting";
return state;
default:
return state;
}
}Nothing in that function touches the network, starts a timer, reads the DOM, or calls anything asynchronous. It takes two plain values and returns a plain value. That's the entire contract, and it's why testing it doesn't require a testing strategy at all — just inputs and an assertion.
describe("transition", () => {
const cases = [
["idle", "connect", "connecting"],
["connecting", "onopen", "open"],
["open", "onclose", "reconnecting"],
["reconnecting", "onopen", "open"],
["reconnecting", "give_up", "failed"],
["failed", "connect", "connecting"],
// an event that isn't legal from this state: ignored, not miscategorized
["open", "connect", "open"],
];
test.each(cases)("transition(%s, %s) -> %s", (state, event, expected) => {
expect(transition(state, event)).toBe(expected);
});
});That last case is worth pausing on, because it's the one people skip when they're only testing the happy path. transition("open", "connect") — receiving a "connect" event while already "open" — isn't a scenario the code is supposed to handle in the sense of doing something clever with it. It's a scenario the code is supposed to reject, by leaving the state exactly where it was. If that assertion weren't there, a bug where "connect" accidentally moved an open connection back to "connecting" — dropping and re-establishing a perfectly good socket for no reason — would pass every other test in the file and only show up in production, intermittently, as a connection that mysteriously resets itself. Asserting on the absence of a transition is exactly as important as asserting on the presence of one.
Notice everything this test file didn't need: no server, no socket, no timer, no mock of anything, no async, no cleanup between tests, no test runner configuration beyond whatever already runs your unit tests. You could paste transition and this test file into a scratch project with no other code and it would run. That's not a coincidence — it's the entire reward for having pulled the decision logic out into a pure function in the first place. Every test here runs in well under a millisecond, and every one of them will produce the exact same result today, next month, and on a CI runner under load at 3 a.m., because there is nothing in the function's behavior that depends on anything other than the two arguments you handed it.
Faking the I/O boundary: driving the machine on command
transition being trivial to test doesn't mean the connection logic is trivial to test — it means the hard part just moved. Something has to listen to a real WebSocket, notice that it fired onclose, and call transition(currentState, "onclose") with the right event at the right time. That wiring code is not pure: it touches a real network object, and you need to test it without touching a real network.
The move is to fake the thing at the boundary — the WebSocket itself — rather than the code that uses it. A real WebSocket connects to an actual URL and fires its events whenever the actual network decides to. A fake one is a small class with the same shape (onopen, onclose, onmessage, send, close) that does nothing on its own and instead lets your test trigger those events by hand, whenever it wants, in whatever order it wants.
class FakeWebSocket {
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
// no actual connection attempt — the test decides what happens next
}
// Test-only helpers, not part of the real WebSocket API
simulateOpen() {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open"));
}
simulateMessage(data) {
this.onmessage?.({ data });
}
simulateClose(code = 1006) {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code });
}
send() {}
close() {
this.simulateClose(1000);
}
}
FakeWebSocket.CONNECTING = 0;
FakeWebSocket.OPEN = 1;
FakeWebSocket.CLOSED = 3;With that in place, testing the wiring becomes testing a scripted scenario rather than hoping a real server cooperates:
test("an unexpected close mid-session dispatches onclose into the state machine", () => {
const socket = new FakeWebSocket("wss://example.test");
const manager = createConnectionManager(socket); // your wiring code
socket.simulateOpen();
expect(manager.state).toBe("open");
socket.simulateClose(1006); // server dropped us, no clean close code
expect(manager.state).toBe("reconnecting");
});
test("a connection that drops three times before succeeding", () => {
const socket = new FakeWebSocket("wss://example.test");
const manager = createConnectionManager(socket);
for (let attempt = 0; attempt < 3; attempt++) {
socket.simulateOpen();
socket.simulateClose(1006);
}
socket.simulateOpen();
expect(manager.state).toBe("open");
});Every scenario that would be awkward, slow, or outright impractical to reproduce against a real server — a clean open, a server-initiated close with no warning, a message arriving before the open handshake finishes registering, three drops in a row — is now one or two method calls on a fake, because the fake's entire job is to say exactly what you tell it to say, in exactly the order you tell it to say it. If your code is fetch-driven instead of WebSocket-driven, the same idea applies one level up: rather than hand-rolling a fake Response, Mock Service Worker intercepts requests at the network layer and lets you script exactly which response, status, or delay a given request gets, without your application code ever knowing it isn't talking to a real server.
Fake timers: testing backoff without waiting for it
The previous lesson's reconnect logic doesn't retry immediately — it backs off, waiting longer after each consecutive failure so a struggling server doesn't get hammered by every disconnected client retrying at once. That's exactly the kind of logic that's easy to get wrong (is the delay actually growing? is it capped? does it reset after a success?) and, if you test it by actually waiting, expensive to verify: a test asserting behavior across five backed-off retries starting at one second and doubling would, tested naively with real timers, take over half a minute by itself. Multiply that by a full suite and you get the exact test suite everyone learns to run less often, which is another way of saying you get a test suite nobody trusts.
The fix is a fake-timers API: a drop-in replacement for setTimeout and friends that a test controls directly, advancing simulated time by however many milliseconds it wants in a single synchronous call, with no actual waiting involved. In Jest, that's jest.useFakeTimers() to swap in the fake clock, and jest.advanceTimersByTime(ms) to move it forward.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test("backoff delay grows across consecutive failures", () => {
const manager = createConnectionManager(new FakeWebSocket("wss://example.test"));
const delays = [];
const originalSchedule = manager.scheduleRetry;
manager.scheduleRetry = (delay) => {
delays.push(delay);
return originalSchedule(delay);
};
manager.socket.simulateClose(1006); // failure 1
manager.socket.simulateClose(1006); // failure 2 (after next attempt starts)
manager.socket.simulateClose(1006); // failure 3
expect(delays[1]).toBeGreaterThan(delays[0]);
expect(delays[2]).toBeGreaterThan(delays[1]);
});
test("advancing the fake clock fires the next retry attempt", () => {
const manager = createConnectionManager(new FakeWebSocket("wss://example.test"));
manager.socket.simulateClose(1006);
expect(manager.state).toBe("reconnecting");
// Instead of waiting 1000ms in real time, fast-forward the clock.
jest.advanceTimersByTime(1000);
expect(manager.attemptCount).toBe(2); // the retry actually fired
});Nothing in that second test paused for a second. jest.advanceTimersByTime(1000) runs synchronously — it tells the fake clock "1000ms have now elapsed," which causes any setTimeout callback scheduled for 1000ms or earlier to fire immediately, in order, right there in the test. You can simulate ten minutes of backoff in a test that finishes before you'd have finished reading its output. Testing Library's guide on fake timers covers the layer above this — how fake timers interact with user-event and with waitFor-style async assertions once you're testing a component rather than raw connection logic — but the mechanism underneath is the same jest.advanceTimersByTime call.
Why the combination is the actual payoff
Each of the three pieces on its own solves one problem: the pure function needs no infrastructure, the fake socket removes the real network, the fake clock removes real waiting. The reason this matters as a combination is that async, stateful, retry-heavy code is exactly the kind of code where skipping any one of the three drags the other two down with it. Test the transition function directly but still exercise it through a real socket and real timers, and you've bought nothing — the suite is still slow and still occasionally red for reasons that have nothing to do with whether your logic is correct. Fake the socket and the clock but leave the decision logic tangled into the wiring code, and you no longer have a two-argument function to test at all — you're back to asserting on manager.state after a long sequence of simulated events and hoping the failure, when it comes, tells you which line caused it.
Put all three together and the properties compound instead of adding up. The suite is fast — milliseconds per test, not seconds or minutes, because nothing in it is actually waiting on anything. It's deterministic — the same inputs to transition produce the same output every run, the fake socket only does what the test tells it to, and the fake clock only advances when told, so there's no real-world timing for a slow CI box to perturb. And it's exhaustive in a way a real integration test can't practically be: three drops in a row, a message arriving mid-reconnect, an out-of-order close-then-open, a backoff sequence run to its cap — each of these is one more scripted call on a fake, not an increasingly elaborate dance of trying to make a real server misbehave in a specific, repeatable way. The alternative — one big integration test hitting a real (or locally spun-up) server with real timers — isn't wrong exactly, but it buys you one slow, occasionally-flaky data point per run, and a team that runs it under duress eventually stops trusting a red result and just re-runs it until it's green. That habit is the actual cost of skipping this shape, and it's the whole reason the previous lesson's insistence on a pure transition(state, event) function pays for itself here.
Go deeper
- Jest — Timer Mocks — The concrete API this lesson's fake-timer examples are built on, including advanceTimersByTime and the gotchas around real vs. fake timers in the same test.
- Testing Library — Using Fake Timers — How fake timers interact with user-event and async assertions in a component test, one layer above the raw Jest API.
- Mock Service Worker — Introduction — The standard way to mock the I/O boundary for fetch-based code at a level above hand-rolled mocks, if a codebase's async logic is HTTP-driven rather than WebSocket-driven.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Why does the transition function's test file not need beforeEach/afterEach setup or any mocking library at all, when the connection manager's tests need both?
- What real bug would the test `transition('open', 'connect')` toBe('open') catch that the other test cases wouldn't?
- In the FakeWebSocket class, why do simulateOpen and simulateClose exist as methods instead of the class just connecting to a real WebSocket under the hood during tests?
- What specifically goes wrong — in terms of test runtime and reliability — if you test exponential backoff with real setTimeout calls instead of fake timers?
- Why does jest.advanceTimersByTime(1000) not simply skip the retry — why does the scheduled callback actually run?
- If a codebase tests transition() directly but still wires up its connection tests against a real local WebSocket server, what problem does that leave unsolved?
- Why is 'the suite is fast' not really a separate benefit from 'the suite is deterministic' in this setup — what's the causal link between the two?