# Testing Async Code, Timers, and Errors

> Promises, setTimeout, and the failure cases. Test frontend async code reliably with Vitest's resolves/rejects matchers and fake timers — no real waiting, no flakiness.

**Published:** 2026-07-08 | **Updated:** 2026-07-08

---


> This is **Part 5** of the "Frontend Testing, Done Right" series. [Browse the full series](/en/series/frontend-testing-done-right/) · [Glossary](/en/posts/frontend-testing-glossary/)

Real-world code **waits**. It waits for API responses; it waits for timers. That makes async testing the first wall most frontend developers hit — but the reason it feels hard boils down to a single question: **"when do you verify?"** Grade the answer sheet before the answer arrives, and you're grading a blank page. This one article packs the complete toolkit for code that waits: Promise verification (`resolves`/`rejects`), fake timers, and the failure cases.

If you've been following the series, you'll feel the contrast immediately with [last time's](/en/posts/frontend-testing-unit-basics/) `filterByQuery` — the pure function that answered on the spot. Landed here from a search? You're fine too: the examples are self-contained. Everything uses Vitest, but on Jest it runs almost unchanged once you swap `vi` for `jest`. (No environment yet? See the [setup article](/en/posts/frontend-testing-setup-vitest/).)

> Practice code: this installment's exact state lives in the [`step-05` tag](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-05). `git checkout step-05` to follow along — or [diff it against step-04](https://github.com/IsaacEryn/frontend-testing-lab/compare/step-04...step-05) to see just what this part added.

## This installment's goals

- Verifying Promises with async/await — `resolves` and `rejects`
- Manipulating time with fake timers
- Testing failure cases, which matter as much as success

{{< img src="images/contents/async-timeline-en.png" alt="A timeline diagram comparing real time and fake time - the upper real-time lane waits out the full 300ms making the test slow, while the lower fake-timer lane pushes time instantly with advanceTimersByTime so verification finishes in milliseconds" >}}

---

## How not to grade a blank page

Let me show you the most common async-testing accident first: the test that forgot its `await`.

```ts
it('fetches the user list', () => {
  expect(fetchUsers()).resolves.toHaveLength(3)   // no await!
})
```

This test **always passes.** Even with the server down. The test finishes before the verification does — a green light that protects nothing, the async cousin of the "fake green" we saw in the strategy article. Vitest does print a warning, but it's safer to burn in the principle:

**An async assertion always gets an `await`.** That's rule number one for everything we learn today.

---

## Building fetchUsers

Let's add a function to last time's `src/lib/users.ts` that fetches the user list from a server.

```ts
// appended to src/lib/users.ts
export async function fetchUsers(): Promise<User[]> {
  const res = await fetch('/api/users')
  if (!res.ok) throw new Error('failed to fetch users')
  return (await res.json()) as User[]
}
```

Send the request with `fetch`, throw if the response isn't healthy (`res.ok`), unwrap the JSON otherwise. A textbook API call.

But calling this from a test means you'd need **a real server.** Tests that pass or fail with the server's mood are trouble, so we'll borrow a tool a little early — **MSW**, which intercepts network requests and returns fake responses. Don't worry that we haven't covered it yet: for now, all you need to know is that "during tests, `/api/users` gets a prepared answer." The proper introduction comes in the component chapter.

If you're following along, copy the repo's `src/mocks/` folder (three files) as-is, and update `src/test/setup.ts` to the repo's version. That's the entire setup for faking the server.

---

## Nailing down success and failure

Now we add fetchUsers tests to `users.test.ts`. Promises get dedicated matchers — `resolves` for success, `rejects` for failure.

```ts
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { fetchUsers } from './users'

describe('fetchUsers', () => {
  it('fetches the user list', async () => {
    await expect(fetchUsers()).resolves.toHaveLength(3)
  })

  it('throws when the server errors', async () => {
    server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
    await expect(fetchUsers()).rejects.toThrow('failed to fetch users')
  })
})
```

How to read these:

- `await expect(promise).resolves.matcher()` — "this Promise should **succeed**, and its result should look like this." Wait for success, then verify the result.
- `await expect(promise).rejects.toThrow(...)` — "this Promise should **fail**, throwing this error." You're waiting for failure, on purpose. If it succeeds instead, the test fails.
- `server.use(...)` in the second test is a one-shot override: "for **this test only**, `/api/users` returns 500." When the test ends, the original response is restored automatically. Conjuring failure at will — that's the power of a fake server.

Testing only success is half the job. Network failures, empty responses, server errors — these are situations your users **actually meet**. Nail down "when it errors, it fails loudly" too, so the test catches whoever deletes the error handling later.

---

## Fake timers — pushing time instead

Now for a different kind of waiting. Calling the API on every keystroke is wasteful, so let's build a **debounce**: "run once, 300ms after the input stops." `src/lib/debounce.ts`:

```ts
/** Runs once, ms after the last call */
export function debounce<T extends (...args: never[]) => void>(fn: T, ms: number) {
  let timer: ReturnType<typeof setTimeout> | undefined
  return (...args: Parameters<T>) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), ms)
  }
}
```

Each call cancels the previous timer and restarts the 300ms clock, so in a burst of calls only the last one survives. (Curious about `never[]` in the type? The repo has a comment explaining it — skipping it costs you nothing today.)

If we tested this by actually waiting 300ms, two things break: tests get **slow** (minutes, at hundreds of tests), and they turn **flaky**, passing or failing with the machine's mood. So instead of waiting, we take control of time itself.

```ts
describe('debounce', () => {
  beforeEach(() => vi.useFakeTimers())
  afterEach(() => vi.useRealTimers())

  it('runs only once for a burst of calls', () => {
    const spy = vi.fn()
    const run = debounce(spy, 300)

    run(); run(); run()

    vi.advanceTimersByTime(299)
    expect(spy).not.toHaveBeenCalled()

    vi.advanceTimersByTime(1)
    expect(spy).toHaveBeenCalledOnce()
  })
})
```

Unpacking the new faces:

- `vi` — Vitest's built-in **helper object**, home to tools like fake timers and fake functions.
- `beforeEach` / `afterEach` — hooks that run automatically **around every test**. Here they guarantee "fake the clock before each test, always restore it after." Skip the restore and other tests end up living in fake time too — remember these as a pair.
- `vi.fn()` — a **fake function** that records its calls (a "spy"). Being able to ask "how many times were you called?" makes it perfect for verifying debounce. It stars in the next installment; this cameo is enough for today.
- `vi.advanceTimersByTime(299)` — **pushes** time forward 299ms. Not waiting — teleporting.

Stop at 299 to confirm "not yet," push 1 more to confirm "fires exactly at 300." Recognize the pattern? It's [last time's edge-case testing](/en/posts/frontend-testing-unit-basics/), replayed on the time axis. A test that would have taken hundreds of real milliseconds finishes in a few.

