CodeOath
← All posts
Testing64 min total · 12 parts

Testing Fundamentals: Unit, Integration, and E2E Tests Done Right

Part 5 of 12 · ~8 min

Jest Fundamentals

Jest is what most JavaScript and TypeScript projects reach for as their test runner and assertion library, ours included — it's what the Node half of this system runs on. The vocabulary it gives you for building a test file is small and stays consistent from file to file. Here it is, applied to the function we've been building up:

describe("inviteStatus", () => {
  test("returns 'early' before the recruiter's window opens", () => {
    const invite = makeInvite({ opensAt: NOW + HOUR });
    expect(inviteStatus(invite, NOW)).toBe("early");
  });

  it("returns 'open' inside the window when the candidate has not started", () => {
    expect(inviteStatus(makeInvite(), NOW)).toBe("open");
  });

  test("returns 'submitted' once a submission has been recorded", () => {
    const invite = makeInvite({ submittedAt: NOW - MINUTE });
    expect(inviteStatus(invite, NOW)).toBe("submitted");
  });
});

describe is purely cosmetic — it buckets related tests under one label so the output reads cleanly, and has zero bearing on whether anything passes or fails. test and it are the exact same function wearing two different names; it exists only so a line reads like an English sentence when you say it out loud — "it returns 'open' inside the window." Neither spelling does anything the other doesn't. Settle on one per codebase and don't switch back and forth, for the same reason you wouldn't rename a variable halfway through a file.

Common matchers

Call expect(value) and you get back an object whose methods are the actual comparison — everything chained after the dot is doing the real work of the assertion:

expect(inviteStatus(invite, NOW)).toBe("expired");        // strict equality (Object.is)
expect(report).toEqual({ passed: 8, total: 10, score: 80 }); // deep equality — structure, not identity
expect(exercise.tags).toContain("concurrency");            // array/iterable membership
expect(caseResult.stderr).toMatch(/AssertionError/);       // regex or substring match on a string
expect(invite.startedAt).toBeNull();
expect(invite.submittedAt).toBeUndefined();
expect(remainingMs(invite, NOW)).toBeGreaterThan(0);
expect(() => session.submit(code, NOW)).toThrow();         // throws — MUST wrap in a function
expect(() => session.submit(code, NOW)).toThrow("window has closed"); // message contains this substring
await expect(requestGrade("sub_91")).resolves.toEqual(report); // a Promise resolves to this value
await expect(requestGrade("nope")).rejects.toThrow();          // a Promise rejects
// those last two need the leading await — see the async chapter for what happens without it

toBe versus toEqual is where almost everyone gets bitten at least once, and our report object is a good way to see why. toBe checks with Object.is, which behaves like === — fine for a string or a number, but two separately built objects holding identical data are never toBe-equal, because identity and content are different questions and toBe only ever asks about identity. expect(report).toBe({ passed: 8, total: 10, score: 80 }) will fail forever, no matter how correct that report actually is. toEqual asks the content question instead, walking the structure recursively — that's almost always the matcher you actually want for an object or an array.

Common mistake: calling expect(session.submit(code, NOW)).toThrow() instead of wrapping the call — expect(() => session.submit(code, NOW)).toThrow(). Written the first way, submit runs the instant that line executes, before expect ever receives a value to inspect, so a thrown error blows up the test outright rather than registering as a clean, readable failure, and whatever the runner prints won't point at the actual problem. toThrow has to be the one calling your code itself, wrapped in its own try/catch block, which is exactly why it insists on being handed a function instead of a result.

Tables with test.each

inviteStatus has four outcomes and five branches, which means five nearly identical test bodies that differ only in their input and expected output. Copy-pasting them is how a suite ends up with a case nobody notices is missing. Jest's test.each runs one body over a table:

test.each([
  ["before the window opens",        { opensAt: NOW + HOUR },                         "early"],
  ["after the window closes",        { closesAt: NOW - MINUTE },                      "expired"],
  ["candidate's own clock ran out",  { startedAt: NOW - 91 * MINUTE },                "expired"],
  ["inside both clocks",             { startedAt: NOW - 10 * MINUTE },                "open"],
  ["already submitted",              { submittedAt: NOW - MINUTE },                   "submitted"],
])("inviteStatus: %s", (_label, overrides, expected) => {
  expect(inviteStatus(makeInvite(overrides), NOW)).toBe(expected);
});

