testing that actually pays off (typescript + react) david abram testing that actually pays off contracts > code paths https://github.com/davidabram/testing-typescript-react 2 / 21 expectations • we won't talk about coverage numbers • we won't talk about classical "unit tests" • we don't want to test everything • we will focus on high-leverage tests only
▍ Note
▍
▍ when people say "unit tests", they often mean isolated tests with lots of mocks.
▍ that's not what this workshop is about.
3 / 21 unit, integration, e2e??? "integration test" isn't really a well-defined category the boundary is blurry: what counts as "integrated"? i prefer test sizes instead:
• small • medium • large https://testing.googleblog.com/2010/12/test-sizes.html Feature │ Small │ Medium │ Large ─────────────────────┼───────┼────────────────┼────── Network access │ No │ localhost only │ Yes
Database │ No │ Yes │ Yes
File system access │ No │ Yes │ Yes
Use external systems │ No │ Discouraged │ Yes
Multiple threads │ No │ Yes │ Yes
Sleep statements │ No │ Yes │ Yes
System properties │ No │ Yes │ Yes
Time limit (seconds) │ 60 │ 300 │ 900+ 4 / 21 the mental model tests protect contracts, not code paths a contract is: • a rule • a guarantee • something users (or other code) depend on good tests reduce thinking during refactors. 5 / 21 resetting instincts ▒▒▒▒ common instincts ▒▒▒▒ better instincts
• ❌ "I should test every function" • ✅ test rules and invariants
• ❌ "if it's external, I must mock it" • ✅ prefer real integrations over mocks
• ❌ "manual testing will catch ui bugs for me" • ✅ lock down important outputs, not everything
6 / 21 highest roi test: tRPC procedures why start here? • pure typescript • no browser • no http • no flaky async ui issues ▍ Tip
▍
▍ if a tRPC procedure is wrong, prod is wrong.
7 / 21 what you're testing tRPC procedures = backend functions + rules rules include: • input validation (zod) • auth • database logic cheap integration tests at the best boundary. 8 / 21 the key technique use createCallerFactory()
call procedures directly in tests
// src/server/router.ts
export const createCaller = t.createCallerFactory(appRouter);
9 / 21 example: direct procedure testing // tests/trpc.caller.test.ts
beforeEach(async () => {
await setupTestDatabase({ userCount: 3 });
});
afterAll(async () => {
await teardownTestDatabase();
});
it("enforces auth + persists data", async () => {
const anon = createCaller(createUnauthenticatedContext());
await expect(
anon.user.create({ email: "test@example.com", name: "Test User" }),
).rejects.toThrow("Authentication required");
const authed = createCaller(createAuthenticatedContext(1));
const newUser = await authed.user.create({
email: "test@example.com",
name: "Test User",
});
expect(newUser.createdAt).toBeInstanceOf(Date);
const fetched = await authed.user.byId({ id: newUser.id });
expect(fetched).toEqual(newUser);
});
10 / 21 what this proves • unauthorized access fails • authorized access works • validation actually runs (zod) • returned data shape is correct • Date survives serialization (superjson)
expect(result.users[0]?.createdAt).toBeInstanceOf(Date);
11 / 21 thinking in rules ask this: ▍ what should always be true, no matter the input?
that sentence defines a property
example tests prove it works once. property tests try to break it. 12 / 21 property test: pagination invariants you actually care about: • pagination never duplicates items • pagination never skips items // tests/pbt.pagination.test.ts
fc.assert(
fc.property(fc.integer({ min: 1, max: 50 }), fc.integer({ min: 1, max: 10 }),
(itemCount, pageSize) => {
const items = createItems(itemCount);
const allPages: Array<{ id: number; value: number }> = [];
let cursor: number | string | undefined = undefined;
for (;;) {
const page = paginateCursor(items, pageSize, cursor);
allPages.push(...page.items);
if (page.nextCursor === undefined) break;
cursor = page.nextCursor;
}
expect(allPages).toEqual(items);
},
),
);
13 / 21 property test: formatting humans are bad at edge cases computers are great at generating them // tests/pbt.format.test.ts
fc.assert(
fc.property(fc.double({ noNaN: true }), (value) => {
const result = formatCurrency(value);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
}),
);
▍ Note
▍
▍ start with pure functions only
▍ (no ui, no state machines yet)
14 / 21 golden tests (snapshots you don't hate) a snapshot is: • a reviewed output • intentionally locked • changed only with awareness ▍ Warning
▍
▍ if the diff is noisy, the snapshot is wrong.
15 / 21 example: react output snapshot // tests/ui.golden.test.tsx (example)
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import Button from "../src/components/Button";
describe("UI Golden Tests", () => {
it("locks down the button contract", () => {
render(<Button variant="primary">Pay now</Button>);
const button = screen.getByRole("button", { name: "Pay now" });
expect({
type: button.getAttribute("type"),
ariaDisabled: button.getAttribute("aria-disabled"),
className: button.className,
}).toMatchSnapshot();
});
});
snapshots protect shape, not behavior
16 / 21 ui tests beginners should write only two things: 1. does not crash 2. is accessible and honest we don't test pixels. we test promises. 17 / 21 example: crash + accessibility // tests/ui.a11y.test.tsx
fc.assert(
fc.property(fc.string(), (children) => {
const { container } = render(<Button>{children}</Button>);
expect(container.querySelector("button")).toBeTruthy();
}),
);
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole("button")).toHaveAttribute("aria-disabled", "true");
18 / 21 cache & state behavior cache bugs feel like lies: • "i did the mutation... why didn't the ui change?" the contract is usually: after mutation, refresh + reset local state
// src/routes/demo/trpc-todo.tsx
const { data, refetch } = useQuery(trpc.todos.list.queryOptions())
const { mutate: addTodo } = useMutation({
...trpc.todos.add.mutationOptions(),
onSuccess: () => {
refetch()
setTodo('')
},
})
▍ Tip
▍
▍ don't test tanstack query. test your invalidation policy (one ui test for critical flows).
19 / 21 testing priority ladder top → bottom priority 1. tRPC procedures 2. data invariants (property tests) 3. golden outputs 4. cache & state behavior (invalidation) 5. ui crash + accessibility if you only do the top two, you're already ahead of most teams. 20 / 21 closing ▍ tests are not about proving code is correct.
▍ they're about making changes safe.
• copy one pattern, not all of them • standardize on a small set of testing styles 21 / 21