Skip to content

06 · Testing Advanced

Level 2 covered basic Jest assertions. This module goes further: typed mocks and spies, mocking an interface (not a concrete class), and testing async rejection paths — using Vitest, which has first-class TypeScript support and a Jest-compatible API, so everything here reads directly onto Jest too.

Setup

npm install -D vitest

The code under test

// notifier.ts
export interface EmailClient {
  send(to: string, subject: string, body: string): Promise<boolean>;
}

export class SignupNotifier {
  constructor(private client: EmailClient) {}

  async welcome(email: string): Promise<string> {
    const ok = await this.client.send(email, "Welcome!", "Thanks for signing up.");
    if (!ok) {
      throw new Error(`failed to email ${email}`);
    }
    return `welcomed ${email}`;
  }
}

SignupNotifier depends on an EmailClient interface, not a concrete SendGridClient class — that's what makes it testable without a real mail server: any object shaped like EmailClient satisfies the constructor.

Typed mocks, spies, and rejection testing

// notifier.test.ts
import { describe, it, expect, vi } from "vitest";
import { SignupNotifier, EmailClient } from "./notifier";

describe("SignupNotifier", () => {
  it("returns a confirmation when the email sends", async () => {
    const client: EmailClient = {
      send: vi.fn().mockResolvedValue(true),
    };
    const notifier = new SignupNotifier(client);

    const result = await notifier.welcome("ada@example.com");

    expect(result).toBe("welcomed ada@example.com");
    expect(client.send).toHaveBeenCalledWith(
      "ada@example.com",
      "Welcome!",
      "Thanks for signing up."
    );
    expect(client.send).toHaveBeenCalledTimes(1);
  });

  it("throws when the email client reports failure", async () => {
    const client: EmailClient = {
      send: vi.fn().mockResolvedValue(false),
    };
    const notifier = new SignupNotifier(client);

    await expect(notifier.welcome("bad@example.com")).rejects.toThrow(
      "failed to email bad@example.com"
    );
  });

  it("spies without replacing behavior using a partial mock", async () => {
    const realClient: EmailClient = {
      send: async () => true,
    };
    const spy = vi.spyOn(realClient, "send");
    const notifier = new SignupNotifier(realClient);

    await notifier.welcome("grace@example.com");

    expect(spy).toHaveBeenCalledOnce();
  });
});

Run with vitest run:

 RUN  v4.1.11

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  21:50:00
   Duration  160ms

client: EmailClient = { send: vi.fn().mockResolvedValue(true) } type-checks because vi.fn() returns a mock function assignable to (to: string, subject: string, body: string) => Promise<boolean> — TypeScript checks the shape of the mock against the interface, so a mock with the wrong parameter count or return type fails to compile, not just to run.

Traps

vi.fn() alone is untyped (Mock<any, any>) until it's assigned into a typed slot. Writing const send = vi.fn(); send(1, 2, 3); compiles fine on its own — the type-checking above only happens because send is placed into an object literal declared as EmailClient. A bare vi.fn() stored in an untyped const gives you no protection.

mockResolvedValue vs. mockReturnValue is a common typo. For an async interface method, mockReturnValue(true) type-checks (a mock function's return type is inferred loosely) but the caller's await gets true directly rather than a resolved promise wrapping it — this usually still works by accident because await on a non-promise just returns it, but mockResolvedValue is the version that matches the interface's actual Promise<boolean> signature and won't silently mask a real async bug (e.g. rejection handling) the way a plain sync return value can.

toHaveBeenCalledWith doesn't fail if you pass too few expected arguments — it only checks the arguments you specify are among the ones present in a lenient way for extra args in some matcher variants, so add toHaveBeenCalledTimes alongside it if call count also matters, as shown above.

Testing against a concrete class instead of an interface forces real mocking libraries (vi.mock(), module factories) which are harder to type correctly — depending on the EmailClient interface rather than a SendGridClient class is what let the tests above use plain object literals with zero mocking-framework ceremony.

How It Actually Works

Type-level testing tools (tsd, expect-type, or Jest's own expectTypeOf) work by exploiting the fact that assignability comparisons happen entirely at compile time and can be turned into a pass/fail signal without running anything: a helper like Equal<A, B> is typically implemented as a conditional type that checks mutual assignability in both directions using a function-type trick ((<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false) — the double-conditional-through-a-generic-function pattern exists specifically to distinguish types the checker would otherwise treat as interchangeable through ordinary one-directional assignability (like any matching everything, or two differently-computed-but-assignable unions), forcing an exact structural identity check rather than a "is compatible with" check.

This category of test genuinely executes as part of tsc's check phase, not at runtime — a type-level assertion that fails produces a compile error at the line it's declared, with no test runner or executed code involved at all, which is why type tests can catch a regression (a return type quietly widening from a literal union to string, say) that a purely runtime unit test asserting on a specific value would never notice, since the runtime value can still be correct while its inferred type has silently degraded.

Mocking generic or overloaded functions for runtime tests runs into the inference machinery from the generics lesson directly: a mock replacement for a function with multiple overload signatures only satisfies the type checker if the mock's own signature is compatible with every overload the real implementation declares — this is why test mocks of complex typed APIs sometimes need as unknown as RealType casts, not because the mock's runtime behavior is wrong, but because reproducing an overloaded or deeply-generic real signature exactly in a hand-written mock is often impractical, and the cast explicitly tells the checker to stop verifying what it structurally can't easily confirm.

Cheat sheet

Technique Use for
vi.fn().mockResolvedValue(v) Async method that should resolve to v
vi.fn().mockRejectedValue(err) Async method that should reject
vi.spyOn(obj, "method") Wrap a real method, keep its behavior, assert calls
expect(fn).toHaveBeenCalledWith(...) Assert exact arguments
expect(promise).rejects.toThrow(msg) Assert an async function throws/rejects
Depend on an interface, not a class Enables typed object-literal mocks with no mocking library

Exercise

Add a retryWelcome(email: string, attempts: number) method to SignupNotifier that calls this.client.send up to attempts times, returning on the first success and throwing after the last failure. Write a test using vi.fn() with .mockResolvedValueOnce(false) chained twice then .mockResolvedValueOnce(true) to verify it retries exactly the right number of times before succeeding.