How to write a test
A goal-oriented guide for writing tests in Unwrap covering the most common patterns.
Decide which kind of test you need
| If you're testing… | Use |
|---|---|
| A pure function (formatter, util, store action) | A unit test in src/__tests__/utils/ or src/__tests__/stores/ |
| A component that fetches and renders data | An integration test with MSW |
| A user flow across multiple pages in a real browser | An E2E test with Playwright |
This guide covers the first two. E2E lives separately under src/__tests__/e2e/.
Where the test goes
Tests mirror the source structure under src/__tests__/. A component at src/components/AlbumList/AlbumList.tsx has its test at src/__tests__/components/AlbumList/AlbumList.test.tsx.
Pattern: Unit test
For a pure function:
// src/__tests__/utils/formatters.test.ts
import { formatMinutes } from '@/utils/formatters';
describe('formatMinutes', () => {
it('rounds to the nearest minute', () => {
expect(formatMinutes(90000)).toBe('2');
});
it('returns "0" for zero milliseconds', () => {
expect(formatMinutes(0)).toBe('0');
});
});No mocks, no setup. Pure input → pure output.
Pattern: Integration test with MSW
For a component that calls a hook that calls a service:
import { renderWithProviders } from '@/test/renderWithProviders';
import { screen } from '@testing-library/react';
import AlbumList from '@/components/AlbumList/AlbumList';
describe('AlbumList', () => {
it('renders the data from the default handler', async () => {
renderWithProviders(<AlbumList timeRange="all" />);
expect(await screen.findByText('To Pimp a Butterfly')).toBeInTheDocument();
});
});renderWithProviders wraps the component in a fresh QueryClient and MemoryRouter. The default MSW handlers in src/mocks/handlers.ts answer the network calls.
Pattern: Override a handler for one test
To test an error or a specific edge case, override the handler with server.use():
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
it('shows an error when the request fails', async () => {
server.use(
http.get('/api/top-albums', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
)
);
renderWithProviders(<AlbumList timeRange="all" />);
expect(await screen.findByText(/couldn't load top albums/i)).toBeInTheDocument();
});The override only applies to the current test as setup.ts resets handlers between tests automatically.
Pattern: Testing a Zustand store
Store actions are pure, so they're testable directly:
import { useDashboardStore } from '@/stores/dashboardStore';
beforeEach(() => {
useDashboardStore.getState().resetToDefaults();
});
it('toggles a category off', () => {
useDashboardStore.getState().toggleCategory('top-tracks');
expect(useDashboardStore.getState().categories).not.toContain('top-tracks');
});Worth knowing
userEvent.upload() respects the accept attribute
If your component has <input accept=".zip">, calling userEvent.upload() with a non-zip file silently does nothing, the upload is blocked at the browser layer. To test JS-side validation logic, use fireEvent.change() instead, which dispatches the raw DOM event and bypasses the HTML filter.
Don't create your own setupServer
The global MSW server is created in src/test/server.ts and started in src/test/setup.ts. Creating a second one in your test file will fight the global. Always import the shared server instance.
Run your test
npm run test:run -- YourComponentThe -- passes the filter to Vitest. Use npm run test (no :run) to keep it watching.
Done
You've written a test that runs in CI on every PR.