PLAINFINCH learn by building

Testing errors

You will test that code refuses bad orders with the right error message, so a vague or broken error cannot slip through.

Why test errors at all?

Refusing bad input is a job, and code can do that job badly. checkOrder() stands between Crumb and Crust's order form and the list the bakers work from on Saturday morning. If it lets through an order with no pickup day, someone bakes loaves that nobody collects. If it rejects good orders, the bakery loses customers. Both directions need tests.

Error tests also protect the message. The text in 'For more than 20 loaves, please phone the bakery' is shown to a real person, and it tells them what to do next. If a later change replaces it with a generic message, the code still throws and still technically refuses the order, but the customer is stuck. A test that checks the message catches that.

Why does my toThrow test crash?

JavaScript runs the argument before it calls the function. In expect(checkOrder({ qty: 0 })), checkOrder() runs first, throws, and the error flies straight out of the test before expect() is ever called. The runner sees an unexpected error and marks the test as failed, with the error from your own code as the reason.

The fix is to hand expect() a function that it can call later: expect(() => checkOrder({ qty: 0 })). Now toThrow() calls that function inside its own try, catches whatever comes out and decides whether it is the error you asked for. When the call throws, as here, the failure shows your own error, OrderError: Order at least one loaf, and no matcher message at all. When the call returns normally, the practice runner says expect() was given a value instead of a function. Vitest and Jest are less direct in both cases, so it helps to recognise the pattern.

// runs too early: the error escapes
expect(checkOrder({ day: 'Saturday', qty: 0 })).toThrow();

// hands over a function: toThrow calls it
expect(() => checkOrder({ day: 'Saturday', qty: 0 })).toThrow();
Figureexpect(checkOrder({ day: 'Saturday', qty: 0 })).toThrow() runs checkOrder first so the error escapes and the test fails; expect(() => checkOrder({ day: 'Saturday', qty: 0 })).toThrow() lets toThrow call it and pass.expect(checkOrder({ day: 'Saturday', qty: 0 })).toThrow() runs checkOrder first so the error escapes and the test fails; expect(() => checkOrder({ day: 'Saturday', qty: 0 })).toThrow() lets toThrow call it and pass.
Without () =>, checkOrder() throws before expect() even runs. Wrapping it hands toThrow a function it can call and catch.

What can you pass to toThrow?

With no argument, toThrow() passes for any error at all. That is rarely enough, as the Break It challenge shows. Give it something to compare and the test becomes far more precise.

A string passes when the error message contains it, so toThrow('one loaf') matches 'Order at least one loaf'. A regular expression is tested against the message, which helps when part of the text changes, such as a number. An error class passes when the error is an instance of it, so toThrow(OrderError) proves the code threw your own error type and not a TypeError from a typo. An error object, such as new Error('Choose a pickup day'), matches when the messages are exactly equal.

  • toThrow(): any error
  • toThrow('pickup day'): the message contains this text
  • toThrow(/phone the bakery/): the message matches this pattern
  • toThrow(OrderError): the error is this class or a subclass
  • .not.toThrow(): the function runs without throwing

When is a custom error class worth it?

A class like OrderError lets the code that calls checkOrder() tell the difference between a customer mistake and a real bug. The order form can catch OrderError and show its message under the field, while letting anything else reach the error log. Tests can make the same distinction with toThrow(OrderError).

Check the class and the message when both matter. Two expect() lines in one test are fine here, because they describe one behaviour: this input is refused, for this reason. If you only had time for one, the message usually catches more, because it is what changes when someone edits the wrong branch.

What is wrong with try and catch in a test?

A test like the Break It one puts the call in a try and the expect() in the catch. It reads sensibly. The trouble starts when the code stops throwing. The catch block never runs, no expect() fails, and the test passes. It checks the message only when there is an error, and says nothing when there is not.

Prefer expect(() => ...).toThrow(...), which fails when nothing is thrown. If you really need the error object, for example to check a field on it, add expect.assertions(1) at the top of the test. That tells the runner to fail the test unless exactly one assertion ran, which closes the gap. Never write a catch that ignores the error: an empty catch in a test turns every failure into a pass.

test('the error names the field', () => {
  expect.assertions(1);
  try {
    checkOrder({ qty: 2 });
  } catch (err) {
    expect(err.message).toMatch('pickup day');
  }
});

Which inputs should an error test try?

Every branch that throws needs at least one test, and the values near each limit need the same care as in the edge cases lesson. For checkOrder() that means a missing day, zero loaves, more than 20 loaves, and a number that is not a whole loaf, such as 1.5. Pair each refusal with a test that the nearest good value is accepted: 20 loaves goes through, 21 does not.

The accepting tests matter as much as the refusing ones. A function that throws for everything would pass every error test you write. Only the happy-path tests, such as two loaves for Saturday, prove the code still lets good orders in.

How do you read a failed error test?

When toThrow() fails, Received shows what actually happened. It says no error when the function returned normally, which usually means a check is missing or its condition is wrong. When it shows a different message, read it closely: a message like hour is not defined is a bug in the error path itself, where a typo throws a ReferenceError instead of the error you wrote.

That second case is sneaky because a bare toThrow() passes. The code is broken and the test is green. Checking the message turns it red and shows you the real error text, which leads straight to the misspelt variable.

What do AI tools get wrong when testing errors?

The most common slip is the unwrapped call, expect(fn(bad)).toThrow(). Close behind is the bare toThrow() with no message or class, which passes for typos and unrelated failures. Generated tests also tend to use the try and catch pattern without expect.assertions(), so they pass when the error disappears.

When you review error tests, check three things. The call is wrapped in () =>. Each toThrow() names a message, pattern or class. And there is a matching test showing that good input does not throw. Those three checks take seconds and catch most weak error tests.

Field notes

  • Wrap the call in an arrow function, as in expect(() => checkOrder(o)).
  • A bare toThrow() passes for any error, typos included.
  • Pass part of the message or an error class to toThrow() to make it precise.
  • Pair each refusal test with a test that good input is accepted.

Easy to get wrong

Without the arrow function the error is thrown before expect() runs, so the test fails with your code's error instead of checking it.

A bare toThrow() passes for any error, including a typo that throws a ReferenceError. Name the message or the class you expect.

If nothing is thrown, the catch never runs and the test passes. toThrow() fails when there is no error.

Questions people ask

Can the tests use OrderError directly?

Yes. Add it to the import line, import { checkOrder, OrderError } from './orders.js', and write toThrow(OrderError). The practice runner makes exported classes and constants available to the tests, the same as Vitest and Jest. The lesson's tests check messages because the message is what the customer reads, and you can add a class check alongside.

Is toThrowError different from toThrow?

No. toThrowError is another name for the same matcher in Vitest and Jest, and the practice runner accepts both. Use whichever your team already uses.

Does toThrow work with async functions?

Not on its own. An async function returns a rejected promise instead of throwing, so you write await expect(placeOrder(order)).rejects.toThrow('pickup day'). That works in Vitest, Jest and the practice runner, and the async tests lesson covers it.

Should every function check its input and throw?

Functions at the edge of your code, where data arrives from forms, files or other services, should. Small helpers deep inside, called only by your own checked code, usually do not need to repeat the checks.

Why test the exact message when it might change?

Test the part the reader will see. A short string such as 'pickup day' or a pattern survives small rewording while still catching a message that has gone vague or wrong.

Keep going