Testing React Components with RTL

You've now written the two layers you'll reach for most: fast unit tests for pure functions, and component tests for UI behaviour. There's a third layer worth knowing about, even though you won't write one this week: the end-to-end (E2E) test.
An E2E test drives a real browser through a real, running version of your app. It clicks the actual buttons, types into the actual form, and asserts on what the actual page shows, with nothing mocked. Where a component test renders ContactForm in isolation, an E2E test loads your deployed portfolio, fills in the contact form, submits it, and checks the success message appears β exercising your routing, your real network calls, and your components all together.
The industry-standard tool for this in 2026 is Playwright (Cypress is the other one you'll see). You write the test in TypeScript, and it can replay the same flow across Chrome, Firefox, and Safari.
These three layers form the testing pyramid: many fast unit tests at the base, fewer component tests in the middle, and a small number of slow-but-realistic E2E tests at the top. E2E tests give the most confidence β they prove the whole thing works together β but they're also the slowest to run and the most brittle, so you write few of them and reserve them for your most important user journeys.
π‘ Why the pyramid shape? A failing unit test tells you exactly which function is wrong. A failing E2E test tells you something in a long chain is wrong, and you still have to go find it. Lean on the fast, precise layers for the bulk of your coverage, and use a handful of E2E tests to confirm the critical paths hold together end to end.
You've already been writing accessible tests without fully realising it. Every time you reached for getByRole or getByLabelText, you asserted that an element is reachable the same way a screen reader or keyboard user would reach it. That's the first layer of accessibility testing, and it's almost free; it falls straight out of querying the way RTL encourages.
But accessible queries only check the elements you happen to query. They won't catch a button with no accessible name three components over, an image missing its alt text, or body text with too little colour contrast. For that broader sweep you bring in an automated accessibility checker: axe.
axe-core is the engine behind most accessibility tooling, including the axe DevTools browser extension and parts of Lighthouse. It scans rendered DOM against a large ruleset; missing form labels, invalid ARIA, duplicate IDs, insufficient contrast, broken heading structure; and reports every violation it finds.
β οΈ Automated checks catch roughly 30β50% of accessibility issues. axe is excellent at the mechanical, rule-based problems, but it cannot tell you whether your tab order makes sense, whether focus moves somewhere sensible after an action, or whether an error is actually announced to a screen reader. Treat axe as a fast first pass, never a certificate of accessibility; keyboard testing and a real screen reader still matter.
For a Vitest project, install vitest-axe:
npm install --save-dev vitest-axe
Register its matcher in your src/test/setup.ts, right alongside jest-dom:
import "@testing-library/jest-dom";
import * as axeMatchers from "vitest-axe/matchers";
import { expect } from "vitest";
expect.extend(axeMatchers);
This adds a single new matcher, toHaveNoViolations(), to every test file.
You render the component, run axe against its container, and assert there are no violations:
// src/components/ContactForm.a11y.test.tsx
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";
import { ContactForm } from "./ContactForm";
it("has no accessibility violations", async () => {
const { container } = render(<ContactForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
If that form had an input with no associated <label>, this test would fail with a message naming the exact element and the rule it broke. Fixing the test means fixing the accessibility problem; precisely the incentive you want.
π‘ You don't need an axe test for every component. Add them where structure and interaction are real: forms, navigation, modals, cards with links. A handful of well-placed accessibility tests catches the regressions that actually reach users.
π‘ Key takeaway: Accessible queries and axe reinforce each other. If your component is easy to find by role and label, it already satisfies many of axe's rules; accessibility is a property of well-structured components, not a chore you bolt on at the end.
So far you've written tests after the code: the function exists, then you test it. Test-Driven Development (TDD) flips that order. You write the test first, watch it fail, then write just enough code to make it pass. It feels backwards until you try it, and then it quietly changes how you work.
TDD is a loop of three short steps:
Then you go around again, one small behaviour at a time. Red β Green β Refactor, repeat.
π‘ Why write the test first? It forces you to decide what "done" looks like before you disappear into implementation details. It guarantees the code is testable, because you literally cannot write untestable code this way. And you never end up with code that has no test, because the test came first.
Say your portfolio shows contributor avatars, and when there's no image you want to fall back to the person's initials. Let's build getInitials the TDD way.
Red. Write the failing test before any implementation:
// src/utils/getInitials.test.ts
import { describe, it, expect } from "vitest";
import { getInitials } from "./getInitials";
describe("getInitials", () => {
it("returns the first letter of each name, uppercased", () => {
expect(getInitials("Ada Lovelace")).toBe("AL");
});
});
Run it. It fails; getInitials doesn't exist yet. Seeing red first is the whole point: it proves the test is wired up and genuinely checks something.
Green. Write the simplest thing that passes:
// src/utils/getInitials.ts
export function getInitials(fullName: string): string {
return fullName
.split(" ")
.map((part) => part[0].toUpperCase())
.join("");
}
Run again; green.
Refactor and extend. Now drive out the edge cases the same way; each one starts as a fresh failing test:
it("handles a single name", () => {
expect(getInitials("Cher")).toBe("C");
});
it("ignores extra whitespace", () => {
expect(getInitials(" Grace Hopper ")).toBe("GH");
});
The whitespace test goes red, because " Grace Hopper ".split(" ") produces empty strings that blow up on part[0]. Now you have a concrete reason to improve the implementation, and a test that proves the fix works:
export function getInitials(fullName: string): string {
return fullName
.trim()
.split(/\s+/)
.map((part) => part[0].toUpperCase())
.join("");
}
Green again. Every loop added exactly one behaviour, and you never wrote a line of implementation that wasn't demanded by a failing test.
β οΈ The discipline is the point. It is tempting to skip "red" and just write the code. But if you never watch the test fail, you don't actually know it can fail; a test that passes against missing or broken code is worse than no test at all. Always go red first.
The week's pair exercise is pure TDD: one partner writes a failing test for the contact form validator, the other writes just enough code to make it pass, then you swap roles. Resist the urge to write the implementation first. The entire point is to feel how writing the test first shapes the code you end up with.
Tests prove your code does what you said. Code review is where another human checks whether you said the right thing; whether the approach is sound, the names are clear, the edge cases are handled, and yes, whether it's tested. On a professional team, almost no code reaches production without it.
The vehicle for this is the pull request (PR): you push your branch, open a PR describing what changed and why, and teammates review it before it merges. You've opened PRs before; this week the focus is on the quality of the conversation inside them.
A good review comment is specific, kind, and actionable. You are reviewing the code, never the person.
blocking: this needs to change before mergesuggestion: I'd prefer this, but it's your callnit: tiny or stylistic, totally optionalquestion: I'm trying to understand, not criticisingif (!items.length) return []" is better still.user is null here?" invites a fix without putting anyone on the defensive.Compare:
β "This is messy. Why didn't you handle the error?"
β
"question: what should happen if fetchProjects() rejects here?
Right now the loading state would hang forever. Maybe a catch
that sets an error message? Happy to pair on it."
for loop because we mutate two arrays here and reduce got harder to read; open to it if you feel strongly." A PR is a conversation, not a verdict.π‘ Tests make reviews better. When your PR includes tests, the reviewer can see the behaviour you intended and trust that it works. A reviewer's most powerful question is often simply: "Is there a test for that?"
When you review a classmate's PR this week, look for:
β οΈ "LGTM" without reading isn't a review. Rubber-stamping a PR helps no one and quietly erodes trust in the whole process. If you approve it, you're vouching for it.
Tests run before you ship. They catch the bugs you thought to check for, in an environment you control. But production is messy: real users on browsers you've never tested, flaky networks, data shapes you didn't anticipate. Something will slip through eventually. The only question is whether you hear about it from your tools, or from an annoyed message weeks later.
Error tracking (also called error monitoring) closes that gap. A tool like Sentry sits inside your deployed app, captures unhandled errors as they happen to real users, and sends you the full picture: the stack trace, the browser and operating system, the sequence of actions that led there (breadcrumbs), and how many users were hit.
The setup is small. You install the SDK and initialise it once, near your app's entry point:
npm install @sentry/react
// src/main.tsx
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN, // comes from your Sentry project
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1.0,
});
π‘
tracesSampleRate: 1.0captures 100% of transactions. That's fine for a low-traffic portfolio, but production apps usually lower it (say0.1, for 10%) so performance monitoring stays affordable and un-noisy once real traffic arrives.
From then on, an error that would otherwise vanish into a user's console gets reported to your Sentry dashboard. You can also wrap part of your UI in Sentry's error boundary, so a crash shows a friendly fallback instead of a blank screen while still being reported:
<Sentry.ErrorBoundary fallback={<p>Something went wrong. We're on it.</p>}>
<App />
</Sentry.ErrorBoundary>
π‘ Tests and monitoring are two halves of quality. Tests prevent the regressions you can imagine; monitoring catches the failures you couldn't. Mature teams invest in both: a green test suite before merge, and eyes on production after deploy.
You don't need to wire up Sentry for your portfolio to pass this week. But knowing this category of tool exists, and why every serious product runs something like it, is part of thinking about quality the way a professional does.