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¶
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:
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.