# Testing Edge Cases, Errors and Async Code

Bugs hide at the limits, in the errors and in the code that waits, so this lesson tests all three with Vitest, using toThrow, resolves and rejects, and fake timers that skip ahead in time.

Freelancer · JavaScript · Unit Testing · Updated November 1, 2026

## After this lesson you can

- Pick the edge cases a function needs tests for
- Test that a function throws an error with expect and toThrow
- Test async code and timers with await, resolves, rejects and vi.useFakeTimers

## Key takeaways

- An edge case is a value at a limit, or one step past it. Test the limit itself, the value on each side of it, zero, empty and the biggest value.
- To test an error, wrap the call in an arrow function, expect(() => ...).toThrow('message'), so Vitest can catch what it throws.
- Make a test async and await what it checks. await expect(...).resolves and .rejects check a promise's value or its error.
- Hand async code a stand-in for the server, so the test is fast and gives the same answer every time.
- vi.useFakeTimers() and vi.advanceTimersByTime() skip ahead in time, so a 5-minute timer is tested in a moment.

A test that checks a 2 PM show and a 7 PM show is a good start, but it walks right past the places where bugs live. Real ticket bugs look like this: the 5 PM show charged the matinee price, seat 121 sold in a theater with 120 seats, and a booth that froze when the gift card server didn't answer. Each sits at a limit, in an error, or in code that waits. This lesson tests all three, the same tests you'll need for any shopping cart, sign-up form or game you build.

The Starlight Cinema's booth is still the example: show prices, booking seats, gift cards, and seats held for five minutes while someone pays.

## Edge cases: where bugs hide

A diner's sign says *Kids 12 and under eat free.* A family walks in with a 12-year-old. Does she eat free? The sign says yes, but a cashier who reads it in a hurry might say no. Every rule with a limit has a value like 12, and code can get it wrong by one in the same way.

A value at a limit, or one step past it, is an **edge case**. (In a textbook, picking these values is called *boundary value analysis*.) Here's `showPrice` with the most common bug there is, `<=` (read "less than or equal to") where it should be `<`:

```javascript
export function showPrice(hour) {
  if (hour <= 17) {
    return 9;
  }
  return 14;
}
```

The tests for 2 PM and 7 PM still pass, so the bug slips through. Two tests at the edge, one on each side of it, catch it: `expect(showPrice(16)).toBe(9)` for a 4 PM show and `expect(showPrice(17)).toBe(14)` for a 5 PM show.

```text
 ❯ tickets.test.js (2 tests | 1 failed) 6ms
   ❯ showPrice (2)
     ✓ charges the matinee price for a 4 PM show 1ms
     × charges the evening price for a 5 PM show 4ms
```

Each test like these is a **test case**: one input and the answer it should give. When you pick test cases, look for:

- **the limit itself**, and the value **on each side** of it: 16 and 17 for a 5 PM cutoff; 7 and 8 for "groups of 8 or more";
- **zero**: 0 seats left, 0 tickets;
- **empty**: an empty array, an empty string;
- **the biggest value**: a full house, all 120 seats sold at once;
- **a value that isn't allowed**: −1 seats, a guest type that doesn't exist.

Changing `<=` back to `<` turns the test green, and it stays on guard from then on.

## Testing errors with toThrow

When you ask the box office for five seats and only three are left, a good agent says *Sorry, only 3 seats left* rather than printing five tickets. In code, that's an error on purpose. `bookSeats` throws one, with `throw new Error(...)`, when a request can't be met:

```javascript
export function bookSeats(showing, count) {
  if (count < 1) {
    throw new Error('Pick at least 1 seat');
  }
  if (count > showing.seatsLeft) {
    throw new Error(`Only ${showing.seatsLeft} seats left`);
  }
  return { ...showing, seatsLeft: showing.seatsLeft - count };
}
```

