Jest is the most common test runner and assertion library for JavaScript and TypeScript projects. A test file is built from a small, consistent vocabulary.
describe("StringUtils.capitalize", () => {
test("capitalizes the first letter", () => {
expect(capitalize("hello")).toBe("Hello");
});
it("leaves an already-capitalized string unchanged", () => {
expect(capitalize("Hello")).toBe("Hello");
});
test("returns an empty string unchanged", () => {
expect(capitalize("")).toBe("");
});
});
describe groups related tests under a shared label, purely for organization and readable output — it doesn't affect whether tests pass or fail. test and it are literally the same function under two names; it exists so a test reads like a sentence ("it capitalizes the first letter"). Neither naming choice matters functionally — pick one per project and stay consistent.
expect(value) returns an object with matcher methods chained onto it — the second half of every assertion:
expect(2 + 2).toBe(4); // strict equality (Object.is)
expect({ a: 1 }).toEqual({ a: 1 }); // deep equality — same structure, different object identity
expect([1, 2, 3]).toContain(2); // array/iterable membership
expect("hello world").toMatch(/world/); // regex or substring match on a string
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(5).toBeGreaterThan(3);
expect(() => riskyCall()).toThrow(); // function throws — MUST wrap in a function
expect(() => riskyCall()).toThrow("bad input"); // throws with a message containing this substring
expect(fetchUser(1)).resolves.toEqual({ id: 1 }); // a Promise resolves to this value
expect(fetchUser(-1)).rejects.toThrow(); // a Promise rejects
toBe vs. toEqual trips people up constantly: toBe uses Object.is (essentially ===) — fine for primitives, but two different object literals with identical contents are never toBe-equal, since they're different references. toEqual recursively compares structure and values instead, which is what you want for objects and arrays almost all the time.
Common mistake: calling
expect(riskyCall()).toThrow()instead ofexpect(() => riskyCall()).toThrow(). The first form callsriskyCall()immediately, and if it throws, the exception happens beforeexpectever receives a value — the test crashes with an unhandled error rather than failing the assertion cleanly.toThrowneeds a function it can call internally, inside its own try/catch, which is why the callback wrapper is mandatory.
describe("Database-backed UserRepository", () => {
let db;
beforeAll(() => {
db = connectToTestDatabase(); // once, before any test in this block
});
beforeEach(() => {
db.seed({ users: [{ id: 1, name: "Ana" }] }); // before EVERY test in this block
});
afterEach(() => {
db.clear(); // after every test — keeps tests isolated from each other
});
afterAll(() => {
db.disconnect(); // once, after every test in this block has run
});
test("finds a user by id", () => {
expect(new UserRepository(db).findById(1).name).toBe("Ana");
});
});
beforeEach/afterEach run around every test in their scope; beforeAll/afterAll run exactly once for the whole block. Reaching for beforeEach to reset state is what actually delivers the "isolated" property from the FIRST checklist above — without it, one test's leftover state can silently change the outcome of the next one, and the order tests happen to run in starts mattering, which is exactly the kind of flakiness a good suite is supposed to avoid.
A snapshot test captures a value — often a rendered component's output, or a large object — the first time it runs, saves it to a file, and on every future run compares the current output against that saved copy:
test("UserCard renders the expected structure", () => {
const tree = renderToString(<UserCard name="Ana" role="Admin" />);
expect(tree).toMatchSnapshot();
});
The appeal is real: one line covers an entire output shape you'd otherwise need dozens of individual assertions to pin down, which makes it very cheap to add broad coverage over something like rendered markup. The trade-off is just as real, and it's the whole reason snapshot tests deserve a more skeptical eye than ordinary assertions: when a snapshot test fails, Jest offers a one-command fix — jest --updateSnapshot — and that command doesn't ask "was this change correct," it just accepts whatever the code currently produces as the new expected answer. A developer in a hurry, staring at a wall of failing snapshots after an unrelated change, can and often does blindly re-approve all of them without actually reading the diff — which turns the snapshot from a regression check into a rubber stamp that happens to still say "passing."
Snapshots earn their keep for large, low-churn structural output where a human genuinely will review the diff on change — a stable design system component, a generated config file. They're a poor fit for anything that changes often or that a reviewer is likely to rubber-stamp; a focused toEqual assertion on the two or three fields that actually matter usually catches the same regressions with far less noise and far less temptation to blindly re-approve.