Skip to content

自动化测试流程 - 保障代码质量的完整方案

1. 自动化测试概述

1.1 什么是自动化测试

自动化测试是使用专门的测试软件来控制测试执行、比较实际结果与预期结果、设置测试前提条件以及其他测试控制和报告功能的过程。

核心价值:

  • 提高效率:自动执行重复性测试任务
  • 保证质量:快速发现代码缺陷
  • 回归测试:确保新代码不破坏现有功能
  • 持续集成:与 CI/CD 无缝集成
  • 文档作用:测试用例作为代码文档

1.2 测试金字塔

        /\
       /  \        E2E 测试(少量)
      /____\       - 端到端测试
     /      \      - UI 测试
    /________\     
   /          \    集成测试(适量)
  /____________\   - API 测试
 /              \  - 组件集成
/________________\ 
                   单元测试(大量)
                   - 函数测试
                   - 工具测试

原则:

  1. 70% 单元测试:快速、稳定、易维护
  2. 20% 集成测试:测试模块间交互
  3. 10% E2E 测试:模拟真实用户场景

1.3 测试类型

单元测试(Unit Testing)

typescript
// 测试单个函数或组件
describe('sum function', () => {
  it('should add two numbers', () => {
    expect(sum(1, 2)).toBe(3);
  });
});

集成测试(Integration Testing)

typescript
// 测试多个模块协作
describe('UserService with UserRepository', () => {
  it('should create user and save to database', async () => {
    const user = await userService.createUser(userData);
    expect(user.id).toBeDefined();
  });
});

E2E 测试(End-to-End Testing)

typescript
// 测试完整用户流程
test('user can login and view dashboard', 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 expect(page).toHaveURL('/dashboard');
});

2. 单元测试

2.1 Jest 配置

安装依赖

bash
npm install -D jest @types/jest ts-jest
npm install -D @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event

配置文件

javascript
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  roots: ['<rootDir>/src'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/?(*.)+(spec|test).+(ts|tsx|js)'
  ],
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest'
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.tsx',
    '!src/reportWebVitals.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

测试环境设置

typescript
// src/setupTests.ts
import '@testing-library/jest-dom';

// 全局模拟
global.matchMedia = global.matchMedia || function () {
  return {
    addListener: jest.fn(),
    removeListener: jest.fn(),
  };
};

// 模拟 window.scrollTo
global.scrollTo = jest.fn();

// 模拟 IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  disconnect() {}
  observe() {}
  takeRecords() {
    return [];
  }
  unobserve() {}
};

2.2 React 组件测试

基础组件测试

