Python单元测试案例如何编写测试用例

wen python案例 33

Python单元测试案例如何编写测试用例(附实战代码)

目录导读


为什么要写单元测试?——三个不得不面对的现实

很多Python开发者都会问:“我的代码能跑,为什么还要花时间写测试?”

Python单元测试案例如何编写测试用例

真实案例拆解:假设你写了一个电商折扣计算函数,上线后产品经理突然说:“满100减20,但如果是VIP用户要打9折,且两种优惠不能叠加。”如果没有任何测试,你改完代码,很可能就忘记了检查边界情况——刚好100元且是VIP”时,折扣到底怎么算?错误一旦上线,可能直接导致公司损失真实订单。

三大核心价值:

  • 早期发现bug:修改代码后,一条pytest命令就能告诉你哪里出了问题,而不是等QA手动点100次页面。
  • 重构的安全网:没有测试的代码重构等于徒手拆炸弹;有测试,你可以大胆调整内部逻辑。
  • 文档即代码:测试用例清晰地说明了“这个函数在什么输入下应该返回什么结果”,比任何注释都准确。

单元测试基础:unittest与pytest的取舍

Python官方标准库自带unittest,但社区更推荐pytest,为什么?

对比维度 unittest pytest
语法复杂度 需要类和方法,self.assertEqual 较为啰嗦 纯函数式 assert,代码量减少50%
插件生态 较有限 强大(如pytest-mock、pytest-cov)
参数化测试 需额外写subTest @pytest.mark.parametrize 一行搞定
执行速度 中等 更快且支持并行执行

本文所有案例均使用pytest,因为它更贴近现代Python开发习惯,且符合Google和Meta的内部测试标准。


案例一:测试一个数学工具函数(入门级)

假设我们有这样一个函数(calculator.py):

def calculate_discount(price: float, is_vip: bool) -> float:
    if not isinstance(price, (int, float)):
        raise TypeError("price must be numeric")
    base_discount = 0.9 if price >= 100 else 1.0
    vip_discount = 0.95 if is_vip else 1.0
    return round(price * base_discount * vip_discount, 2)

如何测试它?直接看测试用例(test_calculator.py):

import pytest
from calculator import calculate_discount
class TestCalculateDiscount:
    @pytest.mark.parametrize("price, vip, expected", [
        (100, False, 90.0),   # 满100打9折
        (100, True, 85.5),    # 满100+VIP:0.9*0.95=0.855
        (50, False, 50.0),    # 不满100,不打折
        (50, True, 47.5),     # 仅VIP折扣
        (0, False, 0.0),      # 边界:0元
        ("abc", False, None), # 异常输入
    ])
    def test_discount(self, price, vip, expected):
        if price == "abc":
            with pytest.raises(TypeError):
                calculate_discount(price, vip)
        else:
            assert calculate_discount(price, vip) == expected

关键点:我们用parametrize覆盖了正常值、边界值、异常值三种情况,这比手动写6个测试函数效率高得多。


案例二:测试一个带外部依赖的API调用(中级)

真实项目中,函数经常调用外部服务(如支付网关、天气API),直接测试会依赖网络,导致测试不稳定,解决方案是Mock

目标函数(order_service.py):

import requests
def check_payment(order_id: str) -> bool:
    response = requests.get(f"https://api.payment.com/v1/orders/{order_id}/status")
    data = response.json()
    return data.get("paid", False)

测试用例(test_order.py):

import pytest
from unittest.mock import patch
from order_service import check_payment
class TestCheckPayment:
    @patch("order_service.requests.get")
    def test_payment_success(self, mock_get):
        # 模拟成功返回
        mock_get.return_value.json.return_value = {"paid": True}
        assert check_payment("ORD-123") == True
    @patch("order_service.requests.get")
    def test_payment_failed(self, mock_get):
        mock_get.return_value.json.return_value = {"paid": False}
        assert check_payment("ORD-456") == False
    @patch("order_service.requests.get")
    def test_network_error(self, mock_get):
        mock_get.side_effect = requests.ConnectionError("网络不可达")
        with pytest.raises(requests.ConnectionError):
            check_payment("ORD-789")

为什么这样做?

  • 不需要真实API密钥或网络。
  • 测试速度从秒级降到毫秒级。
  • 可以模拟各种网络错误,这在真实环境中很难触发。

案例三:测试一个数据库操作的真实场景(高级)

测试数据库函数时,最忌讳污染生产数据库,推荐使用SQLite内存数据库pytest fixtures

示例函数(user_repo.py):

def create_user(conn, name: str, age: int) -> int:
    cursor = conn.cursor()
    cursor.execute("INSERT INTO users (name, age) VALUES (?, ?)", (name, age))
    conn.commit()
    return cursor.lastrowid

测试用例(test_user.py):

import pytest
import sqlite3
from user_repo import create_user
@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")  # 内存数据库
    conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
    yield conn
    conn.close()
class TestCreateUser:
    def test_create_normal_user(self, db_connection):
        user_id = create_user(db_connection, "Alice", 30)
        assert user_id == 1  # 第一个用户ID为1
    def test_duplicate_name(self, db_connection):
        create_user(db_connection, "Bob", 25)
        # 假设数据库有唯一约束,测试异常
        with pytest.raises(sqlite3.IntegrityError):
            create_user(db_connection, "Bob", 30)
    def test_invalid_age_type(self, db_connection):
        with pytest.raises(TypeError):
            create_user(db_connection, "Charlie", "twenty")

核心技巧

  • fixture 每次测试前自动创建新数据库,测试后自动销毁,互不干扰。
  • 使用memory:避免磁盘I/O,测试更快。

常见问题与避坑指南(Q&A)

Q1:测试覆盖率一定要100%吗?

A:不一定,建议优先覆盖核心业务逻辑和边界条件,目标是80%以上,而非100%,盲目的100%覆盖率可能导致测试只测不重要的代码行。

Q2:如何测试私有方法或内部函数?

A:Python没有真正的私有,如果函数名以开头,可以直接在测试模块中调用,但更好的做法是:只测试外部公共接口,内部逻辑会通过公共方法被间接测试到。

Q3:pytest和unittest能混用吗?

A:可以,pytest能自动发现并运行unittest.TestCase的子类,但建议统一使用pytest以避免风格混乱。

Q4:测试文件应该放在哪里?

A:推荐两种模式:

  • 紧耦合:每个模块同级放test_xxx.py(适合小项目)
  • 分离目录:统一放在tests/目录下,内部结构镜像源码(适大中型项目)

Q5:如何避免测试之间的依赖?

A:坚持独立测试原则

  1. 每个测试函数不依赖其他测试的执行顺序。
  2. 使用fixture清理和重建数据。
  3. 避免全局状态(如全局变量、单例模式)。

好的测试用例是项目的一半质量保证

你不需要写“完美”的测试,只需要写“有用”的测试,从最简单的parametrize开始,逐步引入mock和fixture,最终你的测试用例会成为团队最可靠的文档和bug防护网。

小建议:先在当前项目里挑一个你改过的函数,写3个测试(正常/异常/边界),运行pytest -v看结果——你会在30秒内感受到“安全感”。

当测试变成习惯,重构将不再是噩梦,而是乐趣。

抱歉,评论功能暂时关闭!