02-测试技术

📋 学习目标

  • 掌握单元测试编写

  • 学习组件测试技巧

  • 理解E2E测试流程

  • 掌握测试覆盖率分析

🧪 测试框架

Vitest(推荐)

# 安装
pnpm add -D vitest @vitest/ui

# package.json
{
    "scripts": {
        "test": "vitest",
        "test:ui": "vitest --ui",
        "test:coverage": "vitest --coverage"
    }
}

基本配置

// vitest.config.ts
import {defineConfig} from 'vitest/config';

export default defineConfig({
    test: {
        environment: 'jsdom', // 或'node', 'happy-dom'
        globals: true,
        setupFiles: './tests/setup.ts',
        coverage: {
            provider: 'v8',
            reporter: ['text', 'json', 'html']
        }
    }
});

📝 单元测试

基本断言

import {describe, it, expect} from 'vitest';

describe('Math utils', () => {
    it('should add two numbers', () => {
        expect(1 + 1).toBe(2);
    });
    
    it('should multiply numbers', () => {
        const result = multiply(3, 4);
        expect(result).toBe(12);
        expect(result).toBeGreaterThan(10);
        expect(result).toBeLessThan(20);
    });
    
    it('should handle objects', () => {
        const user = {name: 'John', age: 30};
        expect(user).toEqual({name: 'John', age: 30});
        expect(user).toHaveProperty('name');
        expect(user).toMatchObject({name: 'John'});
    });
    
    it('should handle arrays', () => {
        const arr = [1, 2, 3];
        expect(arr).toHaveLength(3);
        expect(arr).toContain(2);
        expect(arr).toEqual([1, 2, 3]);
    });
});

Mock函数

import {vi} from 'vitest';

describe('Mock functions', () => {
    it('should track calls', () => {
        const mockFn = vi.fn();
        mockFn('hello');
        mockFn('world');
        
        expect(mockFn).toHaveBeenCalledTimes(2);
        expect(mockFn).toHaveBeenCalledWith('hello');
        expect(mockFn).toHaveBeenLastCalledWith('world');
    });
    
    it('should return values', () => {
        const mockFn = vi.fn()
            .mockReturnValueOnce('first')
            .mockReturnValueOnce('second')
            .mockReturnValue('default');
        
        expect(mockFn()).toBe('first');
        expect(mockFn()).toBe('second');
        expect(mockFn()).toBe('default');
    });
    
    it('should mock implementation', () => {
        const mockFn = vi.fn((a, b) => a + b);
        expect(mockFn(1, 2)).toBe(3);
    });
});

异步测试

describe('Async tests', () => {
    it('should handle promises', async () => {
        const data = await fetchData();
        expect(data).toEqual({id: 1});
    });
    
    it('should handle async/await', async () => {
        const result = await asyncFunction();
        expect(result).toBe('success');
    });
    
    it('should handle errors', async () => {
        await expect(failingAsync()).rejects.toThrow('Error');
    });
    
    it('should timeout', async () => {
        await new Promise(resolve => setTimeout(resolve, 100));
    }, {timeout: 1000});
});

⚛️ React组件测试

React Testing Library

# 安装
pnpm add -D @testing-library/react @testing-library/jest-dom

基本测试

import {render, screen, fireEvent} from '@testing-library/react';
import {describe, it, expect} from 'vitest';
import Counter from './Counter';

describe('Counter', () => {
    it('should render initial count', () => {
        render(<Counter />);
        expect(screen.getByText('Count: 0')).toBeInTheDocument();
    });
    
    it('should increment count', () => {
        render(<Counter />);
        const button = screen.getByRole('button', {name: /increment/i});
        fireEvent.click(button);
        expect(screen.getByText('Count: 1')).toBeInTheDocument();
    });
    
    it('should decrement count', async () => {
        render(<Counter />);
        const button = screen.getByRole('button', {name: /decrement/i});
        await userEvent.click(button);
        expect(screen.getByText('Count: -1')).toBeInTheDocument();
    });
});

测试Hooks

import {renderHook, act} from '@testing-library/react';
import {useCounter} from './useCounter';

describe('useCounter', () => {
    it('should initialize with 0', () => {
        const {result} = renderHook(() => useCounter());
        expect(result.current.count).toBe(0);
    });
    
    it('should increment', () => {
        const {result} = renderHook(() => useCounter());
        act(() => {
            result.current.increment();
        });
        expect(result.current.count).toBe(1);
    });
});

测试异步组件

import {render, screen, waitFor} from '@testing-library/react';

describe('UserList', () => {
    it('should load and display users', async () => {
        render(<UserList />);
        
        // 等待加载完成
        await waitFor(() => {
            expect(screen.getByText('John')).toBeInTheDocument();
        });
    });
    
    it('should handle errors', async () => {
        // Mock API错误
        vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error'));
        
        render(<UserList />);
        
        await waitFor(() => {
            expect(screen.getByText(/error/i)).toBeInTheDocument();
        });
    });
});

💚 Vue组件测试

Vue Test Utils

# 安装
pnpm add -D @vue/test-utils

基本测试

import {mount} from '@vue/test-utils';
import {describe, it, expect} from 'vitest';
import Counter from './Counter.vue';

