Testing React Components with RTL
You've been building your portfolio for several weeks. It works, you've tested it manually, you've refreshed the browser dozens of times. So why would you need automated tests?
Here's a scenario: you refactor your filterProjects utility to support a new feature. You think nothing is broken. But three components silently depend on the exact output shape of that function, and now two of them render nothing. You find out when a reviewer points it out; or worse, when a recruiter opens your portfolio.
Tests are your safety net. They catch regressions before they reach production, give you confidence to refactor, and document how your code is supposed to behave.
<aside> ๐ก
Worth knowing: At most companies, writing tests is not optional. PRs without test coverage will not be merged. Starting to think in tests now is one of the most career-relevant habits you can build.
</aside>
Before writing a single line, let's establish the mental model you'll use all week.
Frontend applications have two distinct things worth testing, and they need different approaches.
A pure function takes inputs and returns outputs with no side effects. These are your utility functions, data transformers, validators, and formatters. For these, you test inputs and outputs thoroughly; every edge case, every unexpected input, every boundary.
// A pure function โ perfect candidate for unit tests
function filterProjectsByTech(projects: Project[], tech: string): Project[] {
return projects.filter(p => p.techStack.includes(tech))
}
Components render UI and respond to user interaction. Here, you test what the user sees and does, not how the component is wired internally. You don't care whether it uses useState or useReducer. You care that when a user submits an empty form, they see an error message.
// What we test: the behaviour the user experiences
// NOT: which hook is used, or how state is structured internally
test('shows error when form is submitted empty', async () => {
// We'll write this in Chapter 2
})
<aside> ๐ก
Rule of thumb: If it's a function with inputs and outputs, unit test it thoroughly. If it's a component, test the behaviour a user would notice.
</aside>
Vitest is a testing framework built specifically for Vite projects. It's fast, shares config with your existing Vite setup, and has a Jest-compatible API; most things you'll read about Jest apply here too.
In your portfolio project, run:
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
| Package | What it does |
|---|---|
vitest |
The test runner |
@testing-library/react |
Renders React components in tests |
@testing-library/jest-dom |
Custom matchers like toBeInTheDocument() |
@testing-library/user-event |
Simulates real user interactions |
jsdom |
Simulates a browser DOM in Node.js |
Open your vite.config.ts and add a test block:
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
})
The jsdom environment simulates a browser DOM in Node.js; this is what lets React Testing Library render your components during tests without a real browser.
Create src/test/setup.ts:
import '@testing-library/jest-dom'
This registers the custom matchers from jest-dom globally, so you can use toBeInTheDocument(), toBeVisible(), toHaveValue(), and others in every test file without importing them manually.
In your package.json:
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
<aside> ๐ก
vitest --ui opens a browser-based test dashboard. It's optional but genuinely useful once you have more than a handful of tests; you can see which passed, which failed, and re-run individual tests with a click.
</aside>
Create src/test/setup.test.ts:
test('Vitest is working', () => {
expect(1 + 1).toBe(2)
})
Run npm test. Green? You're ready.
Let's write a real test for a real function. Your portfolio has a utility that filters projects by technology; let's test it properly.
Create src/utils/filterProjects.ts:
export type Project = {
id: number
title: string
techStack: string[]
}
export function filterProjectsByTech(projects: Project[], tech: string): Project[] {
return projects.filter(p =>
p.techStack.map(t => t.toLowerCase()).includes(tech.toLowerCase())
)
}
Create src/utils/filterProjects.test.ts:
import { describe, it, expect } from 'vitest'
import { filterProjectsByTech, Project } from './filterProjects'
const projects: Project[] = [
{ id: 1, title: 'Portfolio', techStack: ['React', 'TypeScript'] },
{ id: 2, title: 'Weather App', techStack: ['React', 'TailwindCSS'] },
{ id: 3, title: 'Blog', techStack: ['Next.js', 'TypeScript'] },
]
describe('filterProjectsByTech', () => {
it('returns projects that include the given tech', () => {
const result = filterProjectsByTech(projects, 'React')
expect(result).toHaveLength(2)
expect(result[0].title).toBe('Portfolio')
})
it('is case-insensitive', () => {
const result = filterProjectsByTech(projects, 'typescript')
expect(result).toHaveLength(2)
})
it('returns an empty array when no projects match', () => {
const result = filterProjectsByTech(projects, 'Vue')
expect(result).toHaveLength(0)
})
it('returns an empty array when given an empty list', () => {
const result = filterProjectsByTech([], 'React')
expect(result).toHaveLength(0)
})
})
describe groups related tests; use it to organise around a single function or conceptit (or test) defines one test case; name it like a sentence: "it does X when Y"expect paired with a matcher (toBe, toHaveLength, toEqual) asserts what the result should be<aside> ๐ก
Naming tests well matters. When a test fails, the first thing you read is the test name. "returns an empty array when no projects match" tells you exactly what broke. "test 3" tells you nothing.
</aside>
Notice we tested four distinct cases:
This is what thorough means for unit tests. You're not trying to hit every possible string; you're thinking about the categories of input that could behave differently.
https://www.youtube.com/watch?v=XdDZKeM5_pQ&list=PL4cUxeGkcC9iyuClsf48SSgsJPBStHo7F
The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.