This document provides guidelines for writing and running tests in the FructoSahel Next.js application.
- Test Framework: Vitest - A blazing fast unit test framework powered by Vite
- Testing Library: @testing-library/react - For testing React components
- DOM Testing: @testing-library/jest-dom - Custom matchers for DOM assertions
- User Interactions: @testing-library/user-event - Simulating user interactions
- Environment: jsdom - DOM implementation for Node.js
# Run tests in watch mode
bun test
# Run tests with UI
bun test:ui
# Run tests with coverage report
bun test:coverage
# Run tests once (CI mode)
bun run vitest runtests/
├── setup.ts # Global test setup and mocks
├── components/ # Component tests
│ └── button.test.tsx
├── hooks/ # Custom hook tests
│ └── use-farms.test.ts
├── utils/ # Utility function tests
│ └── format.test.ts
└── README.md # This file
When testing components, follow these best practices:
- Test user-facing behavior, not implementation details
- Use accessible queries (getByRole, getByLabelText, etc.)
- Test user interactions with userEvent
- Test different variants and props
Example:
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from '@/components/ui/button'
describe('Button Component', () => {
it('renders button with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
it('handles onClick event', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})Test custom hooks using renderHook from @testing-library/react:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useFarms } from '@/lib/hooks/use-farms'
describe('useFarms', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('fetches farms successfully', async () => {
const mockData = [{ id: '1', name: 'Test Farm' }]
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockData,
})
const { result } = renderHook(() => useFarms())
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual(mockData)
})
})Test pure functions with straightforward input/output assertions:
import { describe, it, expect } from 'vitest'
import { formatCurrency } from '@/lib/utils/format'
describe('formatCurrency', () => {
it('formats currency in West African CFA Franc', () => {
const result = formatCurrency(150000)
expect(result).toContain('150')
expect(result).toContain('000')
})
})Structure your tests using the AAA pattern:
it('should do something', () => {
// Arrange - Set up test data and conditions
const initialValue = 5
// Act - Execute the code being tested
const result = myFunction(initialValue)
// Assert - Verify the result
expect(result).toBe(10)
})Test names should clearly describe what is being tested:
// Good
it('formats currency in West African CFA Franc', () => {})
it('displays error message when API request fails', () => {})
// Bad
it('works', () => {})
it('test 1', () => {})Always mock external dependencies like API calls, navigation, and third-party libraries:
// Already mocked in tests/setup.ts
// - next/navigation
// - next-intl
// Mock fetch for API calls
beforeEach(() => {
global.fetch = vi.fn()
})Use afterEach to clean up mocks and restore original implementations:
afterEach(() => {
vi.restoreAllMocks()
})When testing async code, use waitFor to wait for state updates:
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})Don't just test the happy path. Test edge cases:
- Empty states
- Error states
- Loading states
- Boundary values
- Null/undefined inputs
Each test should be independent and not rely on the state from other tests:
// Good - Each test is isolated
describe('Counter', () => {
it('increments count', () => {
const counter = new Counter()
counter.increment()
expect(counter.value).toBe(1)
})
it('decrements count', () => {
const counter = new Counter()
counter.decrement()
expect(counter.value).toBe(-1)
})
})Global mocks are configured in tests/setup.ts:
next/navigation- Router, pathname, search paramsnext-intl- Translations and locale
For API tests, mock fetch in individual test files:
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock data' }),
})
})Use vi.mock() to mock entire modules:
vi.mock('@/lib/api/client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
},
}))Aim for:
- 80%+ overall coverage
- 100% coverage for critical business logic
- Focus on meaningful tests, not just coverage metrics
View coverage report:
bun test:coverageCoverage reports are generated in:
- Text format (console)
- JSON format (
coverage/coverage-final.json) - HTML format (
coverage/index.html)
Tests should:
- Run automatically on every commit
- Pass before merging PRs
- Generate coverage reports
- Fail the build if coverage drops below threshold
If you see errors about missing DOM APIs, ensure jsdom is configured in vitest.config.ts:
export default defineConfig({
test: {
environment: 'jsdom',
},
})Make sure mocks are defined before importing the tested module:
// Good
vi.mock('@/lib/api')
import { useApi } from '@/lib/api'
// Bad - mock won't work
import { useApi } from '@/lib/api'
vi.mock('@/lib/api')Increase the timeout for specific tests:
it('long running test', async () => {
// test code
}, 10000) // 10 second timeoutWhen adding new features:
- Write tests first (TDD approach recommended)
- Ensure all tests pass
- Maintain or improve code coverage
- Follow existing test patterns and conventions
- Document complex test scenarios
Happy testing!