This guide documents the testing conventions, patterns, and best practices for the CommitLabs frontend. It covers running tests, mocking common dependencies (fetch, Freighter wallet API, timers), and using React Testing Library.
- Test Framework: Vitest
- Component Testing: React Testing Library + happy-dom
- Coverage Requirement: 95% (statements, branches, functions, lines)
- Test Environment: happy-dom (lightweight DOM alternative)
pnpm testWatch mode re-runs tests automatically when files change. Ideal for development.
pnpm test:watchpnpm test:coverageCoverage reports are generated in HTML format and opened in your browser. The project enforces a 95% threshold across:
- Statements
- Branches
- Functions
- Lines
Covered files are configured in vitest.config.ts and currently include the src/lib/backend/ directory.
Tests are organized by type and location:
tests/
βββ api/ # API route tests
β βββ helpers.ts # Shared test utilities
β βββ health.test.ts
β βββ [...other routes]
βββ components/ # Component tests
βββ lib/ # Library/utility tests
βββ setup/ # Test setup and configuration
β βββ vitest.setup.ts # Global setup
β βββ vitest.d.ts # Type definitions
βββ *.test.tsx # Smoke/integration tests
- Test files:
<name>.test.tsor<name>.test.tsx(suffix, not__tests__folder) - Test suites: Use
describe()blocks - Test cases: Use
it()blocks with clear, descriptive names - Component props interfaces:
React.ComponentProps<typeof ComponentName>
For component tests, place them alongside the component or in a tests/components/ directory:
src/components/
βββ MyComponent.tsx
βββ MyComponent.test.tsx β Same folder is acceptable
For API route tests, place them in tests/api/:
tests/api/
βββ some-route.test.ts
For library/utility tests, place them in tests/lib/:
tests/lib/
βββ some-utility.test.ts
Use accessibility-first queries when testing components:
// β
GOOD: Query by role (most accessible)
const button = screen.getByRole('button', { name: /submit/i });
// β
GOOD: Query by label (forms)
const input = screen.getByLabelText('Email address');
// β
GOOD: Query by placeholder (when label not available)
const searchInput = screen.getByPlaceholderText('Search...');
// β AVOID: Query by test ID (last resort only)
const element = screen.getByTestId('my-element');
// β AVOID: Query by tag or class
const element = screen.getByTag('div');Always import from @testing-library/react:
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';// @vitest-environment happy-dom
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import MyButton from '@/components/MyButton';
describe('MyButton', () => {
it('renders with label and handles click', () => {
const onClickHandler = vi.fn();
render(
<MyButton onClick={onClickHandler} label="Click me" />
);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onClickHandler).toHaveBeenCalledOnce();
});
it('disables the button when loading', () => {
render(<MyButton label="Submit" isLoading />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});Use vi.stubGlobal() to mock the global fetch function:
import { describe, it, beforeEach, afterEach, vi } from 'vitest';
describe('API Integration', () => {
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it('fetches and displays data', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'test' }),
});
// ... your test code
expect(mockFetch).toHaveBeenCalledWith(
'/api/endpoint',
expect.objectContaining({ method: 'GET' })
);
});
it('handles fetch errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
// ... expect error handling
});
});Helper for mock responses:
function createMockResponse<T>(data: T, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: async () => data,
text: async () => JSON.stringify(data),
headers: new Headers({ 'content-type': 'application/json' }),
} as Response);
}Mock the @stellar/freighter-api module to test wallet interactions:
import { describe, it, beforeEach, vi } from 'vitest';
// Mock the Freighter API
vi.mock('@stellar/freighter-api', () => ({
isConnected: vi.fn(),
getPublicKey: vi.fn(),
signTransaction: vi.fn(),
}));
import { isConnected, getPublicKey, signTransaction } from '@stellar/freighter-api';
describe('Wallet Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('connects and retrieves public key', async () => {
const mockAddress = `G${'A'.repeat(55)}`;
vi.mocked(isConnected).mockResolvedValue(true);
vi.mocked(getPublicKey).mockResolvedValue(mockAddress);
// ... your test code
expect(getPublicKey).toHaveBeenCalled();
});
it('signs a transaction', async () => {
const mockSignedXDR = 'AAAAAgAA...';
vi.mocked(signTransaction).mockResolvedValue(mockSignedXDR);
// ... your test code
expect(signTransaction).toHaveBeenCalledWith(
expect.stringContaining('Transaction'),
expect.any(Object)
);
});
it('handles wallet not installed', async () => {
vi.mocked(isConnected).mockRejectedValue(
new Error('Freighter is not installed')
);
// ... expect graceful error handling
});
});Use vi.mock() to mock entire modules or services:
import { vi } from 'vitest';
// Mock at module level (top of file, runs before imports)
vi.mock('@/lib/backend/contracts', () => ({
getCommitmentFromChain: vi.fn(),
createCommitmentOnChain: vi.fn(),
}));
// Now you can import and use mocked versions
import {
getCommitmentFromChain,
createCommitmentOnChain,
} from '@/lib/backend/contracts';
describe('Commitment Service', () => {
it('retrieves commitment', async () => {
const mockCommitment = { id: 'c-1', status: 'ACTIVE' };
vi.mocked(getCommitmentFromChain).mockResolvedValue(mockCommitment as any);
// ... test code
expect(getCommitmentFromChain).toHaveBeenCalledWith('c-1');
});
});Partial mocking (keep some real implementations):
vi.mock('@/lib/backend/validation', async (importActual) => {
const actual = await importActual<
typeof import('@/lib/backend/validation')
>();
return {
...actual,
validateEmail: vi.fn((email) => email.includes('@')),
};
});Use fake timers to test time-dependent code without waiting:
import { describe, it, beforeEach, afterEach, vi } from 'vitest';
describe('Polling Service', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('polls at regular intervals', async () => {
const poll = vi.fn();
// Simulate polling every 1 second
const interval = setInterval(poll, 1000);
// Fast-forward time
vi.advanceTimersByTime(3000);
expect(poll).toHaveBeenCalledTimes(3);
clearInterval(interval);
});
it('handles timeout correctly', async () => {
const callback = vi.fn();
setTimeout(callback, 5000);
// Fast-forward 5 seconds
vi.advanceTimersByTime(5000);
expect(callback).toHaveBeenCalledOnce();
});
it('runs all pending timers', async () => {
const fn1 = vi.fn();
const fn2 = vi.fn();
setTimeout(fn1, 1000);
setTimeout(fn2, 2000);
// Run all timers at once
vi.runAllTimers();
expect(fn1).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();
});
});Use helpers to create mock requests and test route handlers:
import { describe, it, expect } from 'vitest';
import { GET } from '@/app/api/health/route';
import { createMockRequest, parseResponse } from '../helpers';
describe('GET /api/health', () => {
it('returns a healthy status', async () => {
const request = createMockRequest('http://localhost:3000/api/health');
const response = await GET(request, { params: {} });
const result = await parseResponse(response);
expect(result.status).toBe(200);
expect(result.data).toMatchObject({
success: true,
data: { status: 'healthy' },
});
});
});Helper functions (from tests/api/helpers.ts):
// Create a mock NextRequest
const request = createMockRequest(url, {
method: 'POST',
body: { key: 'value' },
headers: { 'x-custom': 'header' },
});
// Create route context for dynamic routes
const context = createMockRouteContext({ id: '123' });
// Parse response for assertions
const result = await parseResponse(response);
// result.status, result.data, result.headersThe project includes custom matchers in tests/setup/vitest.setup.ts:
// Check if string starts with
expect('hello world').toStartWith('hello');
// Check if string ends with
expect('hello world').toEndWith('world');Common matchers from @testing-library/jest-dom:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveAttribute('href', '/path');
expect(element).toHaveClass('active');
expect(element).toHaveTextContent('Expected text');import { render } from '@testing-library/react';
import { WalletProvider } from '@/contexts/WalletContext';
function renderWithProviders(component: React.ReactElement) {
return render(
<WalletProvider>
{component}
</WalletProvider>
);
}
describe('ComponentUsingWallet', () => {
it('displays wallet status', () => {
renderWithProviders(<ComponentUsingWallet />);
// test code
});
});import { render, screen, waitFor } from '@testing-library/react';
it('loads data asynchronously', async () => {
render(<DataLoader />);
// Wait for async operation to complete
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});import userEvent from '@testing-library/user-event';
it('submits form with validation', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<Form onSubmit={onSubmit} />);
const input = screen.getByLabelText('Email');
await user.type(input, 'test@example.com');
const button = screen.getByRole('button', { name: /submit/i });
await user.click(button);
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
});
});// @vitest-environment happy-dom
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import MyComponent from '@/components/MyComponent';
interface MockProps extends Partial<React.ComponentProps<typeof MyComponent>> {}
function renderComponent(props: MockProps = {}) {
const defaultProps: React.ComponentProps<typeof MyComponent> = {
// Set required props
};
return render(<MyComponent {...defaultProps} {...props} />);
}
describe('MyComponent', () => {
it('renders with default props', () => {
renderComponent();
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('handles user interaction', () => {
const onAction = vi.fn();
renderComponent({ onAction });
fireEvent.click(screen.getByRole('button'));
expect(onAction).toHaveBeenCalledOnce();
});
it('displays error state', () => {
renderComponent({ error: 'Something went wrong' });
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
});import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GET, POST } from '@/app/api/my-route/route';
import { createMockRequest, parseResponse } from '../helpers';
vi.mock('@/lib/backend/service', () => ({
getService: vi.fn(),
}));
import { getService } from '@/lib/backend/service';
describe('POST /api/my-route', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 200 on success', async () => {
vi.mocked(getService).mockResolvedValue({ data: 'test' } as any);
const request = createMockRequest('http://localhost:3000/api/my-route', {
method: 'POST',
body: { key: 'value' },
});
const response = await POST(request, { params: {} });
const result = await parseResponse(response);
expect(result.status).toBe(200);
expect(result.data).toMatchObject({ success: true });
});
it('returns 400 on invalid input', async () => {
const request = createMockRequest('http://localhost:3000/api/my-route', {
method: 'POST',
body: {}, // Missing required fields
});
const response = await POST(request, { params: {} });
const result = await parseResponse(response);
expect(result.status).toBe(400);
});
it('returns 405 for unsupported method', async () => {
const request = createMockRequest('http://localhost:3000/api/my-route', {
method: 'DELETE',
});
const response = await DELETE(request);
const result = await parseResponse(response);
expect(result.status).toBe(405);
});
});- β Write tests with user behavior in mind (what does the user see and do?)
- β Use accessibility queries (getByRole, getByLabelText)
- β Keep tests focused and isolated (one behavior per test)
- β Mock external dependencies (fetch, wallet API, services)
- β Use descriptive test names that explain what's being tested
- β
Clean up mocks in
afterEach()blocks - β Test both success and error cases
- β Use fake timers to avoid slow tests
- β Test implementation details (test behavior, not how it works)
- β Use
getByTestIdas first choice (only when necessary) - β Leave mock setup in tests (use beforeEach/afterEach)
- β Create overly complex test scenarios (break them into smaller tests)
- β Test libraries you didn't write (assume they work)
- β Forget to await async operations
- β Use
setTimeoutinstead of fake timers
pnpm test -- hero-section.test.tsxpnpm test -- --grep "renders"pnpm test:uipnpm test -- --reporter=verboseAdd this to .vscode/launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["test", "--inspect-brk", "--watch"],
"console": "integratedTerminal"
}- Vitest Documentation
- React Testing Library
- Testing Library Best Practices
- @testing-library/jest-dom Matchers
- DEVELOPER_GUIDE.md β Coding standards and contribution workflow
- README.md β Project overview and setup
- docs/FRONTEND_ARCHITECTURE.md β Component structure and data flow