Python Web 开发进阶实战:全链路测试体系 —— Pytest + Playwright + Vitest 构建高可靠交付流水线

第一章:为什么需要分层测试?

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 rulemain
    • ✔ Require status checks to pass before merging
    • ✔ Require Test Suite workflow

第七章:测试维护与最佳实践

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 中单独运行
相关推荐
皇族崛起2 小时前
【视觉多模态】基于视觉AI的人物轨迹生成方案
人工智能·python·计算机视觉·图文多模态·视觉多模态
HealthScience2 小时前
常见的微调的方式有哪些?(Lora...)
vscode·python
貂蝉空大2 小时前
vue-pdf-embed分页预览解决文字丢失问题
前端·vue.js·pdf
nimadan122 小时前
**免费有声书配音软件2025推荐,高拟真度AI配音与多场景
人工智能·python
满天星辰2 小时前
Typescript的infer到底怎么使用?
前端·typescript
ss2732 小时前
RuoYi-App 本地启动教程
前端·javascript·vue.js
Jolyne_2 小时前
useRef存在的潜在性能问题
前端
可触的未来,发芽的智生2 小时前
完全原生态思考:从零学习的本质探索→刻石头
javascript·人工智能·python·神经网络·程序人生
炫饭第一名2 小时前
Lottie-web 源码解析(一):从 JSON Schema 认识 Lottie 动画的本质📒
前端·javascript·css