Refusing is part of its job, so it needs tests too. The matcher is **`toThrow`**, and the call goes inside an arrow function:

```javascript
describe('bookSeats', () => {
  const showing = { title: 'Space Pirates 3', seatsLeft: 3 };

  it('can sell the very last seats', () => {
    expect(bookSeats(showing, 3)).toEqual({ title: 'Space Pirates 3', seatsLeft: 0 });
  });

  it('refuses more seats than are left', () => {
    expect(() => bookSeats(showing, 4)).toThrow('Only 3 seats left');
  });

  it('refuses zero seats', () => {
    expect(() => bookSeats(showing, 0)).toThrow('Pick at least 1 seat');
  });
});
```

Why the arrow, `() => bookSeats(showing, 4)`? Without it, `bookSeats` runs first, throws, and the test stops before `expect` ever gets a look. Vitest reports the test as failed with the error itself, `Error: Only 3 seats left`, which is confusing, because that's the error you wanted. With the arrow, you hand `expect` the call *unmade*, and `expect` makes it inside a safety net, catches what's thrown and checks it. The text in `toThrow('Only 3 seats left')` only needs to be part of the error's message.

## Testing async code: await, resolves and rejects

At a busy food court you order, and they hand you a buzzer. The food isn't ready, but the buzzer is a promise that it will be. An `async` function works the same way: it hands back a **promise** right away, and the value arrives later. A test has to wait for it with `await`, and a test that awaits is an `async` function too.

`checkGiftCard` asks the theater's server for a card's balance. The function that asks is handed in as `lookUpBalance`, so the code doesn't care who answers:

```javascript
export async function checkGiftCard(code, lookUpBalance) {
  const balance = await lookUpBalance(code);
  if (balance === undefined) {
    throw new Error(`No gift card with the code ${code}`);
  }
  return balance;
}
```

A test shouldn't call the real server: it's slow, it might be down, and the balances change. Instead, the test hands over a stand-in, a function that answers at once from a list. A stand-in like that is called a **test double**, like a stunt double in a film:

```javascript
describe('checkGiftCard', () => {
  // A stand-in for the theater's server: it answers at once, from a list.
  async function fakeLookUp(code) {
    const balances = { 'MOVIE-1234': 25, 'MOVIE-5678': 0 };
    return balances[code];
  }

  it('finds the balance of a gift card', async () => {
    const balance = await checkGiftCard('MOVIE-1234', fakeLookUp);
    expect(balance).toBe(25);
  });

  it('finds an empty card too', async () => {
    await expect(checkGiftCard('MOVIE-5678', fakeLookUp)).resolves.toBe(0);
  });

  it('rejects a code that does not exist', async () => {
    await expect(checkGiftCard('MOVIE-0000', fakeLookUp)).rejects.toThrow('No gift card');
  });
});
```

- **`await`, then `expect`**, in the first test: wait for the value, then check it like any other.
- **`.resolves`** checks the value a promise ends up with: `await expect(promise).resolves.toBe(0)`.
- **`.rejects`** checks the error a promise ends up with: `await expect(promise).rejects.toThrow('No gift card')`. `checkGiftCard` throws when the server knows no such card, and an `async` function that throws hands back a promise that **rejects**.

In the app, `lookUpBalance` calls the real server with `fetch`; in the test, it's `fakeLookUp`. `checkGiftCard` works the same with either.

## A forgotten await

Leave out `await` and the test checks the buzzer instead of the food. `expect(checkGiftCard('MOVIE-1234', fakeLookUp)).toBe(25)` fails with `expected Promise{…} to be 25`: the test got the promise, not the 25. Leaving `await` off a `.resolves` or `.rejects` check is caught too. Vitest 5 fails the test and says what's missing:

```text
Error: Promise returned by `expect(actual).resolves.toBe(expected)` was not awaited. This assertion is asynchronous and must be awaited; otherwise, it is not guaranteed to complete before the test finishes:

await expect(actual).resolves.toBe(expected)
```

