第一章:为什么需要分层测试?
1.1 测试金字塔模型
[E2E 测试] ← 少量(5%)
/ \
[集成测试] [组件测试] ← 中等(15%)
| |
[单元测试] ------------------------ [单元测试] ← 大量(80%)
(后端) (前端)
| 层级 | 速度 | 稳定性 | 覆盖范围 | 适用场景 |
|---|---|---|---|---|
| 单元测试 | ⚡ 极快 | 🔒 高 | 单个函数/组件 | 核心算法、工具函数 |
| 集成测试 | 🕒 快 | 🔒 高 | 模块间交互 | API 路由、数据库操作 |
| E2E 测试 | 🐢 慢 | 🌪️ 中 | 用户完整流程 | 登录 → 操作 → 退出 |
原则:
- 优先编写单元测试(成本低、反馈快)
- 关键路径必须有 E2E 覆盖(防止回归)
第二章:后端测试 ------ Pytest 全面实践
2.1 安装依赖
pip install pytest pytest-cov factory-boy faker httpx
更新 requirements-dev.txt:
pytest==7.4.0
pytest-cov==4.1.0
factory-boy==3.3.0
faker==20.0.0
httpx==0.25.0 # 用于测试 API
2.2 项目结构
/backend
├── app/
│ ├── models/
│ ├── routes/
│ └── ...
├── tests/
│ ├── conftest.py ← 全局 fixture
│ ├── unit/ ← 单元测试
│ │ └── test_user_utils.py
│ └── integration/ ← 集成测试
│ ├── test_auth_api.py
│ └── test_user_api.py
2.3 配置测试环境(conftest.py)
# tests/conftest.py
import pytest
from app import create_app, db
from config import TestingConfig
@pytest.fixture(scope='session')
def app():
app = create_app(TestingConfig)
with app.app_context():
db.create_all()
yield app
db.drop_all()
@pytest.fixture(scope='function')
def client(app):
return app.test_client()
@pytest.fixture(scope='function')
def db_session(app):
with app.app_context():
db.session.begin_nested() # 支持回滚
yield db.session
db.session.rollback()
关键点:
- 使用
TestingConfig(独立数据库)- 每个测试后回滚事务,避免数据污染
2.4 单元测试示例:用户工具函数
# tests/unit/test_user_utils.py
from app.utils.user import generate_username
def test_generate_username():
name = generate_username("张三")
assert name.startswith("zhang_san_")
assert len(name) == 12 # zhang_san_XX
2.5 集成测试示例:认证 API
# tests/integration/test_auth_api.py
import json
from tests.factories import UserFactory
def test_login_success(client, db_session):
# 准备数据
password = "secure_password"
user = UserFactory(password=password)
db_session.add(user)
db_session.commit()
# 发送请求
response = client.post('/auth/login', data=json.dumps({
'username': user.username,
'password': password
}), content_type='application/json')
# 断言
assert response.status_code == 200
data = json.loads(response.data)
assert 'access_token' in data
assert data['user']['username'] == user.username
工厂模式(Factories)
# tests/factories.py
from factory import Sequence, LazyFunction
from factory.alchemy import SQLAlchemyModelFactory
from app.models import User, db
from faker import Faker
fake = Faker()
class UserFactory(SQLAlchemyModelFactory):
class Meta:
model = User
sqlalchemy_session = db.session
username = Sequence(lambda n: f"user{n}")
email = LazyFunction(lambda: fake.email())
password = "default_password" # 实际存储为哈希
优势:
- 避免硬编码测试数据
- 支持关联对象创建(如
UserFactory(profile=ProfileFactory()))
2.6 测试 Celery 任务
# tests/integration/test_tasks.py
from celery_worker import celery
from tasks.email import send_welcome_email
def test_send_welcome_email_task(mocker):
mock_send = mocker.patch('tasks.email.send_email')
# 在 eager 模式下执行(同步)
with celery.conf.override(task_always_eager=True):
send_welcome_email("test@example.com")
mock_send.assert_called_once_with(
to="test@example.com",
subject="欢迎加入我们!",
body=mocker.ANY
)
技巧:
- 使用
mocker(pytest-mock)模拟外部依赖task_always_eager=True让任务立即执行
第三章:前端测试 ------ Vitest + Vue Test Utils
3.1 安装依赖
npm install -D vitest @vue/test-utils jsdom happy-dom
更新 vite.config.ts:
// vite.config.ts
export default defineConfig({
// ...
test: {
environment: 'happy-dom', // 或 'jsdom'
coverage: {
provider: 'istanbul',
reporter: ['text', 'html', 'lcov']
}
}
})
3.2 项目结构
/frontend
├── src/
│ ├── components/
│ │ └── LoginForm.vue
│ └── stores/
│ └── auth.ts
├── tests/
│ ├── unit/
│ │ ├── components/
│ │ │ └── LoginForm.spec.ts
│ │ └── stores/
│ │ └── auth.spec.ts
│ └── __mocks__/
│ └── axios.ts ← Mock API
3.3 Mock Axios
// tests/__mocks__/axios.ts
const axios = {
create: () => axios,
get: vi.fn(),
post: vi.fn(),
interceptors: {
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() }
}
}
export default axios
在 vitest.config.ts 中启用:
// vitest.config.ts
export default defineConfig({
test: {
alias: [{ find: /^axios$/, replacement: './tests/__mocks__/axios.ts' }]
}
})
3.4 组件测试:LoginForm.vue
// tests/unit/components/LoginForm.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LoginForm from '@/components/LoginForm.vue'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('axios') // 使用 mock
describe('LoginForm', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('calls login on submit', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[type="text"]').setValue('testuser')
await wrapper.find('input[type="password"]').setValue('123456')
await wrapper.find('form').trigger('submit.prevent')
// 验证 Pinia action 被调用(或通过 mock axios)
expect(wrapper.emitted()).toHaveProperty('login')
})
})
3.5 Store 测试:Auth Store
// tests/unit/stores/auth.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
vi.mock('axios')
describe('AuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
;(axios.post as vi.Mock).mockResolvedValue({
data: {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
user: { id: 1, username: 'test' }
}
})
})
it('logs in successfully', async () => {
const store = useAuthStore()
await store.login({ username: 'test', password: '123' })
expect(store.isAuthenticated).toBe(true)
expect(store.currentUsername).toBe('test')
expect(localStorage.getItem('access_token')).toBe('mock-access-token')
})
})
第四章:端到端测试 ------ Playwright 真实用户仿真
4.1 为什么选 Playwright?
| 工具 | 优势 |
|---|---|
| Selenium | 成熟但慢,API 复杂 |
| Cypress | 仅限 Chrome,收费功能多 |
| Playwright | 跨浏览器(Chromium/Firefox/WebKit)、速度快、自动等待、视频录制 |
4.2 安装与初始化
npm init playwright@latest
选择:
- ✔ TypeScript
- ✔ Jest(但我们用原生 Playwright Test)
- ✔ 安装 browsers
生成 playwright.config.ts。
4.3 项目结构
/e2e
├── tests/
│ ├── auth.spec.ts ← 登录/注册流程
│ └── dashboard.spec.ts ← 主界面操作
├── pages/ ← Page Object 模式
│ ├── LoginPage.ts
│ └── DashboardPage.ts
└── .env.local ← 测试账号凭证
4.4 Page Object 模式
// e2e/pages/LoginPage.ts
import { Page } from '@playwright/test'
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(username: string, password: string) {
await this.page.fill('input[name="username"]', username)
await this.page.fill('input[name="password"]', password)
await this.page.click('button:has-text("登录")')
await this.page.waitForURL('/') // 等待跳转
}
}
4.5 E2E 测试用例:用户登录
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
test('should login successfully', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('testuser', 'secure_password')
// 验证登录后状态
await expect(page.getByText('仪表盘')).toBeVisible()
await expect(page).toHaveURL('/')
})
4.6 测试 MFA(多因素认证)
// e2e/tests/mfa.spec.ts
test('should complete MFA flow', async ({ page }) => {
// 1. 正常登录
await loginPage.login('mfa_user', 'password')
// 2. 进入 MFA 页面
await expect(page.getByText('请输入验证码')).toBeVisible()
// 3. 生成 TOTP(需共享密钥)
const token = generateTOTP(process.env.MFA_SECRET!)
await page.fill('#mfa-code', token)
await page.click('button:has-text("验证")')
// 4. 进入主界面
await expect(page.getByText('欢迎')).toBeVisible()
})
注意:MFA 测试需在安全环境下进行(如隔离的测试账号)。
第五章:测试覆盖率与质量门禁
5.1 后端覆盖率(pytest-cov)
运行并生成报告:
pytest --cov=app --cov-report=html --cov-report=term-missing
查看 htmlcov/index.html。
质量门禁(要求 ≥80%):
pytest --cov=app --cov-fail-under=80
5.2 前端覆盖率(Vitest)
npm run test:unit -- --coverage
报告位于 coverage/ 目录。
5.3 E2E 不计算覆盖率,但需覆盖核心路径
- 用户注册 → 登录 → 操作 → 退出
- 错误处理(如密码错误、网络失败)
第六章:CI/CD 集成 ------ GitHub Actions
6.1 工作流文件
新建 .github/workflows/test.yml:
name: Test Suite
on: [push, pull_request]
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpass
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest --cov=app --cov-fail-under=80
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npm run build
- run: npm run test:unit -- --coverage
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Start Backend (in background)
run: |
pip install -r requirements.txt
nohup python app.py > backend.log 2>&1 &
sleep 10 # 等待启动
- name: Run E2E Tests
run: npx playwright test
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
retention-days: 30
关键点:
- 并行运行三类测试
- E2E 测试启动真实后端服务
- 失败时上传 Playwright 报告(含截图/视频)
6.2 保护主分支
在 GitHub 仓库设置中:
- Branch protection rule →
main- ✔ Require status checks to pass before merging
- ✔ Require
Test Suiteworkflow
第七章:测试维护与最佳实践
7.1 避免脆弱测试
-
不要 依赖具体 CSS 类名(用
data-testid)<!-- 好 --> <button data-testid="login-btn">登录</button> <!-- 坏 --> <button class="btn btn-primary _x2j9a">登录</button> -
使用语义化选择器
// Playwright await page.getByRole('button', { name: '登录' }).click()
7.2 测试数据隔离
- 每个测试用唯一用户名(如
testuser_${Date.now()}) - 测试后清理数据(Pytest 回滚 / Playwright 删除账号)
7.3 速度优化
| 技巧 | 效果 |
|---|---|
| 并行测试 | Pytest -n auto,Vitest --threads |
| 缓存依赖 | GitHub Actions actions/cache |
| 跳过慢测试 | @pytest.mark.slow,CI 中单独运行 |