mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
* 🔖 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>
561 lines
12 KiB
Plaintext
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/
|