单元测试:pytest 编写高覆盖率 Flask 测试

📂 所属阶段:第五阶段 — 高级进阶(性能与架构)
🔗 相关章节:Flask-Login 实战 · 环境搭建


每次改完代码心惊胆战不敢上线?怕哪里漏改了一个路由逻辑?

别慌!pytest + Flask 的组合能帮你快速搭建一套轻量但覆盖全面的测试套件,今天我们就一步步来写覆盖视图、登录验证和数据库的测试用例。


1. pytest 入门配置

1.1 一键安装依赖

除了核心的 pytest,再加个适配 Flask 的 pytest-flask,帮我们处理应用上下文这类麻烦事:

pip install pytest pytest-flask

1.2 项目根目录加 pytest.ini

这个配置文件能帮你统一 pytest 的行为,不用每次敲一堆参数:

# pytest.ini
[pytest]
testpaths = tests  # 指定测试文件所在的目录
python_files = test_*.py  # 只扫描 test_ 开头的文件
python_functions = test_*  # 只运行 test_ 开头的函数
addopts = -v --tb=short  # 默认输出详细测试名 + 精简报错堆栈

2. 测试前置处理神器:pytest Fixtures

如果每写一个测试用例都要「创建应用→建表→登录→测试→删表」,代码会重复到爆炸!

conftest.py 是 pytest 提供的全局测试配置文件,我们在这里定义可复用的 fixture(测试夹具),把这些前置/后置逻辑打包起来。

# conftest.py(必须放在项目根目录或 tests 目录下)
import pytest
from app import create_app
from app.extensions import db

@pytest.fixture
def app():
    """创建、初始化并销毁测试用应用"""
    # 用专门的测试配置(记得在 create_app 里区分 testing/prod/dev)
    app = create_app("testing")
    
    # 进入应用上下文才能操作数据库
    with app.app_context():
        db.create_all()  # 测试前建表
        yield app  # 把测试用应用传递给需要的测试函数
        db.drop_all()  # 测试后自动删表,避免污染下一次测试

@pytest.fixture
def client(app):
    """模拟浏览器请求的测试客户端"""
    return app.test_client()

@pytest.fixture
def authenticated_client(client, app):
    """快速获取一个已登录的测试客户端(简化版)"""
    # 注意:简化版假设 register/login 是正常的;复杂场景建议单独建 user fixture
    with app.app_context():
        client.post("/auth/register", data={
            "email": "authed_test@example.com",
            "password": "TestPass123!",
            "username": "authed_testuser"
        })
    client.post("/auth/login", data={
        "email": "authed_test@example.com",
        "password": "TestPass123!"
    })
    return client

3. 基础视图测试:从首页到登录注册

先从无状态、公开的页面开始写,比如首页、登录/注册页,这类测试最容易上手,也最稳。

# tests/test_auth.py
def test_index_page_loads(client):
    """✅ 验证首页能正常打开,包含核心文案"""
    response = client.get("/")
    # 1. 检查 HTTP 状态码(200=成功)
    assert response.status_code == 200
    # 2. 检查响应体里有没有项目的核心内容(注意 response.data 是 bytes 类型)
    assert b"道满博客" in response.data

def test_login_page_renders_form(client):
    """✅ 验证登录页加载并显示登录表单"""
    response = client.get("/auth/login")
    assert response.status_code == 200
    # 可以用更具体的表单字段检查,比如有没有邮箱输入框
    assert b'<input type="email"' in response.data or b"邮箱" in response.data

def test_login_success_with_valid_credentials(client, app):
    """✅ 验证有效邮箱密码能正常登录并跳转"""
    # 先单独注册一个测试用户(避免和 authenticated_client 冲突)
    client.post("/auth/register", data={
        "email": "alice@test.com",
        "password": "StrongPass!2024",
        "username": "alice_dev"
    })
    
    # 登录并跟随重定向
    response = client.post("/auth/login", data={
        "email": "alice@test.com",
        "password": "StrongPass!2024"
    }, follow_redirects=True)
    
    assert response.status_code == 200
    assert b"登录成功" in response.data

def test_login_rejects_wrong_password(client):
    """❌ 验证错误密码会返回对应提示"""
    # 不需要注册真实用户,直接测错误逻辑
    response = client.post("/auth/login", data={
        "email": "nonexistent@test.com",
        "password": "wrong123"
    })
    assert response.status_code == 200
    assert b"邮箱或密码错误" in response.data

def test_register_requires_valid_fields(client):
    """❌ 验证注册表单的基础校验(无效邮箱、太短密码等)"""
    response = client.post("/auth/register", data={
        "email": "not-a-real-email",  # 无效格式
        "password": "123",  # 太短(假设配置了至少8位)
        "username": "t"  # 太短(假设配置了至少2位)
    })
    assert response.status_code == 200
    # 随便检查一个错误提示就行,不用全列(全列太脆弱)
    assert b"邮箱格式不正确" in response.data or b"password" in response.data

4. 进阶测试:受保护路由的权限验证

Flask-Login 给路由加了 @login_required?那我们必须测试「未登录会不会被拦截,已登录能不能正常访问」:

# tests/test_articles.py
def test_create_article_redirects_to_login_when_not_authed(client):
    """⚠️ 验证未登录用户直接访问创建文章页会被重定向到登录"""
    response = client.get("/articles/create")
    # 302 是临时重定向的状态码
    assert response.status_code == 302
    # 检查重定向的目标地址是不是登录页(可以只检查开头)
    assert response.location.startswith("/auth/login")

def test_authed_user_can_create_article(authenticated_client):
    """✅ 验证已登录用户能正常创建文章并显示"""
    # 提交创建文章的表单并跟随重定向
    response = authenticated_client.post("/articles/create", data={
        "title": "我的第一篇测试博客",
        "content": "这是专门用来测试博客创建功能的内容~"
    }, follow_redirects=True)
    
    assert response.status_code == 200
    assert b"我的第一篇测试博客" in response.data

5. 提升安全感:代码覆盖率测试

写了一堆测试,到底覆盖了多少业务代码?用 pytest-cov 生成可视化报告!

5.1 安装与运行

pip install pytest-cov

# 1. 生成终端简约报告(标注未覆盖的代码行)
pytest --cov=app --cov-report=term-missing

# 2. 生成 HTML 可视化报告(推荐,能看到哪行没覆盖)
pytest --cov=app --cov-report=html
# 运行完打开 htmlcov/index.html 即可

5.2 覆盖率小目标

不用强求 100%,但核心业务(比如登录验证、文章发布)至少要 80%+,简单的工具函数可以直接 100%。


6. 速查 & 小结

📝 常用 pytest + Flask 测试语法

# 测试相关装饰器/属性
@pytest.fixture     # 定义可复用的测试前置/后置
client.get(url)     # 发送 GET 请求
client.post(url, data={...})  # 发送 POST 请求(form-data 格式)
response.status_code  # 获取响应状态码
response.data         # 获取响应体(bytes 类型,要用 b"" 匹配)
response.location     # 获取重定向的目标 URL
follow_redirects=True # 自动跟随重定向后的地址

💡 最佳实践

  1. 测试文件/函数命名要清晰:看名字就知道测的是「成功/失败/什么场景」
  2. 每个测试用例只测一件事:不要在一个 test_* 里同时测登录、注册、发布
  3. 数据库操作一定要用独立的测试环境:别碰开发/生产数据库!
  4. 尽量用 TDD(测试驱动开发):先写失败的测试,再写功能代码让它通过,最后重构

🔗 扩展阅读