typescript
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button Component', () => {
  it('renders button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick handler when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('applies custom className', () => {
    render(<Button className="custom-btn">Click me</Button>);
    expect(screen.getByText('Click me')).toHaveClass('custom-btn');
  });

  it('renders disabled button', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

Hooks 测试

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

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });

  it('resets count', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(12);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

异步组件测试

typescript
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import { getUserById } from '@/api/users';

jest.mock('@/api/users');

describe('UserProfile', () => {
  it('displays loading state', () => {
    (getUserById as jest.Mock).mockReturnValue(
      new Promise(() => {}) // 永不resolve
    );
    
    render(<UserProfile userId="123" />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('displays user data when loaded', async () => {
    const mockUser = {
      id: '123',
      name: 'John Doe',
      email: 'john@example.com'
    };
    
    (getUserById as jest.Mock).mockResolvedValue(mockUser);
    
    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('john@example.com')).toBeInTheDocument();
    });
  });

  it('displays error message on failure', async () => {
    (getUserById as jest.Mock).mockRejectedValue(
      new Error('Failed to fetch user')
    );
    
    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});

2.3 Context 和 Provider 测试

typescript
// AuthProvider.test.tsx
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';

const wrapper = ({ children }: { children: React.ReactNode }) => (
  <AuthProvider>{children}</AuthProvider>
);

describe('AuthContext', () => {
  it('provides initial auth state', () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    expect(result.current.user).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });

  it('updates user on login', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await act(async () => {
      await result.current.login('user@example.com', 'password');
    });
    
    expect(result.current.user).toBeDefined();
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('clears user on logout', async () => {
    const { result } = renderHook(() => useAuth(), { wrapper });
    
    await act(async () => {
      await result.current.login('user@example.com', 'password');
    });
    
    expect(result.current.isAuthenticated).toBe(true);
    
    act(() => {
      result.current.logout();
    });
    
    expect(result.current.user).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });
});

2.4 工具函数测试

typescript
// utils.test.ts
import { formatDate, validateEmail, debounce } from './utils';

describe('formatDate', () => {
  it('formats date correctly', () => {
    const date = new Date('2024-01-15');
    expect(formatDate(date)).toBe('2024-01-15');
  });

  it('handles invalid date', () => {
    expect(formatDate(new Date('invalid'))).toBe('Invalid Date');
  });
});

describe('validateEmail', () => {
  it('validates correct email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('rejects invalid email', () => {
    expect(validateEmail('invalid-email')).toBe(false);
    expect(validateEmail('user@')).toBe(false);
    expect(validateEmail('@example.com')).toBe(false);
  });
});

describe('debounce', () => {
  jest.useFakeTimers();

  it('delays function execution', () => {
    const func = jest.fn();
    const debouncedFunc = debounce(func, 1000);

    debouncedFunc();
    expect(func).not.toHaveBeenCalled();

    jest.advanceTimersByTime(1000);
    expect(func).toHaveBeenCalledTimes(1);
  });

  it('cancels previous calls', () => {
    const func = jest.fn();
    const debouncedFunc = debounce(func, 1000);

    debouncedFunc();
    debouncedFunc();
    debouncedFunc();

    jest.advanceTimersByTime(1000);
    expect(func).toHaveBeenCalledTimes(1);
  });
});

3. 集成测试

3.1 API 集成测试

typescript
// userService.test.ts
import { UserService } from './UserService';
import { UserRepository } from './UserRepository';
import { Database } from './Database';

describe('UserService Integration', () => {
  let userService: UserService;
  let userRepository: UserRepository;
  let database: Database;

  beforeAll(async () => {
    database = new Database();
    await database.connect();
    userRepository = new UserRepository(database);
    userService = new UserService(userRepository);
  });

  afterAll(async () => {
    await database.disconnect();
  });

  beforeEach(async () => {
    await database.clear();
  });

  it('creates and retrieves user', async () => {
    const userData = {
      email: 'user@example.com',
      name: 'John Doe'
    };

    const createdUser = await userService.createUser(userData);
    expect(createdUser.id).toBeDefined();

    const retrievedUser = await userService.getUserById(createdUser.id);
    expect(retrievedUser).toEqual(createdUser);
  });

  it('updates user information', async () => {
    const user = await userService.createUser({
      email: 'user@example.com',
      name: 'John Doe'
    });

    const updatedUser = await userService.updateUser(user.id, {
      name: 'Jane Doe'
    });

    expect(updatedUser.name).toBe('Jane Doe');
    expect(updatedUser.email).toBe('user@example.com');
  });

  it('deletes user', async () => {
    const user = await userService.createUser({
      email: 'user@example.com',
      name: 'John Doe'
    });

    await userService.deleteUser(user.id);

    await expect(
      userService.getUserById(user.id)
    ).rejects.toThrow('User not found');
  });
});

3.2 组件集成测试

typescript
// LoginForm.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { AuthProvider } from '@/contexts/AuthContext';
import { BrowserRouter } from 'react-router-dom';
import { server } from '@/mocks/server';
import { rest } from 'msw';

const AllProviders = ({ children }: { children: React.ReactNode }) => (
  <BrowserRouter>
    <AuthProvider>{children}</AuthProvider>
  </BrowserRouter>
);

describe('LoginForm Integration', () => {
  it('successfully logs in user', async () => {
    const user = userEvent.setup();
    
    render(<LoginForm />, { wrapper: AllProviders });

    await user.type(
      screen.getByLabelText(/email/i),
      'user@example.com'
    );
    await user.type(
      screen.getByLabelText(/password/i),
      'password123'
    );
    await user.click(screen.getByRole('button', { name: /log in/i }));

    await waitFor(() => {
      expect(window.location.pathname).toBe('/dashboard');
    });
  });

  it('displays error on failed login', async () => {
    const user = userEvent.setup();
    
    server.use(
      rest.post('/api/auth/login', (req, res, ctx) => {
        return res(
          ctx.status(401),
          ctx.json({ message: 'Invalid credentials' })
        );
      })
    );

    render(<LoginForm />, { wrapper: AllProviders });

    await user.type(
      screen.getByLabelText(/email/i),
      'wrong@example.com'
    );
    await user.type(
      screen.getByLabelText(/password/i),
      'wrongpassword'
    );
    await user.click(screen.getByRole('button', { name: /log in/i }));

    await waitFor(() => {
      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
    });
  });
});

3.3 使用 MSW 模拟 API

设置 MSW

typescript
// src/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    
    return res(
      ctx.json({
        id,
        name: 'John Doe',
        email: 'john@example.com'
      })
    );
  }),

  rest.post('/api/auth/login', async (req, res, ctx) => {
    const { email, password } = await req.json();
    
    if (email === 'user@example.com' && password === 'password123') {
      return res(
        ctx.json({
          user: { id: '1', email, name: 'John Doe' },
          token: 'fake-jwt-token'
        })
      );
    }
    
    return res(
      ctx.status(401),
      ctx.json({ message: 'Invalid credentials' })
    );
  }),

  rest.get('/api/posts', (req, res, ctx) => {
    const page = req.url.searchParams.get('page') || '1';
    
    return res(
      ctx.json({
        posts: [
          { id: '1', title: 'Post 1', content: 'Content 1' },
          { id: '2', title: 'Post 2', content: 'Content 2' }
        ],
        page: Number(page),
        total: 10
      })
    );
  })
];
typescript
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
typescript
// src/setupTests.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

