如何对含副作用的函数(如数据库操作函数)开展单元测试?
针对数据库交互函数的单元测试优化方案
我非常理解你现在的困扰——仅仅mock数据库连接然后检查execute的调用情况,确实测不到核心的业务逻辑,很容易出现“测试通过但实际代码有问题”的情况。下面我给你两种可行的思路,既满足你偏好单元测试的需求,又能提升测试的有效性:
一、不依赖检查副作用调用参数的单元测试方法:用带状态的测试替身替代简单Mock
与其只验证cnx.execute的调用次数和参数,不如写一个模拟真实数据库行为的测试替身,它内部维护一个内存中的数据结构来模拟数据库表,执行SQL查询时实际操作这个内存结构。这样你可以直接测试业务操作的结果(比如账户余额变化),而不用纠结底层的调用细节。
举个具体的实现例子:
class MockDBConnection: def __init__(self): # 用字典模拟账户表,键是账户ID,值是余额 self.accounts = {"1": 1000, "2": 500} def execute(self, query, *args): # 根据不同的查询语句模拟对应的数据库操作 if query == 'query 1': # 模拟查询账户余额的逻辑 return self.accounts[args[0]] elif query == 'query 2': # 模拟转账更新余额的逻辑 target_account = args[0] amount = args[1] # 这里假设query1已经获取了转出账户的余额,所以直接操作 self.accounts["1"] -= amount self.accounts[target_account] += amount return True
然后你的单元测试就可以这么写:
def test_dao_transfer(): # 初始化带状态的模拟连接 mock_cnx = MockDBConnection() # 执行转账操作 result = dao_transfer(mock_cnx, "1", "2", 200) # 直接检查模拟数据库中的状态,验证业务结果 assert mock_cnx.accounts["1"] == 800 assert mock_cnx.accounts["2"] == 700 assert result is True
这种方式的优势在于:你测试的是业务逻辑的实际效果,而不是函数调用的细节。哪怕以后dao_transfer里的SQL语句调整了,只要业务逻辑不变,测试依然能通过,不会因为实现细节的变动而频繁修改测试用例。
二、更利于单元测试的纯IO处理方案:分离业务逻辑与IO操作
如果可以对现有代码做重构,最理想的方式是把核心业务逻辑和数据库IO操作完全分离——让业务逻辑成为不依赖任何外部资源的纯函数,IO操作单独抽成数据访问层。这样单元测试的难度会大幅降低,而且测试的稳定性也会提升。
具体拆分步骤如下:
- 提取纯业务逻辑函数:只处理计算和规则校验,不涉及任何数据库操作
def calculate_transfer_balances(balance_from, balance_to, transfer_amount): # 这里可以添加各种业务规则校验,比如余额是否充足、转账金额是否合法 if transfer_amount <= 0: raise ValueError("Transfer amount must be positive") if balance_from < transfer_amount: raise ValueError("Insufficient balance in source account") # 返回计算后的新余额 return balance_from - transfer_amount, balance_to + transfer_amount
- 拆分数据库IO操作:把和数据库交互的逻辑单独封装成函数
def get_account_balance(cnx, account_id): return cnx.execute('query 1', account_id) def update_account_balance(cnx, account_id, new_balance): return cnx.execute('query 2', account_id, new_balance)
- 重构原函数为协调者:只负责调用纯业务函数和数据访问函数,不包含核心逻辑
def dao_transfer(cnx, account_id1, account_id2, money): # 获取账户当前余额(IO操作) balance1 = get_account_balance(cnx, account_id1) balance2 = get_account_balance(cnx, account_id2) # 计算新余额(纯业务逻辑) new_balance1, new_balance2 = calculate_transfer_balances(balance1, balance2, money) # 更新数据库(IO操作) update_account_balance(cnx, account_id1, new_balance1) update_account_balance(cnx, account_id2, new_balance2) return True
这样拆分后,单元测试会变得非常清晰:
- 对于
calculate_transfer_balances,你可以轻松测试各种业务场景(余额充足、余额不足、金额为负等),完全不需要mock任何东西,因为它是纯函数,输入确定输出就确定。 - 对于
dao_transfer,你只需要mockget_account_balance和update_account_balance,验证它们被正确调用即可,不用关心底层的cnx.execute细节;或者结合前面的测试替身,测试完整流程的正确性。
这种架构的好处是:核心业务逻辑的测试独立且稳定,不会因为数据库连接、SQL语句的变化而失效;IO操作的测试可以单独做集成测试,和单元测试互不干扰。
内容的提问来源于stack exchange,提问作者amirouche
相关产品推荐
相关产品推荐

