Testing streams the server pushes
Sequence numbers, timing, the last message, and data that repeats.
Plenty of sockets are mostly one-way: the client connects, maybe says what it wants, and the server talks from then on. Price feeds, live scores, progress updates and notification streams all work like this. This lesson uses /ws/tickersign in to see, which pushes one stock price a second, to look at how you test a stream you can't control.
What the ticker sends
Connect with ?count=3 and the ticker sends three prices, a completion message, and closes. Here is a real run, with the time of each event since the connection opened:
+0.00s open
+0.00s {"type":"tick","data":{"tick":1,"symbol":"AAPL","name":"Apple Inc.","price":230.19,"change":2.67,"changePercent":1.17,"volume":10137,"timestamp":"2026-10-11T07:43:49.669Z"}}
+0.99s {"type":"tick","data":{"tick":2,"symbol":"MSFT","name":"Microsoft Corp.","price":444.51,"change":6.40,"changePercent":1.46,"volume":10274,"timestamp":"2026-10-11T07:43:50.672Z"}}
+1.99s {"type":"tick","data":{"tick":3,"symbol":"GOOGL","name":"Alphabet Inc.","price":177.47,"change":1.13,"changePercent":0.64,"volume":10411,"timestamp":"2026-10-11T07:43:51.674Z"}}
+1.99s {"type":"complete","ticks":3}
+3.02s close 1000 "Ticker finished"Try it. The panel asks for five ticks; edit count in the URL for more, up to 300, or press Disconnect to stop early.
Messages appear here.
Formatted, one tick looks like this:
{
"type": "tick",
"data": {
"tick": 2,
"symbol": "MSFT",
"name": "Microsoft Corp.",
"price": 444.51,
"change": 6.40,
"changePercent": 1.46,
"volume": 10274,
"timestamp": "2026-10-11T07:43:50.672Z"
}
}Every message has a type. Ticks carry their data in a data object with a sequence number, tick, that counts up from 1. The last message is complete and says how many ticks were sent. Then the server closes with 1000 and the reason Ticker finished. That's a well-designed stream: each message says what it is, the sequence number lets a client spot gaps, and the stream announces its own end instead of just stopping.
What to check
You can't test a stream message by message the way you test request and response. You collect what arrives over a period, then check the collection. These are the things to assert, roughly in order of how often they catch bugs:
| Check | On this ticker |
|---|---|
| Sequence: no gaps, no repeats, starts where it should | tick goes 1, 2, 3 … up to count |
| The end: a final message, then the right close code | complete with the right count, then 1000 Ticker finished |
| Count: as many items as were asked for | Exactly count ticks |
| Shape: every message parses and has its fields | Every tick has all eight data fields, with the right types |
| Timing: roughly the promised rhythm | About one second between ticks |
| Values: each one makes sense on its own and with the others | changePercent agrees with change and price |
The last row is easy to skip and often productive. On tick 1, change is 2.67 and price is 230.19, so the previous price was 227.52 and the change was 1.17%, which is what changePercentsays. Fields that are calculated from each other should agree, and when a feed is assembled from several sources they sometimes don't.
A test that waits properly
The simplest reliable approach for a stream that ends by itself is to collect everything until the close, with an overall timeout so a stream that never ends fails the test instead of hanging it:
function collect(url, { timeoutMs = 10000 } = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const messages = [];
const timer = setTimeout(() => {
ws.close();
reject(new Error(`no close after ${timeoutMs} ms; got ${messages.length} messages`));
}, timeoutMs);
ws.addEventListener("message", (e) => {
messages.push({ at: Date.now(), msg: JSON.parse(e.data) });
});
ws.addEventListener("close", (e) => {
clearTimeout(timer);
resolve({ messages, code: e.code, reason: e.reason });
});
});
}const { messages, code, reason } = await collect("wss://api.testingapis.com/ws/ticker?count=5");
const ticks = messages.filter((m) => m.msg.type === "tick");
const last = messages.at(-1).msg;
// The sequence: 1, 2, 3, 4, 5 with nothing missing or repeated
assert.deepEqual(ticks.map((t) => t.msg.data.tick), [1, 2, 3, 4, 5]);
// The end: a completion message that agrees with what arrived, then a normal close
assert.deepEqual(last, { type: "complete", ticks: 5 });
assert.equal(code, 1000);
assert.equal(reason, "Ticker finished");
// The rhythm: about a second apart, with room for a slow network
for (let i = 1; i < ticks.length; i++) {
const gap = ticks[i].at - ticks[i - 1].at;
assert.ok(gap > 700 && gap < 1500, `gap ${i} was ${gap} ms`);
}The timing check allows 700 to 1,500 milliseconds for a one-second interval. In the runs measured for this lesson the real gaps were between 991 and 1,002 milliseconds, but a test run on a busy CI machine over a slow network will see more spread. Tight timing assertions are the second most common cause of flaky stream tests, after fixed sleeps. Decide what the product actually promises ("about once a second", "at least every five seconds") and test that.
Look closely at the run at the top and you'll see the first tick arrives immediately, not after a second, and the completion message arrives in the same instant as the last tick. A test that expects count seconds before completion will be off by one interval. Those are the kinds of details to write down once and check every time.
Parameters and limits
The ticker takes one parameter, count, documented as 1 to 300. Testing the edges:
count=3,45,55,70: exactly that many ticks, then a normal close.count=0: one tick and a completion saying"ticks": 1. Out-of-range values are raised to the minimum instead of rejected.count=999: 300 ticks over five minutes, then a normal completion. Values above the maximum are lowered to it.count=abc: the handshake fails with a 400, so a browser sees only an error and close code 1006, as the handshake lesson showed.- Messages sent to the ticker are ignored. It keeps ticking.
- Closing from the client stops it: the server answers with 1000 and the reason
Closed by client, and sends no more ticks.
Clamping quietly, rather than rejecting, is a reasonable design for a feed. But it means a client that asks for 0 gets something it didn't ask for and no warning. Whichever way an API goes, check that the docs match.
Data that repeats
Connect twice and compare. Every connection starts at tick 1 with Apple at 230.19, then Microsoft at 444.51, then Alphabet at 177.47. The prices are the same on every connection; only the timestamps differ. The ticker is deterministic, which is ideal for tests: you can assert exact values.
It also means the stream has no memory. If a client drops after tick 20 and reconnects, it gets tick 1 again, not tick 21. A real price feed would send the current price instead, but the problem is the same: after a reconnect, the client has to decide what it already has, what it missed, and what to ignore. That's the subject of the reconnecting lesson.
Exercise
Connect to the ticker with count=10 and save every message. Check, without looking at the values first: are the sequence numbers 1 to 10? Does every changePercent match its change and price to two decimal places? Is every timestamplater than the one before? Report anything that doesn't hold, with the message that broke it.