Credence Frontend uses Vitest with React Testing Library and jsdom to unit-test components and hooks.
npm run test # single run (CI)
npm run test:watch # watch mode (development)
npm run coverage # single run with coverage reportAll test configuration lives in the test block of vite.config.ts:
test: {
environment: 'jsdom', // browser-like DOM
globals: true, // auto-import describe/it/expect/vi
setupFiles: ['./src/test/setup.ts'],
}src/test/setup.ts runs before every test file and extends Vitest's expect with jest-dom matchers (toBeInTheDocument, toHaveAttribute, etc.):
import '@testing-library/jest-dom'The @ alias resolves to src/, matching the app configuration. Use it freely in tests:
import AmountInput from '@/components/AmountInput'
import { useFocusTrap } from '@/hooks/useFocusTrap'Co-locate test files with their source files using the .test.tsx / .test.ts suffix:
| Source | Test file |
|---|---|
src/components/Foo.tsx |
src/components/Foo.test.tsx |
src/hooks/useBar.ts |
src/hooks/useBar.test.ts |
Use RTL's render directly. Wrap with MemoryRouter when the component uses React Router hooks or <Link>:
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
function renderWithRouter(ui: React.ReactElement, { path = '/' } = {}) {
return render(<MemoryRouter initialEntries={[path]}>{ui}</MemoryRouter>)
}Prefer userEvent over fireEvent — it simulates full browser event sequences (pointer down → up → click, focus handling, etc.):
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), 'CONFIRM')
await user.click(screen.getByRole('button', { name: 'Submit' }))
await user.keyboard('{Escape}')Use fireEvent only when you need to dispatch a specific low-level DOM event (e.g. keydown on a container element).
jsdom does not implement window.matchMedia. Mock it in a beforeAll at the top of any test file that renders components using media queries or the ThemeToggle:
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})Spy on Storage.prototype so tests stay isolated:
beforeEach(() => {
const store: Record<string, string> = {}
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => store[key] ?? null)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, val) => {
store[key] = String(val)
})
})
afterEach(() => vi.restoreAllMocks())beforeEach(() => {
const mockFn = vi.fn()
// Use Object.defineProperty so the component's handlePaste sees our mock
Object.defineProperty(navigator, 'clipboard', {
writable: true,
configurable: true,
value: { readText: mockFn },
})
})
afterEach(() => {
vi.restoreAllMocks()
// Ensure navigator.clipboard is restored
delete (navigator as any).clipboard
})
// **AddressInput-specific pattern**
// In AddressInput.test.tsx, extract the mock into a top-level variable
// for use in both paste-success and paste-fallback tests:
// let clipboardReadTextMock: ReturnType<typeof vi.fn>
// beforeEach(() => { clipboardReadTextMock = vi.fn(); ... })Hooks that use requestAnimationFrame for focus timing (e.g. useFocusTrap) should have it mocked to fire synchronously so tests don't need waitFor:
beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
})
afterEach(() => vi.restoreAllMocks())Run npm run coverage to generate a text summary and an lcov report.
Per-file thresholds are enforced in vite.config.ts. The current targets are:
| File | Lines | Branches |
|---|---|---|
src/components/AddressInput.tsx |
90% | 90% |
src/components/AmountInput.tsx |
80% | 80% |
src/components/ConfirmDialog.tsx |
— | 90% |
src/hooks/useFocusTrap.ts |
— | 85% |
A build that misses a threshold exits with a non-zero code, which fails CI.
- One
describeper component or hook — nest innerdescribeblocks for logical groups (rendering, callbacks, accessibility). - Plain-English
itsentences —it('disables the confirm button until CONFIRM is typed'). - Globals are auto-imported —
describe,it,expect,vi,beforeEach,afterEach,beforeAll,afterAllare available without importing, courtesy ofglobals: truein the config. Explicit imports are also fine and improve editor type-checking in edge cases. - Clean up after each test — restore mocks with
vi.restoreAllMocks()inafterEach; resetdocument.body.styleor other global state you mutate. - No
__tests__directories — co-locate tests with source for easier navigation.