Files
lobehub/docs/development/basic/test.mdx
CanisMinor 43578a9bcc 📝 docs: Polishing and improving product documentation (#12612)
* 🔖 chore(release): release version v2.1.34 [skip ci]

* 📝 docs: Polish documents

* 📝 docs: Fix typo

* 📝 docs: Update start

* 📝 docs: Fix style

* 📝 docs: Update start

* 📝 docs: Update layout

* 📝 docs: Fix typo

* 📝 docs: Fix typo

---------

Co-authored-by: lobehubbot <i@lobehub.com>
2026-03-03 16:01:41 +08:00

561 lines
12 KiB
Plaintext

---
title: Testing Guide
description: >-
Explore LobeHub's testing strategy, including unit testing with Vitest and
end-to-end testing with Playwright + Cucumber.
tags:
- LobeHub
- Testing
- Unit Testing
- End-to-End Testing
- vitest
- Playwright
---
# Testing Guide
LobeHub's testing strategy includes unit testing with [Vitest][vitest-url] and end-to-end (E2E) testing with Playwright + Cucumber. This guide covers how to write and run tests effectively.
## Overview
Our testing strategy includes:
- **Unit Tests** — Vitest for functions, components, and stores
- **E2E Tests** — Playwright + Cucumber for user flows
- **Type Checking** — TypeScript compiler (`bun run type-check`)
- **Linting** — ESLint, Stylelint
## Quick Reference
### Commands
```bash
# 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>
## Unit Testing with Vitest
### Test File Structure
Create test files alongside the code being tested, named `<filename>.test.ts`:
```
src/utils/
├── formatDate.ts
└── formatDate.test.ts
```
### Writing Test Cases
Use `describe` and `it` to organize test cases. Use `beforeEach`/`afterEach` to manage setup and teardown:
```typescript
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');
});
});
});
```
### Testing React Components
Use `@testing-library/react` to test component behavior:
```typescript
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();
});
});
});
```
### Testing Zustand Stores
Reset store state in `beforeEach` to keep tests independent:
```typescript
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');
});
});
});
```
### Mocking
#### Prefer `vi.spyOn` Over `vi.mock`
```typescript
// ✅ 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 Browser APIs
```typescript
// 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 Modules
```typescript
// 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(),
},
}));
```
### Testing Async Code
```typescript
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');
});
```
### Testing Database Code
For database and ORM tests in packages:
```bash
# 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'
```
## E2E Testing with Playwright
### Running E2E Tests
```bash
# Run all E2E tests
pnpm e2e
# Run in UI mode (interactive)
pnpm e2e:ui
# Smoke tests only (quick validation)
pnpm test:e2e:smoke
```
### E2E Test Structure
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
```
### Writing E2E Tests
**Feature file** (`e2e/features/chat.feature`):
```gherkin
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`):
```typescript
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();
});
```
## Best Practices
### 1. Test Behavior, Not Implementation
```typescript
// ✅ 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();
});
```
### 2. Use Semantic Queries
```typescript
// ✅ 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');
```
### 3. Clean Up After Each Test
```typescript
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
});
```
### 4. Test Edge Cases
```typescript
describe('validateEmail', () => {
it('should accept valid email', () => {
expect(validateEmail('user@example.com')).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);
});
});
```
### 5. Keep Tests Independent
```typescript
// ❌ 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');
});
```
## Common Patterns
### Testing Hooks
```typescript
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);
});
```
### Testing Context Providers
```typescript
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');
});
```
### Testing API Calls with MSW
```typescript
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');
});
```
## Coverage
### Generate Coverage Report
```bash
bun run test-app:coverage
```
Then open `coverage/index.html` to view the report.
### Coverage Goals
| Area | Target |
| -------------- | ------ |
| Critical paths | 80%+ |
| Utilities | 90%+ |
| UI components | 70%+ |
## Debugging Tests
### VS Code Debugging
Add to `.vscode/launch.json`:
```json
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"runtimeExecutable": "bun",
"runtimeArgs": ["x", "vitest", "run", "${file}"],
"console": "integratedTerminal"
}
```
### Vitest UI
```bash
bunx vitest --ui
```
Opens an interactive test explorer in the browser.
### Console Logging
```typescript
it('should work', () => {
console.log('Debug:', value); // appears in test output
expect(value).toBe(expected);
});
```
## CI/CD Integration
GitHub Actions automatically runs on every PR:
1. Linting (ESLint, Stylelint)
2. Type checking
3. Unit tests
4. E2E tests
5. Build verification
All checks must pass before a PR can merge.
[vitest-url]: https://vitest.dev/