Testing Fundamentals

The bar: you can prove a Next.js feature works before anyone else touches it — unit, component, and end-to-end — and wire those tests into CI so nothing merges broken.

Untested code is broken code you haven't caught yet. When you ship to a platform serving thousands of veterans, "it worked on my machine" is not a status report. Tests are how you move fast without lying to yourself about whether the thing still works.

Testing is not overhead you add at the end. It's the tool that lets you refactor, upgrade a dependency, or hand code to a teammate without holding your breath. This module takes you from writing your first assertion to running a full suite in continuous integration.

11.1 Testing Philosophy

Objective: understand why you test and what is actually worth testing.

The testing pyramid tells you where to spend effort. Many fast unit tests at the base, fewer integration tests in the middle, a handful of slow E2E tests at the top. Invert that pyramid — all E2E, no units — and your suite gets slow, flaky, and expensive to keep green.

Test-driven development (TDD) flips the order: write the failing test first, then the code that makes it pass. It forces you to define "done" before you start. You won't do it for everything, but for a tricky function or a bug fix it's the cleanest way to work.

The trap for self-taught engineers is worshipping code coverage. A hundred percent coverage means every line ran during tests, not that the behavior is correct. Chasing the number leads to tests that assert nothing. Spend your budget on testing economics — test the logic that breaks in painful ways, skip the trivial getters. And treat flaky tests (pass sometimes, fail sometimes) as bugs: usually a timing race or shared state leaking between tests. A flaky suite is worse than no suite because people stop trusting it.

Drill:

  • Sketch the testing pyramid and label where unit, integration, and E2E tests live.
  • Do one feature TDD-style: red, green, refactor.
  • Read a coverage report and name two lines that are covered but untested in behavior.
  • Identify one flaky test cause: shared state, timing, or test-order dependence.

11.2 Unit Testing with Jest

Objective: write effective unit tests for JavaScript and TypeScript.

Jest is the runner. It finds your test files, executes them, and reports pass/fail. Once configured, you write tests with describe to group, it (or test) to name a case, and expect to assert. That structure reads like a sentence: describe the thing, state what it should do, expect a result.

Matchers are the verbs of expect. Use toBe for primitives (strict equality), toEqual for deep object/array comparison, and toThrow for functions that should error. Picking toBe on an object is a classic early mistake — it compares references, not contents, so two identical objects fail.

Real code has setup. beforeEach and afterEach run before and after every test to build fixtures and clean them up, so tests don't bleed into each other. When a function depends on something you don't want to actually run — a network call, a clock, a module — mock it. jest.fn() makes a fake function you can inspect; jest.mock() replaces a whole module. For async code, await the promise inside your test and assert on the result, or your test will pass before the work finishes.

Drill:

  • Configure Jest and run a single file with jest path/to/file.
  • Write a describe/it/expect block for a pure function.
  • Use toBe, toEqual, and toThrow each at least once.
  • Reset state between tests with beforeEach / afterEach.
  • Fake a dependency with jest.fn() and a module with jest.mock().
  • Test an async function by awaiting its resolved value.

11.3 React Component Testing

Objective: test React components the way a user actually uses them.

React Testing Library (RTL) has one core idea: test behavior, not implementation. Don't reach into component internals or state — render the component and interact with it the way a user would. That way your tests survive refactors and only break when the user-visible behavior breaks.

You render a component into a virtual DOM, then query for elements. The three query families matter: getBy* throws if the element is missing (use when it should be there now), queryBy* returns null instead of throwing (use to assert absence), and findBy* returns a promise (use for elements that appear after an async update). Mixing these up causes half of all confusing RTL failures.

Drive user interactions with click, type, and select from @testing-library/user-event, then assert on what the user would see. You can test custom hooks in isolation with renderHook. Snapshot testing captures rendered output and flags any change — useful for stable markup, but a liability when overused, because a giant auto-updated snapshot asserts nothing and everyone just blesses the diff.

Drill:

  • render a component and query it with getByRole / getByText.
  • Choose correctly between getBy, queryBy, and findBy.
  • Simulate click, type, and select with user-event.
  • Test a custom hook with renderHook.
  • Add one snapshot test and justify why that component deserves one.

11.4 Integration Testing

Objective: test how pieces work together — components, data, and auth.

Unit tests prove functions work alone. Integration tests prove they work together: a form that hits an API route, a handler that reads the database, a flow that depends on who's logged in. This is where real bugs hide, because the seams between parts are where assumptions collide.

The curriculum teaches App Router Server Actions and route handlers. Learn them — they're a valuable modern skill. Just know that when you bring this back to the VWC app, the app currently runs the Pages Router, so the thing you're integration-testing is a handler in src/pages/api/ and server data comes from getServerSideProps / getStaticProps, not Server Components or Server Actions. The testing shape is the same: call the handler, assert on the response and side effects.

Database testing needs discipline. Use a test database, not production, and reset state between runs. Fixtures and factories give you consistent, readable test data instead of hand-built objects scattered everywhere. For authenticated flows, seed a session or mock the auth guard so you can test both the allowed and forbidden paths.

Drill:

  • Test an API handler in src/pages/api/ by invoking it and asserting the response.
  • Translate an App Router route handler mentally to its Pages Router equivalent.
  • Reset the database between tests against a dedicated test DB.
  • Build a factory that produces a valid record with sane defaults.
  • Test one authenticated flow: allowed for a role, blocked without it.

11.5 End-to-End Testing with Playwright

Objective: write E2E tests that catch real regressions without going flaky.

Playwright drives a real browser through a real app — the closest thing to a user clicking around. These tests are your last line of defense before production, and also your slowest and most fragile, so you keep them few and focused on critical paths.

Structure them with the Page Object Model: wrap each page's selectors and actions in a class, so when the UI changes you fix one file instead of fifty tests. Prefer locators built on roles and accessible text over brittle CSS or XPath — they survive redesigns and double as an accessibility check. Playwright's assertions auto-wait for the condition, which kills a whole category of timing flakiness you'd hit with manual sleeps.

Beyond clicks, Playwright does visual regression testing (screenshot diffs that catch unintended layout changes) and runs tests in parallel across workers to keep the suite fast. The payoff lands in CI/CD: wire the suite into your pipeline so every pull request runs it, and broken user flows get blocked before merge instead of discovered by a veteran mid-signup.

Drill:

  • Set up Playwright and run npx playwright test.
  • Write a Page Object for one screen and use it in a test.
  • Prefer getByRole / getByText locators over raw CSS selectors.
  • Use auto-waiting assertions like expect(locator).toBeVisible().
  • Add one visual regression snapshot and run the suite in parallel.
  • Run the E2E suite in CI on every pull request.

11.6 Hands-On Lab: Test Suite

Objective: build a comprehensive test suite for a Next.js feature, unit through E2E, running in CI.

What you ship

A layered test suite for one real feature:

  • Unit tests with Jest for the feature's utility functions, covering the tricky branches and edge cases.
  • Component tests with React Testing Library that render the UI and drive it through real user interactions.
  • End-to-end tests with Playwright that walk the full user flow in a browser.
  • A CI job that runs all three on every pull request, so nothing merges red.

On the VWC codebase

The VWC app already tests exactly this way: Jest plus React Testing Library for units and components, Playwright for end-to-end. Remember the app runs the Pages Router, so your integration tests target handlers in src/pages/api/ and data loaders like getServerSideProps — not App Router route handlers or Server Actions. Use the shared Prisma singleton in tests too; never new PrismaClient() per file.