4. E2E 测试

4.1 Playwright 配置

安装和配置

bash
npm install -D @playwright/test
npx playwright install
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

4.2 编写 E2E 测试

基础流程测试

typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can sign up', async ({ page }) => {
    await page.goto('/signup');

    await page.fill('[name="email"]', 'newuser@example.com');
    await page.fill('[name="password"]', 'SecurePass123!');
    await page.fill('[name="confirmPassword"]', 'SecurePass123!');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/welcome');
    await expect(page.locator('text=Welcome')).toBeVisible();
  });

  test('user can log in', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'wrong@example.com');
    await page.fill('[name="password"]', 'wrongpass');
    await page.click('button[type="submit"]');

    await expect(page.locator('.error-message')).toContainText(
      'Invalid credentials'
    );
  });
});

复杂交互测试

typescript
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

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"]', 'password123');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('add product to cart', async ({ page }) => {
    await page.goto('/products');

    // 点击第一个产品
    await page.click('.product-card:first-child');
    await expect(page).toHaveURL(/\/products\/\d+/);

    // 添加到购物车
    await page.click('button:has-text("Add to Cart")');

    // 验证购物车图标显示数量
    await expect(page.locator('.cart-count')).toHaveText('1');
  });

  test('complete checkout process', async ({ page }) => {
    // 先添加产品到购物车
    await page.goto('/products/1');
    await page.click('button:has-text("Add to Cart")');

    // 进入购物车
    await page.click('.cart-icon');
    await expect(page).toHaveURL('/cart');

    // 验证产品在购物车中
    await expect(page.locator('.cart-item')).toHaveCount(1);

    // 进入结账
    await page.click('button:has-text("Checkout")');
    await expect(page).toHaveURL('/checkout');

    // 填写配送信息
    await page.fill('[name="address"]', '123 Main St');
    await page.fill('[name="city"]', 'New York');
    await page.fill('[name="zipCode"]', '10001');

    // 填写支付信息
    await page.fill('[name="cardNumber"]', '4242424242424242');
    await page.fill('[name="expiry"]', '12/25');
    await page.fill('[name="cvv"]', '123');

    // 提交订单
    await page.click('button:has-text("Place Order")');

    // 验证成功页面
    await expect(page).toHaveURL('/order-confirmation');
    await expect(page.locator('text=Order Confirmed')).toBeVisible();
  });
});

