A Sleep in a Test Is a Guess About the Future
Most flaky tests aren't unlucky. They are honest reports of a race the code never admitted it was running.
- Published
- Reading time
- 3 min read
- Length
- 455 words
- Topics
- Testing, Automation, Puppeteer
Sooner or later, almost every browser test suite grows a line like this:
await page.click("#save");
await new Promise((resolve) => setTimeout(resolve, 2000));It passes on the laptop where it was written. It fails once a week in CI. Someone raises it to 3000, it goes quiet for a month, and then it fails again.
After most of a year writing automated tests with Selenium, Puppeteer and Pest, I read that line as a small confession. It says: something happens after this click, I don’t know when, and I’m going to guess.
Wait for Conditions, Not Durations
A sleep waits for a duration. What the test actually needs is a condition: the request finished, the message appeared, the row exists. Durations are guesses about the future. Conditions are facts about the present. Reliable browser tests are built almost entirely on the second kind.
const [response] = await Promise.all([
page.waitForResponse(
(res) => res.url().endsWith("/api/orders") && res.request().method() === "POST",
),
page.click("#save"),
]);
expect(response.ok()).toBe(true);
await page.waitForSelector(".toast-success", { visible: true });The Promise.all matters. Start listening before you click, or a fast response can arrive before anyone is waiting for it, and you’ve written a new race while fixing the old one.
Flaky Tests Are Often Honest
The more interesting lesson is what flaky tests are usually saying. A test that fails one run in twenty is tempting to treat as noise. But a test is just a very fast, very patient user. If the test can click Save before the form has loaded its data, so can a person on a slow connection. If the test sees the old list for a moment after a delete, so does everyone else.
So before fixing a flaky test, it’s worth asking a different question: can a real person hit the same race? Surprisingly often the answer is yes, and the flaky test was the only one telling the truth.
Make Time an Input
On the server the guess is usually about the clock rather than the network. Code that calls now() deep inside a method is reading an input nobody declared. Its tests pass all month and fail on the 31st, or at midnight, or in another time zone.
Laravel’s testing helpers let you declare the clock instead. In Pest:
it('expires an invite after seven days', function () {
$invite = Invite::factory()->create();
$this->travel(8)->days();
expect($invite->fresh()->isExpired())->toBeTrue();
});The test no longer hopes it runs at a convenient moment. It says what time it is.
Hidden Inputs
Strip away the tools and the rule is about honesty. Anything your code depends on that isn’t written down as an input (the network’s speed, the clock, the order two events arrive in) is a hidden assumption. Tests are where hidden assumptions come to fail, loudly and repeatedly, until someone finally writes them down.
That’s the real job of a test suite. Not to prove the code works, but to make every assumption the code relies on visible enough to argue with.