docs/development/basic/test.mdx
LobeHub's testing strategy includes unit testing with Vitest and end-to-end (E2E) testing with Playwright + Cucumber. This guide covers how to write and run tests effectively.
Our testing strategy includes:
bun run type-check)# Run a specific unit test (RECOMMENDED)
bunx vitest run --silent='passed-only' 'path/to/test.test.ts'
# Run tests in a package (e.g., database)
cd packages/database && bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
# Type checking
bun run type-check
# E2E tests
pnpm e2e
<Callout type={'warning'}>
Never run the full test suite with bun run test — it runs all tests and takes ~10 minutes.
Always target a specific file with bunx vitest run --silent='passed-only' '[file-path]'.
</Callout>
Create test files alongside the code being tested, named <filename>.test.ts:
src/utils/
├── formatDate.ts
└── formatDate.test.ts
Use describe and it to organize test cases. Use beforeEach/afterEach to manage setup and teardown:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatDate } from './formatDate';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('formatDate', () => {
describe('with default format', () => {
it('should format date correctly', () => {
const date = new Date('2024-03-15');
const result = formatDate(date);
expect(result).toBe('Mar 15, 2024');
});
});
describe('with custom format', () => {
it('should use custom format', () => {
const date = new Date('2024-03-15');
const result = formatDate(date, 'YYYY-MM-DD');
expect(result).toBe('2024-03-15');
});
});
});
Use @testing-library/react to test component behavior:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
it('should render user name', () => {
render(<UserProfile name="Alice" />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
it('should call onClick when button clicked', () => {
const onClick = vi.fn();
render(<UserProfile name="Alice" onClick={onClick} />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('should handle async data loading', async () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
});
Reset store state in beforeEach to keep tests independent:
import { describe, it, expect, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { useUserStore } from './index';
beforeEach(() => {
useUserStore.setState({
users: {},
currentUserId: null,
});
});
describe('useUserStore', () => {
describe('addUser', () => {
it('should add user to store', () => {
const user = { id: '1', name: 'Alice' };
act(() => {
useUserStore.getState().addUser(user);
});
const state = useUserStore.getState();
expect(state.users['1']).toEqual(user);
});
});
describe('setCurrentUser', () => {
it('should update current user ID', () => {
act(() => {
useUserStore.getState().setCurrentUser('123');
});
expect(useUserStore.getState().currentUserId).toBe('123');
});
});
});
vi.spyOn Over vi.mock// ✅ Good — spyOn is scoped and auto-restores
vi.spyOn(messageService, 'createMessage').mockResolvedValue('msg_123');
// ❌ Avoid — global mocks are fragile and leak between tests
vi.mock('@/services/message');
// Mock Image
const mockImage = vi.fn(() => ({
addEventListener: vi.fn((event, handler) => {
if (event === 'load') setTimeout(handler, 0);
}),
removeEventListener: vi.fn(),
}));
vi.stubGlobal('Image', mockImage);
// Mock URL.createObjectURL
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
// Mock fetch
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data: 'test' }),
ok: true,
}),
);
// Mock external library
vi.mock('axios', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: {} })),
},
}));
// Mock internal module
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
import { waitFor } from '@testing-library/react';
it('should load data asynchronously', async () => {
await expect(fetchUser('123')).resolves.toEqual({ id: '123', name: 'Alice' });
});
it('should handle errors', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('User not found');
});
For database and ORM tests in packages:
# Client DB tests
cd packages/database
bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
# Server DB tests
cd packages/database
TEST_SERVER_DB=1 bunx vitest run --silent='passed-only' 'src/models/user.test.ts'
# Run all E2E tests
pnpm e2e
# Run in UI mode (interactive)
pnpm e2e:ui
# Smoke tests only (quick validation)
pnpm test:e2e:smoke
E2E tests live in the e2e/ directory:
e2e/
├── features/ # Cucumber feature files (.feature)
│ ├── auth.feature
│ └── chat.feature
├── step-definitions/ # Step implementations
│ ├── auth.steps.ts
│ └── chat.steps.ts
├── support/ # Shared helpers and hooks
└── playwright.config.ts
Feature file (e2e/features/chat.feature):
Feature: Chat Functionality
Scenario: User sends a message
Given I am logged in
And I am on the chat page
When I type "Hello, AI!"
And I click the send button
Then I should see my message in the chat
And I should see a response from the AI
Step definitions (e2e/step-definitions/chat.steps.ts):
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
Given('I am on the chat page', async function () {
await this.page.goto('/chat');
});
When('I type {string}', async function (message: string) {
await this.page.fill('[data-testid="chat-input"]', message);
});
When('I click the send button', async function () {
await this.page.click('[data-testid="send-button"]');
});
Then('I should see my message in the chat', async function () {
await expect(this.page.locator('.user-message').last()).toBeVisible();
});
// ✅ Good — tests what the user experiences
it('should allow user to submit form', () => {
render(<ContactForm />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Alice' },
});
fireEvent.click(screen.getByText('Submit'));
expect(screen.getByText('Form submitted')).toBeInTheDocument();
});
// ❌ Bad — tests internal implementation details
it('should call setState when input changes', () => {
const setState = vi.fn();
render(<ContactForm setState={setState} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Alice' },
});
expect(setState).toHaveBeenCalled();
});
// ✅ Good — accessible queries match what users see
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email address');
screen.getByText('Welcome back');
// ❌ Bad — test IDs should be a last resort
screen.getByTestId('submit-button');
import { beforeEach, afterEach, vi } from 'vitest';
beforeEach(() => {
vi.clearAllMocks(); // Clear mock call history
vi.clearAllTimers(); // Clear timers if using fake timers
});
afterEach(() => {
vi.restoreAllMocks(); // Restore original implementations
});
describe('validateEmail', () => {
it('should accept valid email', () => {
expect(validateEmail('[email protected]')).toBe(true);
});
it('should reject empty string', () => {
expect(validateEmail('')).toBe(false);
});
it('should reject email without @', () => {
expect(validateEmail('user.example.com')).toBe(false);
});
it('should reject email without domain', () => {
expect(validateEmail('user@')).toBe(false);
});
});
// ❌ Bad — tests share state and depend on execution order
let userId: string;
it('should create user', () => {
userId = createUser('Alice');
expect(userId).toBeDefined();
});
it('should fetch user', () => {
const user = getUser(userId); // depends on previous test
expect(user.name).toBe('Alice');
});
// ✅ Good — each test sets up its own data
it('should create user', () => {
const userId = createUser('Alice');
expect(userId).toBeDefined();
});
it('should fetch user', () => {
const userId = createUser('Bob');
const user = getUser(userId);
expect(user.name).toBe('Bob');
});
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
it('should increment counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';
import { MyComponent } from './MyComponent';
function renderWithTheme(component: React.ReactElement) {
return render(<ThemeProvider theme="dark">{component}</ThemeProvider>);
}
it('should use theme', () => {
renderWithTheme(<MyComponent />);
expect(screen.getByRole('main')).toHaveClass('dark-theme');
});
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ id: '1', name: 'Alice' }));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should fetch user data', async () => {
const user = await fetchUser('1');
expect(user.name).toBe('Alice');
});
bun run test-app:coverage
Then open coverage/index.html to view the report.
| Area | Target |
|---|---|
| Critical paths | 80%+ |
| Utilities | 90%+ |
| UI components | 70%+ |
Add to .vscode/launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"runtimeExecutable": "bun",
"runtimeArgs": ["x", "vitest", "run", "${file}"],
"console": "integratedTerminal"
}
bunx vitest --ui
Opens an interactive test explorer in the browser.
it('should work', () => {
console.log('Debug:', value); // appears in test output
expect(value).toBe(expected);
});
GitHub Actions automatically runs on every PR:
All checks must pass before a PR can merge.