Testing Philosophy
Tests are insurance. Theyβre worth what they cost β no more. Iβm not chasing 100% coverage; Iβm trying to ship features I can change next month without breaking.
The shape: more integration, fewer unit
I lean toward the testing trophy (Kent C. Dodds), not the pyramid:
π
βββββββββββ
β E2E β β a handful β golden paths only
βββββββββββ€
β Int. β β the bulk β features behaving end-to-end
βββββββββββ€
β Unit β β only for genuinely complex pure functions
βββββββββββ€
β Static β β TypeScript + ESLint, free coverage
βββββββββββ
A test that mounts a real component, lets it hit a mock-server route, and asserts the rendered output catches way more real bugs per line than ten unit tests that each pin one implementation detail.
Test behavior, not implementation
The single rule that decides whether a test ages well:
If I refactor the internals without changing the user-visible behavior, the test should still pass.
// Bad β pinned to implementation. Renaming setUser breaks the test.
expect(authStore.getState().setUser).toHaveBeenCalledWith({ id: "1" });
// Good β pinned to behavior. The user is now logged in.
expect(screen.getByText(/welcome, ahmad/i)).toBeInTheDocument();
I write tests from the perspective of someone using the feature, not someone reading the code.
What I mock and what I donβt
| Thing | Default |
|---|---|
| The component under test | Never mock |
| Child components | Almost never β let them render |
| Network requests | Mock with MSW (a real HTTP layer, intercepted) |
| Database (Node tests) | Real test DB, reset between tests |
| Time, randomness | Mock β vi.useFakeTimers(), fixed seed |
| Third-party SDKs (Stripe, OpenAI) | Mock at the SDK boundary |
I avoid mocking the database in integration tests. A test that passes against a mock and fails against the real schema is worse than no test.
React tests use Testing Library, not Enzyme
@testing-library/react (web) and @testing-library/react-native (mobile). Queries in priority order:
getByRoleβ accessible roles (most resilient)getByLabelTextβ for form inputsgetByTextβ for buttons, links, copygetByTestIdβ last resort, when nothing else works
If I have to reach for getByTestId, thatβs usually a hint the component isnβt accessible β and the right fix is often adding the missing aria-label, not the test id.
A test file looks like this
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("logs the user in with valid credentials", async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "a@b.com");
await user.type(screen.getByLabelText(/password/i), "hunter2");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(await screen.findByText(/welcome back/i)).toBeInTheDocument();
});
it("shows an error for an invalid password", async () => {
// ...
});
});
One describe per component, one it per behavior. No nested describes unless there are genuinely different contexts (e.g., logged-in vs logged-out).
E2E: Playwright for web, Maestro for mobile
I keep the E2E suite small and ruthless β only the user journeys that, if they broke in prod, would actually hurt:
- Sign up β log in β log out
- The top one or two βmoney pathsβ (checkout, send message, upload file)
- Anything regulated (payments, account deletion)
Everything else is integration. E2E is slow, flaky-prone, and expensive to maintain β I pay that cost only for the parts that need it.
When I skip tests
I donβt write tests for:
- Prototypes Iβm about to throw away (and Iβm honest with myself about whether itβs really a prototype)
- Tiny one-off scripts that will be run once
- Generated code (Prisma client, OpenAPI stubs)
- Trivial getters/setters and pure passthroughs
Everything else gets at least one test that exercises its happy path. If I canβt think of a single behavior worth asserting, the code probably shouldnβt exist either.