describe('Counter.vue', () => {
    it('should render count', () => {
        const wrapper = mount(Counter);
        expect(wrapper.text()).toContain('Count: 0');
    });
    
    it('should increment', async () => {
        const wrapper = mount(Counter);
        await wrapper.find('button').trigger('click');
        expect(wrapper.text()).toContain('Count: 1');
    });
    
    it('should accept props', () => {
        const wrapper = mount(Counter, {
            props: {
                initialCount: 10
            }
        });
        expect(wrapper.text()).toContain('Count: 10');
    });
});

测试组合式函数

import {useCounter} from './useCounter';

describe('useCounter', () => {
    it('should increment', () => {
        const {count, increment} = useCounter();
        expect(count.value).toBe(0);
        increment();
        expect(count.value).toBe(1);
    });
});

🌐 E2E测试

Playwright(推荐)

# 安装
pnpm create playwright

# 运行测试
pnpm playwright test
pnpm playwright test --ui
pnpm playwright show-report

基本E2E测试

import {test, expect} from '@playwright/test';

test.describe('Login flow', () => {
    test('should login successfully', async ({page}) => {
        // 访问页面
        await page.goto('http://localhost:3000/login');
        
        // 填写表单
        await page.fill('input[name="email"]', 'user@example.com');
        await page.fill('input[name="password"]', 'password123');
        
        // 点击按钮
        await page.click('button[type="submit"]');
        
        // 验证跳转
        await expect(page).toHaveURL('http://localhost:3000/dashboard');
        
        // 验证元素
        await expect(page.locator('h1')).toContainText('Dashboard');
    });
    
    test('should show error for invalid credentials', async ({page}) => {
        await page.goto('http://localhost:3000/login');
        
        await page.fill('input[name="email"]', 'wrong@example.com');
        await page.fill('input[name="password"]', 'wrong');
        await page.click('button[type="submit"]');
        
        await expect(page.locator('.error')).toBeVisible();
    });
});

高级E2E测试

test.describe('Shopping cart', () => {
    test.beforeEach(async ({page}) => {
        // 登录
        await page.goto('/login');
        await page.fill('[name="email"]', 'user@example.com');
        await page.fill('[name="password"]', 'password');
        await page.click('button[type="submit"]');
        await page.waitForURL('/dashboard');
    });
    
    test('should add item to cart', async ({page}) => {
        await page.goto('/products');
        
        // 点击第一个产品
        await page.click('.product-card:first-child button');
        
        // 验证购物车数量
        const badge = page.locator('.cart-badge');
        await expect(badge).toHaveText('1');
        
        // 截图
        await page.screenshot({path: 'cart.png'});
    });
});

📊 测试覆盖率

配置覆盖率

// vitest.config.ts
export default defineConfig({
    test: {
        coverage: {
            provider: 'v8',
            reporter: ['text', 'json', 'html', 'lcov'],
            exclude: [
                'node_modules/',
                'tests/',
                '**/*.test.{js,ts,jsx,tsx}',
                '**/*.config.{js,ts}',
                '**/types.ts'
            ],
            thresholds: {
                lines: 80,
                functions: 80,
                branches: 80,
                statements: 80
            }
        }
    }
});

查看覆盖率

# 生成覆盖率报告
pnpm test:coverage

# 打开HTML报告
open coverage/index.html

🎯 测试最佳实践

1. AAA模式

test('should add item to list', () => {
    // Arrange(准备)
    const list = new List();
    const item = {id: 1, name: 'Item'};
    
    // Act(执行)
    list.add(item);
    
    // Assert(断言)
    expect(list.items).toHaveLength(1);
    expect(list.items[0]).toEqual(item);
});

2. 测试隔离

describe('User service', () => {
    let userService;
    
    beforeEach(() => {
        // 每个测试前重置
        userService = new UserService();
    });
    
    afterEach(() => {
        // 清理
        userService.clear();
    });
    
    it('should create user', () => {
        const user = userService.create('John');
        expect(user.name).toBe('John');
    });
});

3. Mock外部依赖

import {vi} from 'vitest';
import {fetchUser} from './api';

vi.mock('./api', () => ({
    fetchUser: vi.fn()
}));

test('should fetch user', async () => {
    fetchUser.mockResolvedValue({id: 1, name: 'John'});
    
    const user = await getUser(1);
    
    expect(fetchUser).toHaveBeenCalledWith(1);
    expect(user.name).toBe('John');
});

4. 快照测试

import {render} from '@testing-library/react';

test('should match snapshot', () => {
    const {container} = render(<Button>Click me</Button>);
    expect(container).toMatchSnapshot();
});

🔄 持续集成

GitHub Actions

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
    test:
        runs-on: ubuntu-latest
        
        steps:
            - uses: actions/checkout@v3
            
            - name: Setup Node.js
              uses: actions/setup-node@v3
              with:
                  node-version: '20'
            
            - name: Install dependencies
              run: pnpm install
            
            - name: Run tests
              run: pnpm test:coverage
            
            - name: Upload coverage
              uses: codecov/codecov-action@v3

📚 测试工具推荐

单元测试

  • Vitest

  • Jest

  • Mocha + Chai

组件测试

  • React Testing Library

  • Vue Test Utils

  • Enzyme(React旧项目)

E2E测试

  • Playwright

  • Cypress

  • Puppeteer

其他工具

  • MSW:API Mock

  • Faker.js:测试数据生成

  • Testing Playground:选择器辅助

📚 参考资料