--- 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 ``` **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]'`. ## Unit Testing with Vitest ### Test File Structure Create test files alongside the code being tested, named `.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(); expect(screen.getByText('Alice')).toBeInTheDocument(); }); it('should call onClick when button clicked', () => { const onClick = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); expect(onClick).toHaveBeenCalledOnce(); }); it('should handle async data loading', async () => { render(); 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(); 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(); 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({component}); } it('should use theme', () => { renderWithTheme(); 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/