使用 Fixtures

typescript
// e2e/fixtures/auth.ts
import { test as base } from '@playwright/test';

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // 自动登录
    await page.goto('/login');
    await page.fill('[name="email"]', 'user@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');
    
    await use(page);
  },
});

export { expect } from '@playwright/test';
typescript
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures/auth';

test('user can view dashboard', async ({ authenticatedPage }) => {
  await expect(authenticatedPage).toHaveURL('/dashboard');
  await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
});

4.3 视觉回归测试

typescript
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');
  
  // 截图对比
  await expect(page).toHaveScreenshot('homepage.png');
});

test('responsive layout', async ({ page }) => {
  await page.goto('/');
  
  // 桌面视图
  await page.setViewportSize({ width: 1920, height: 1080 });
  await expect(page).toHaveScreenshot('desktop-view.png');
  
  // 平板视图
  await page.setViewportSize({ width: 768, height: 1024 });
  await expect(page).toHaveScreenshot('tablet-view.png');
  
  // 移动视图
  await page.setViewportSize({ width: 375, height: 667 });
  await expect(page).toHaveScreenshot('mobile-view.png');
});

5. CI/CD 集成

5.1 GitHub Actions 配置

yaml
# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      
      - run: npm ci
      
      - name: Run unit tests
        run: npm run test:unit -- --coverage
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json
  
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      
      - run: npm ci
      
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
  
  e2e-tests:
    name: E2E Tests
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'
      
      - run: npm ci
      
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

5.2 并行测试

yaml
# .github/workflows/parallel-tests.yml
name: Parallel Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      
      - run: npm ci
      - run: npx playwright install --with-deps
      
      - name: Run tests
        run: npx playwright test --shard=${{ matrix.shard }}/4
      
      - name: Upload blob report
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: blob-report-${{ matrix.shard }}
          path: blob-report
  
  merge-reports:
    if: always()
    needs: [test]
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      
      - name: Download reports
        uses: actions/download-artifact@v3
        with:
          path: all-blob-reports
      
      - name: Merge reports
        run: npx playwright merge-reports --reporter html ./all-blob-reports
      
      - name: Upload HTML report
        uses: actions/upload-artifact@v3
        with:
          name: html-report
          path: playwright-report

6. 测试覆盖率

6.1 配置覆盖率报告

javascript
// jest.config.js
module.exports = {
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    },
    './src/components/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90
    }
  },
  coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
};

6.2 查看覆盖率报告

bash
# 生成覆盖率报告
npm run test -- --coverage

# 生成 HTML 报告
npm run test -- --coverage --coverageReporters=html

# 打开报告
open coverage/lcov-report/index.html

6.3 集成到 CI

yaml
# .github/workflows/coverage.yml
name: Coverage

on: [push, pull_request]

jobs:
  coverage:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      
      - run: npm ci
      
      - name: Run tests with coverage
        run: npm run test -- --coverage
      
      - name: Upload to Codecov
        uses: codecov/codecov-action@v3
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/coverage-final.json
          flags: unittests
          name: codecov-umbrella
      
      - name: Comment PR with coverage
        if: github.event_name == 'pull_request'
        uses: romeovs/lcov-reporter-action@v0.3.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          lcov-file: ./coverage/lcov.info

7. 性能测试

7.1 使用 Lighthouse CI

