InteractiveFrameworks

Your First Test

Write unit tests for a plain service: the shape of a test, the matchers that say what you expect, and the bugs a test is worth nothing without catching.

What you'll learn

  • Structure a test with describe, it and beforeEach, and read its result
  • Choose between toBe, toEqual, toHaveLength and toThrow, and know why toThrow takes a function
  • Judge a test by the bugs it catches, not by whether it passes

Every lesson so far was graded by calling your endpoints. That is a test: something runs your code, looks at what came back, and says pass or fail. This course hands you the tool that did it. A test is code that exercises other code and states what should have happened, and the point of writing one is not the green tick. It is that the next change, yours or a colleague's, cannot break what the test states without someone hearing about it.

The shape

A test file, by convention something.spec.ts next to something.ts, is a script that registers tests. Three functions do the registering, and they exist as globals, no import needed:

describe('Calculator', () => {
  let calculator: Calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  it('adds two numbers', () => {
    const sum = calculator.add(2, 3);
    expect(sum).toBe(5);
  });
});

describe groups tests under a name, usually the class under test. it (or test, the same function) registers one test: a name that reads as a sentence, and a function that runs it. beforeEach runs before every test in the group, and it exists so each test starts from a fresh object and cannot depend on what an earlier test did. A test that only passes when another ran first is a test that will fail on the day it runs alone.

Inside a test, the three lines you see are the shape every test has: arrange the situation, act on the code under test, assert what came out. Keep them in that order and a failing test reads like a bug report.

Saying what you expect

expect(value) returns an object whose methods are matchers. The ones you will use every day:

MatcherPasses when
toBe(x)the value is x, by Object.is: the same number, string, boolean, or the same object reference
toEqual(x)the value has the same shape and contents as x, however deep
toHaveLength(n)an array or string has that length
toContain(x)an array contains x, or a string contains the substring
toBeDefined(), toBeUndefined(), toBeNull(), toBeTruthy()what they say
toThrow(x?)calling the value throws; with x, the error is that class, or its message contains that string

.not in front of any matcher inverts it: expect(luna.id).not.toBe(tom.id).

The one that trips everyone is toBe on objects. Two objects with the same contents are not the same object, so expect(service.findOne(1)).toBe({ id: 1, name: 'Tom' }) fails even when the cat is right. Use toEqual for objects and arrays, toBe for primitives.

The other is toThrow. Writing expect(service.findOne(99)).toThrow() calls findOne(99) before expect ever runs, and the throw escapes the test. Hand expect a function that does the call, and the matcher calls it inside a try:

expect(() => service.findOne(99)).toThrow(NotFoundException);

What a failure looks like

When a matcher fails, the test stops there and the failure reads:

expect(received).toEqual({ id: 1, name: "Tom", age: 3 })

Expected: { id: 1, name: "Tom", age: 3 }
Received: { id: 1, name: "Tom", age: 4 }
    at cats.service.spec.ts:14:17

Read it top down: which matcher, what you said should happen, what did, and the line in your spec. A good test name plus that message is usually enough to find the bug without a debugger.

A test is worth the bugs it catches

it('creates a cat', () => { service.create('Tom', 3); }) passes. It also passes if create stores nothing, returns nothing, or stores the wrong cat, because it states nothing. A passing test proves only that no assertion failed, and a test with no assertion cannot fail.

This workspace grades your tests the way the harness behind this course grades every lesson: it runs them against the service, where they must pass, and then against several buggy versions of the service, where at least one of them must fail. The Tests tab lists those bugs. A bug that gets through is a hole in your tests, and the tab tells you which. This is called mutation testing, and it is the honest measure of a test: not "does it pass", but "would it notice".

Your task

cats/cats.service.ts is the service from Basics, with nothing to inject, so new CatsService() is all a test needs, and beforeEach already does it. Replace the four it.todo entries in cats.service.spec.ts with tests:

  1. create() returns the cat with the name and age it was given, and two cats get different ids.
  2. findAll() lists the created cats in the order they were created.
  3. findOne() throws NotFoundException for an id nobody has.
  4. remove() returns the removed cat, and afterwards the cat is neither listed nor found.

Then look at the bugs the tab says your tests must catch, and make sure each one fails at least one test.

When it fails

  • Expected the promise to resolve or needs a function: toThrow was given a value instead of a function. Wrap the call in () => ....
  • toBe fails on two cats that look identical: they are two objects. toEqual compares contents.
  • A bug escapes: no test states the thing the bug breaks. "every cat gets id 1" escapes tests that never compare two ids; "remove keeps the cat" escapes tests that check only the return value.
  • A test passes alone and fails with the others: state leaks between tests. beforeEach should build the service fresh.

Remember

  • describe groups, it registers, beforeEach resets; arrange, act, assert.
  • toEqual for objects and arrays, toBe for primitives, toThrow on a function.
  • A test that cannot fail states nothing; judge a test by the bugs it would catch.
Stuck? Show a hint

Each it() arranges (create some cats), acts (call the method), asserts (expect). Use toEqual for objects and arrays, toBe for numbers and strings. To assert a throw, hand expect a function: expect(() => service.findOne(99)).toThrow(NotFoundException). For remove(), check the return value and then that findAll() and findOne() no longer know the cat.