---

## Now break it on purpose

This installment's alarm check. Delete the `await` from the first fetchUsers test:

```ts
it('fetches the user list', () => {
  expect(fetchUsers()).resolves.toHaveLength(3)   // await deleted!
})
```

Then sabotage the code — change the URL in `users.ts` to `/api/userz`. The server will return 404, so the test should obviously go red... and yet **the light turns green** (Vitest logs an "unhandled rejection" warning, but the test itself records a pass). Restore the `await`, and only then do you get an honest red.

Don't forget to fix the URL back. Today's lesson in one line: **the more welcome a green light is, the more you should ask whether the test actually waited.**

---

## The one-page recap

- All of async testing hangs on "when do you verify" — **an async assertion always gets an `await`** (without it: a permanently green fake)
- Promise success: `await expect(p).resolves.matcher()`. Failure: `rejects.toThrow(...)` — and failure cases are half the job
- Don't wait on time-dependent code — **push time with fake timers**: `vi.useFakeTimers()` + `advanceTimersByTime()`
- `beforeEach`/`afterEach`: whatever world you fake, always restore
- When you're done, delete an `await` and witness the false pass yourself — you won't forget it afterward

---

## Async, no longer scary

Three weapons for code that waits: `await` + `resolves`/`rejects`, and fake timers. Next up is the art of swapping out dependencies — including the true identity of `vi.fn()`, which made its cameo today: test doubles, done properly.

If an async test passes sometimes and fails sometimes — congratulations, you've already met the **flaky test** foretold in part one. The culprit is usually a missing `await`.

> **Level up**: you can now test async code, timers, and failure cases without flinching.

> **Next up**: mock, stub, spy — understanding test doubles properly

> Bumped into an unfamiliar term? The [glossary](/en/posts/frontend-testing-glossary/) has them all, one line each.

