A test is a small program that checks that your code still works. When you change something next month, tests tell you at once if you broke something else. Many beginners skip tests because they feel like extra work. In this lesson you will see that a few good tests are quick to write, and that they make changing code much less scary.
What should you test?
Do not try to test everything. Test what matters to the user, and what is easy to break:
- Plain functions with rules, such as validation or price calculations. These are the easiest and most valuable tests.
- Components the way a person uses them: the person sees text, clicks buttons and types in fields.
- Important flows: sign in, add to cart, submit a form, an error message after a failed request.
A good guide from the testing library's own creators: test what the user sees and does, not how your code is built inside. If you test internal details like state names, your tests break when you tidy your code, even though the app still works. Those are bad tests.
The tools
- Vitest is the test runner. It works with Vite, so it needs almost no setup, and it is very fast. (Jest is an older, similar tool you may see in other projects.)
- React Testing Library draws your component in a fake browser and gives you ways to find things the way a user would: by text, by label and by role.
- jest-dom adds helpful checks like “is this in the page?”.
- user-event pretends to be a real person clicking and typing.
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
Then add this to vite.config.js, and a setup file:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.js',
},
});
// src/test/setup.js
import '@testing-library/jest-dom/vitest';
// package.json scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}
npm test starts Vitest in watch mode: it reruns the tests you changed as you save. npm run test:run runs once, which is what you use in a build.
Your first test: a plain function
// price.js
export function totalPrice(items) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// price.test.js
import { describe, it, expect } from 'vitest';
import { totalPrice } from './price';
describe('totalPrice', () => {
it('adds up price times quantity', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 1 },
];
expect(totalPrice(items)).toBe(25);
});
it('returns 0 for an empty cart', () => {
expect(totalPrice([])).toBe(0);
});
});
describegroups tests.it(ortest) is one test with a clear sentence about what should happen.expect(value).toBe(expected)is an assertion. If it is not true, the test fails and shows you the difference.
Testing a component
// Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Add one</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
it('adds one when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Add one' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
The test follows what a person does: draw the component, see “Count: 0”, click the button named “Add one”, see “Count: 1”. It says nothing about useState. If you later rewrite the counter with useReducer, this test still passes, which is exactly the point.
Finding things: the query priority
Testing Library suggests finding elements the way people and assistive tools do. Use them in this order:
getByRole('button', { name: 'Save' }): the best. It also checks that your page is accessible.getByLabelText('Email'): for form fields.getByText('Welcome'): for text content.getByPlaceholderText,getByAltText,getByTitle: when the above do not fit.getByTestId('x'): last resort, using adata-testidattribute.
There are three families: `getBy` throws if the thing is missing, `queryBy` returns null (use it to check that something is not there), and `findBy` waits for it to appear (use it for async changes).
Testing a form
it('shows an error for a short password and submits when valid', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignUpForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'asha@example.com');
await user.type(screen.getByLabelText('Password'), 'short');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByRole('alert')).toHaveTextContent('at least 8 characters');
expect(onSubmit).not.toHaveBeenCalled();
await user.clear(screen.getByLabelText('Password'));
await user.type(screen.getByLabelText('Password'), 'longenough123');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
vi.fn() makes a mock function: a fake function that remembers how it was called. We pass it in as onSubmit and check that it was (or was not) called. Notice also that finding fields by label forces your form to have proper labels.
Testing async data
For components that load data, do not call the real server in a test. It would be slow and unreliable. Replace fetch with a fake:
import { vi } from 'vitest';
it('shows posts after loading', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, title: 'First post' }],
}));
render(<Posts />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('First post')).toBeInTheDocument();
});
it('shows an error when the request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<Posts />);
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load');
});
findByText waits (for up to a second by default) until the text appears. Testing both the happy path and the error path is important, because errors are where real apps break. For bigger projects, a tool called MSW (Mock Service Worker) fakes the network at a deeper level, so you do not need to stub fetch in every test.
Testing a custom hook
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
it('toggles the value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => result.current[1]());
expect(result.current[0]).toBe(true);
});
renderHook runs your hook inside a tiny test component. act makes sure React has finished updating before you check the result. Testing Library's own helpers like user.click already wrap act for you.
Testing with context and a router
A component that uses context or React Router needs the provider around it. Make a small helper:
import { MemoryRouter } from 'react-router-dom';
function renderWithProviders(ui, { route = '/' } = {}) {
return render(
<MemoryRouter initialEntries={[route]}>
<AuthProvider>{ui}</AuthProvider>
</MemoryRouter>
);
}
MemoryRouter is a router that keeps the address in memory, so no real browser is needed.
What makes a good test
- It has a clear name that reads like a sentence: “shows an error for a short password”.
- It tests one idea.
- It does not depend on other tests or on the order they run.
- It uses no real network, timers or dates when it can fake them.
- It would still pass if you rewrote the inside of the component but kept the behaviour.
Do not chase 100 percent coverage. A few tests on your most important paths are worth more than many tests on trivial things. When you fix a bug, write a test that fails because of the bug first. Then fix it. The bug can never silently return.
Beyond component tests
- End-to-end tests with Playwright or Cypress open a real browser and click through your whole app, including the server. They are slower, so write a few for the most important journeys, such as sign up and checkout.
- Continuous integration (CI) runs your tests automatically on every push, for example with GitHub Actions, so a broken change is caught before it goes live.
Common mistakes
- Testing internal state or function names instead of what the user sees.
- Using
getByTestIdfor everything, which skips accessibility checks. - Forgetting
awaitwithuser.clickandfindBy, so the test checks too early. - Calling real APIs in tests.
- Tests that depend on each other, or on the current date.
Your task
- Install Vitest and Testing Library, and write two tests for a
totalPricefunction. - Write a test for a counter: click, and check the new text.
- Write a test for your sign-up form: a short password shows an error, and a good one calls
onSubmit. - Write a test for a component that loads data, with a fake
fetch, for both success and failure.
How to check it worked
npm run test:runpasses and prints a list of your tests.- Changing the inside of a component (for example from
useStatetouseReducer) does not break its test. - You can break a feature on purpose and see a test fail.
Summary
- Test what users see and do. Start with plain functions, then the key components and flows.
- Use Vitest to run tests, React Testing Library to draw components, and user-event to click and type.
- Find elements by role, label and text. Use
findByfor async changes andqueryByto check something is absent. - Fake the network. Test both success and error paths. Run tests in CI.