The rule that keeps you out of trouble: when the code under test is `async`, make the test `async`, and put `await` in front of the call or the `expect`.

## Fake timers: skip ahead in time

A nature show films a flower for a week and plays it back in ten seconds. Tests need the same trick. When you pick seats, the booth holds them for five minutes while you pay, then lets them go. `holdSeats` does that with a timer:

```javascript
export function holdSeats(count, onExpire) {
  return setTimeout(() => onExpire(count), 5 * 60 * 1000);
}
```

A real test would wait five real minutes. **Fake timers** replace JavaScript's clock while a test runs, so the test decides when time passes. They live on **`vi`** (read "V I"), Vitest's toolbox, which you import like `describe`:

```javascript
import { describe, it, expect, vi, afterEach } from 'vitest';

describe('holdSeats', () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it('lets the seats go after 5 minutes', () => {
    vi.useFakeTimers();
    const letGo = [];

    holdSeats(2, (count) => letGo.push(count));

    vi.advanceTimersByTime(4 * 60 * 1000);
    expect(letGo).toEqual([]);

    vi.advanceTimersByTime(1 * 60 * 1000);
    expect(letGo).toEqual([2]);
  });
});
```

- **`vi.useFakeTimers()`** swaps in the fake clock. Timers set after this line wait for the test's say-so.
- **`vi.advanceTimersByTime(ms)`** moves the clock forward and runs every timer that comes due. After 4 minutes nothing has happened; after the 5th, the seats are let go. Checking both sides of the 5 minutes is the edge-case habit again.
- **`afterEach(() => vi.useRealTimers())`** runs after every test in the group and puts the real clock back, so one test's fake clock never leaks into the next.

The whole file runs in a moment:

```text
 ✓ tickets.test.js (9 tests) 6ms
   ✓ showPrice (2)
     ✓ charges the matinee price for a 4 PM show 1ms
     ✓ charges the evening price for a 5 PM show 0ms
   ✓ bookSeats (3)
     ✓ can sell the very last seats 0ms
     ✓ refuses more seats than are left 0ms
     ✓ refuses zero seats 0ms
   ✓ checkGiftCard (3)
     ✓ finds the balance of a gift card 0ms
     ✓ finds an empty card too 0ms
     ✓ rejects a code that does not exist 0ms
   ✓ holdSeats (1)
     ✓ lets the seats go after 5 minutes 2ms
```

Nine tests, including a five-minute timer, in 6 milliseconds.

**Try it.** Add `bookSeats`, `checkGiftCard` and `holdSeats` to your `starlight-cinema` project's `tickets.js`, and these tests to `tickets.test.js`. Then plant a bug on purpose in each function, such as `count >= showing.seatsLeft`, or `4 * 60 * 1000` for the timer, and check that a test turns red each time.

## Check yourself

3 questions to try on the lesson page.

## So what

The happy path is where code works on day one; the edges, the errors and the waiting are where it breaks in front of customers. A test at each of them is a smoke alarm in the rooms where fires start.

## Key terms

- [Edge Case](https://www.ask-codey.com/glossary/edge-case)
- [Test Case](https://www.ask-codey.com/glossary/test-case)
- [Test Double](https://www.ask-codey.com/glossary/test-double)
- [Promise](https://www.ask-codey.com/glossary/promise)
- Fake Timers

## Cheat sheets

- [Testing Edge Cases, Errors and Async Code](https://www.ask-codey.com/cheat-sheets/javascript-testing-edge-cases-errors-async-cheat-sheet)

## Related

- [Your First Tests with Vitest](https://www.ask-codey.com/lessons/javascript-testing-first-tests-with-vitest)
- [Testing a Page with jsdom](https://www.ask-codey.com/lessons/javascript-testing-the-dom)

Source: https://www.ask-codey.com/lessons/javascript-testing-edge-cases-errors-async