javascript
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run build && npm run serve',
      url: ['http://localhost:3000'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'categories:accessibility': ['error', { minScore: 0.9 }],
        'categories:best-practices': ['error', { minScore: 0.9 }],
        'categories:seo': ['error', { minScore: 0.9 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};
yaml
# .github/workflows/lighthouse.yml
name: Lighthouse CI

on: [push]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      
      - run: npm ci
      
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun

7.2 性能监控测试

typescript
// performance.test.ts
import { test, expect } from '@playwright/test';

test('page load performance', async ({ page }) => {
  const startTime = Date.now();
  
  await page.goto('/');
  
  const loadTime = Date.now() - startTime;
  expect(loadTime).toBeLessThan(3000); // 3秒内加载完成
});

test('measure Core Web Vitals', async ({ page }) => {
  await page.goto('/');
  
  const metrics = await page.evaluate(() => {
    return new Promise((resolve) => {
      new PerformanceObserver((list) => {
        const entries = list.getEntries();
        const lcp = entries.find(entry => entry.entryType === 'largest-contentful-paint');
        const fid = entries.find(entry => entry.entryType === 'first-input');
        const cls = entries.find(entry => entry.entryType === 'layout-shift');
        
        resolve({ lcp, fid, cls });
      }).observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] });
    });
  });
  
  expect(metrics.lcp).toBeDefined();
});

8. 测试最佳实践

8.1 测试组织

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   └── Button.stories.tsx
│   └── Form/
│       ├── Form.tsx
│       └── Form.test.tsx
├── hooks/
│   ├── useAuth.ts
│   └── useAuth.test.ts
├── utils/
│   ├── formatters.ts
│   └── formatters.test.ts
└── __tests__/
    ├── integration/
    └── e2e/

8.2 命名约定

typescript
// 描述性的测试名称
describe('UserForm', () => {
  describe('when user is logged in', () => {
    it('should display user information', () => {
      // ...
    });

    it('should allow editing profile', () => {
      // ...
    });
  });

  describe('when user is not logged in', () => {
    it('should redirect to login page', () => {
      // ...
    });
  });
});

8.3 AAA 模式

typescript
test('user can add item to cart', async () => {
  // Arrange(准备)
  const product = { id: '1', name: 'Product 1', price: 100 };
  const { result } = renderHook(() => useCart());

  // Act(执行)
  act(() => {
    result.current.addItem(product);
  });

  // Assert(断言)
  expect(result.current.items).toHaveLength(1);
  expect(result.current.items[0]).toEqual(product);
  expect(result.current.total).toBe(100);
});

8.4 避免常见陷阱

避免测试实现细节

typescript
// ❌ 不好 - 测试实现细节
it('calls useState with initial value', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(useState).toHaveBeenCalledWith(10);
});

// ✅ 好 - 测试行为
it('initializes counter with value', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});

避免测试过度耦合

typescript
// ❌ 不好 - 依赖 DOM 结构
it('renders user name', () => {
  render(<UserCard user={mockUser} />);
  expect(screen.getByTestId('user-card').children[0].textContent).toBe('John Doe');
});

// ✅ 好 - 使用语义查询
it('renders user name', () => {
  render(<UserCard user={mockUser} />);
  expect(screen.getByText('John Doe')).toBeInTheDocument();
});

正确处理异步

typescript
// ❌ 不好 - 不等待异步操作
it('loads user data', () => {
  render(<UserProfile userId="123" />);
  expect(screen.getByText('John Doe')).toBeInTheDocument();
});

// ✅ 好 - 使用 waitFor
it('loads user data', async () => {
  render(<UserProfile userId="123" />);
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

9. 测试策略总结

9.1 测试优先级

  1. 高优先级

    • 核心业务逻辑
    • 用户关键路径
    • 安全相关功能
    • 支付和交易流程
  2. 中优先级

    • 常用功能
    • 数据验证
    • 错误处理
    • API 集成
  3. 低优先级

    • UI 样式细节
    • 静态内容
    • 次要功能

9.2 测试覆盖率目标

核心模块:90%+ 覆盖率
工具函数:80%+ 覆盖率
UI 组件:70%+ 覆盖率
整体项目:80%+ 覆盖率

9.3 持续改进

  1. 定期审查测试:移除过时的测试
  2. 重构测试代码:保持测试代码质量
  3. 监控测试性能:优化慢速测试
  4. 更新测试工具:使用最新的测试库
  5. 团队培训:提升测试技能

通过建立完善的自动化测试流程,可以有效保障代码质量,提高开发效率,降低线上故障率。