Jest reports each row as its own separate pass or fail, named by the label, so a regression in one branch does not hide behind four passing siblings. The table also documents the function better than five scattered tests would: every rule the function implements is visible in one nine-line block. We will meet this exact table again on the Python side, where it is called parametrize.

Setup and teardown

Once a test needs something more expensive than an object literal — a database, a temp directory, a running service — you need somewhere to build it and somewhere to tear it down. Jest gives you four hooks. Here they are around the repository layer that actually reads invites out of Postgres:

describe("inviteRepo.findByToken", () => {
  let db;

  beforeAll(async () => {
    db = await connectToTestDatabase(); // once, before any test in this block
    await db.migrate();
  });

  beforeEach(async () => {
    await db.insertInvite(makeInvite({ token: "iv_7c2a" })); // before EVERY test here
  });

  afterEach(async () => {
    await db.truncateAll(); // after every test — this is what keeps them isolated
  });

  afterAll(async () => {
    await db.disconnect(); // once, after every test in this block has run
  });

  test("finds an invite by its token", async () => {
    const invite = await findByToken("iv_7c2a");
    expect(invite.exerciseId).toBe("ex_rate_limiter");
  });

  test("returns null for a token that does not exist", async () => {
    expect(await findByToken("iv_nope")).toBeNull();
  });
});

beforeEach and afterEach fire around every single test in whatever block they live in; beforeAll and afterAll fire exactly once, no matter how many tests share that block. The split here comes down to cost: opening a Postgres connection and running migrations is slow, but the result is safe to reuse, so that work lives in beforeAll. A row, on the other hand, is cheap to create and absolutely not safe to leave lying around for the next test, so it gets inserted in beforeEach and wiped in afterEach.

That afterEach is the thing that actually makes Isolated — the "I" from FIRST, back a couple of sections ago — real instead of aspirational. Take it away and the second test in this block starts seeing rows the first one never cleaned up. Worse, nothing complains right away — it keeps passing until a third test inserts another row with the same token, or the runner happens to shuffle the execution order, and only then does something fail in a file nobody touched that day. State quietly leaking from one test into the next is behind most of the times someone says a suite "passes locally" and nowhere else.

Snapshot testing, and its real trade-off

A snapshot test takes whatever a piece of code produces the first time the test runs, stores that output in a file sitting next to the test, and from then on checks every later run against what got saved. It's built for things too unwieldy to check field by field — rendered markup being the usual case:

test("AssessmentBanner renders the pre-start state", () => {
  const tree = renderToString(
    <AssessmentBanner status="early" opensAt={NOW + 2 * DAY} now={NOW} />
  );
  expect(tree).toMatchSnapshot();
});

There's a real appeal here. A single line locks down an entire shape of output that would otherwise cost two dozen separate assertions to pin down piece by piece, which makes broad coverage over something like a rendered component astonishingly cheap to add.

The cost is just as real, though, and it's exactly why a snapshot deserves more suspicion than a normal assertion. The moment one fails, Jest hands you a single command that makes the red go away: jest --updateSnapshot. Nothing about that command asks whether what changed was right — it just takes whatever the code produces right now and writes it down as the new correct answer. Picture a developer twenty minutes from a demo, staring at forty red snapshots after touching some unrelated CSS: they run the update command and move on with their day. Buried in those forty diffs, unnoticed, is the one where the banner quietly stopped showing any remaining time at all. The snapshot test didn't fail to notice that regression — it noticed, offered a button that would make the noticing go away, and got clicked. As far as the CI dashboard is concerned, nothing ever happened.

Where snapshots genuinely earn their place is large output that barely changes and that a human will actually sit down and read the diff of — a design-system component that's gone stable, a generated config file nobody touches often. They're the wrong tool for anything that churns constantly, or anything a reviewer will most likely nod past without truly reading. For our own banner, the more honest approach is two narrow assertions aimed at the parts that actually carry meaning:

test("AssessmentBanner shows the time remaining while the assessment is open", () => {
  render(<AssessmentBanner status="open" remainingMs={62 * MINUTE} />);
  expect(screen.getByRole("timer")).toHaveTextContent("1:02:00");
  expect(screen.getByRole("button", { name: /submit/i })).toBeEnabled();
});

Longer to write, smaller to review, and it fails for exactly one reason instead of forty.