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 中单独运行
相关推荐
恋猫de小郭8 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
寻星探路13 小时前
【深度长文】万字攻克网络原理:从 HTTP 报文解构到 HTTPS 终极加密逻辑
java·开发语言·网络·python·http·ai·https
崔庆才丨静觅15 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606116 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了16 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅16 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
ValhallaCoder16 小时前
hot100-二叉树I
数据结构·python·算法·二叉树
崔庆才丨静觅16 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
猫头虎17 小时前
如何排查并解决项目启动时报错Error encountered while processing: java.io.IOException: closed 的问题
java·开发语言·jvm·spring boot·python·开源·maven
崔庆才丨静觅17 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端