跳到主要内容

单元测试|为工程质量保驾护航

信息
2024年8月11日 · ·

单元测试

单元测试是软件开发过程中确保代码质量和正确性的关键手段。它指的是对软件中的最小可测试单元(通常是函数或方法)进行验证,确保其行为符合预期。

基本概念

  • 单元测试:验证软件中最小单元(通常是函数或方法)的正确性,确保其独立性和可重复性。
  • 测试用例:描述一组输入和预期输出,用于验证软件单元的行为是否正确。
  • 测试覆盖率:衡量单元测试覆盖程序代码量的指标,分为行覆盖率、分支覆盖率和路径覆盖率等。

重要性

  • 早期发现 BUG:在开发过程中早期发现和修复缺陷,降低修复成本。
  • 代码重构保障:在进行代码重构时确保现有功能不被破坏。
  • 文档和示例:单元测试可以作为代码的实际使用示例,提供良好的文档支持。
  • 持续集成:单元测试是持续集成和持续交付的基础,确保每次代码变更不会引入新的问题。

常用技术

  1. 断言 ((\textit{Assertions})):用于验证代码执行结果是否符合预期。
  2. 测试框架
    • Python:unittest, pytest
    • Java:JUnit
    • JavaScript:Jest, Mocha
  3. Mock 测试:用于模拟和隔离外部依赖(如数据库、网络服务)。
    • Python:unittest.mock
    • Java:Mockito
    • JavaScript:Sinon.js

功能测试

功能代码

我们使用 Python 实现以下 UserServiceEmailServiceUserServiceWithEmail 三个类,来演示单元测试的基本概念。

# user_service.py
class UserService:
def __init__(self):
self.users = {} # 使用字典来存储用户信息

def add_user(self, user_id, name):
# 添加用户
if user_id in self.users:
raise ValueError("User ID already exists")
self.users[user_id] = name

def get_user(self, user_id):
# 查找用户
return self.users.get(user_id, None)

def delete_user(self, user_id):
# 删除用户
if user_id in self.users:
del self.users[user_id]
else:
raise ValueError("User ID does not exist")

# email_service.py
class EmailService:
def send_email(self, email_address, subject, content):
# 模拟发送电子邮件
print(f"Sending email to {email_address} with subject {subject}")
return True

# email_decorator.py
class UserServiceWithEmail(UserService):
def __init__(self, email_service):
super().__init__()
self.email_service = email_service

def add_user(self, user_id, name, email_address):
super().add_user(user_id, name)
self.email_service.send_email(email_address, "Welcome!", "Thank you for registering!")

单测代码

单元测试的目标是确保每个单独的函数或方法在与系统其他部分隔离的情况下工作正常,所以需要码验证了各种可能的操作场景。

接下我们分别对 UserServiceUserServiceWithEmail 进行测试。

# test_user_service.py
import unittest
from user_service.py import UserService
from email_service.py import EmailService
from email_decorator.py import UserServiceWithEmail
from unittest.mock import MagicMock

class TestUserService(unittest.TestCase):
def setUp(self):
# 说明:每个测试方法运行前都会执行 `setUp` 方法,初始化一个 `UserService` 实例,确保每个测试在相同的初始状态下进行。
self.service = UserService()

def test_add_user(self):
# 说明:测试添加用户功能。添加一个用户后,使用 `get_user` 方法检索该用户,并验证返回结果是否与添加的一致。
self.service.add_user(1, "John")
self.assertEqual(self.service.get_user(1), "John")

def test_add_user_existing_id(self):
# 说明:测试添加重复用户 ID 的情况。第一次添加用户成功后,尝试用相同的 ID 添加新用户,验证是否抛出 `ValueError` 异常。
self.service.add_user(1, "John")
with self.assertRaises(ValueError):
self.service.add_user(1, "Jane")

def test_get_user_non_existing(self):
# 说明:测试获取不存在的用户。调用 `get_user` 方法查询一个不存在的用户 ID,验证返回值是否为 `None`。
self.assertIsNone(self.service.get_user(999))

def test_delete_user(self):
# 说明:测试删除用户功能。首先添加一个用户,然后删除该用户,验证该用户是否已经被成功删除(查询时返回 `None`)。
self.service.add_user(1, "John")
self.service.delete_user(1)
self.assertIsNone(self.service.get_user(1))

def test_delete_user_non_existing(self):
# 说明:测试删除不存在的用户。尝试删除一个不存在的用户,验证是否抛出 `ValueError` 异常。
with self.assertRaises(ValueError):
self.service.delete_user(999)

class TestUserServiceWithEmail(unittest.TestCase):
def setUp(self):
self.email_service = EmailService()
# 说明:使用 `MagicMock` 对象 Mock(模拟) `send_email` 方法,使其在测试过程中返回 `True` 而不是实际发送电子邮件。初始化测试对象时,将 Mock 对象传入测试函数。
self.email_service.send_email = MagicMock(return_value=True)
self.service = UserServiceWithEmail(self.email_service)

def test_add_user_sends_email(self):
# 说明:测试添加用户并发送欢迎电子邮件的功能。添加用户后,验证 `send_email` 方法是否被调用一次,且参数正确。
user_id = 1
name = "John"
email_address = "john@example.com"

self.service.add_user(user_id, name, email_address)
self.email_service.send_email.assert_called_once_with(
email_address, "Welcome!", "Thank you for registering!"
)

def test_add_user_existing_id(self):
# 说明:测试在电子邮件版本的用户服务中添加重复用户 ID 的情况。预计会抛出 `ValueError`。
self.service.add_user(1, "John", "john@example.com")
with self.assertRaises(ValueError):
self.service.add_user(1, "Jane", "jane@example.com")

def test_delete_user(self):
# 说明:测试删除用户功能。同样地,先添加用户,再删除,最后验证该用户是否已经被成功删除。
self.service.add_user(1, "John", "john@example.com")
self.service.delete_user(1)
self.assertIsNone(self.service.get_user(1))

if __name__ == "__main__":
unittest.main()

Mock 测试

基本概念

Mock 测试是一种在软件测试中模拟对象或行为的技术。在测试某个单元(通常是一个函数或类)时,通过创建“虚拟对象”来模拟系统中的真实对象或依赖,替代它所依赖的其他组件,以便隔离待测试单元并专注于其自身的逻辑。通过使用 mock 对象,可以控制这些外部依赖的行为和状态,从而确保测试的确定性和一致性。

主要作用

  • 隔离测试:确保测试只关注目标组件,而不受其他组件或外部系统的影响。
  • 控制行为:可以设定模拟对象的返回值或行为,以测试不同场景。
  • 提高效率:避免与数据库、网络等真实服务的交互,提高测试速度。

常见场景

Mock 测试常用于单元测试,帮助开发者确保代码在预期条件下的表现。

  • 替代数据库调用,以避免对实际数据库的写操作。
  • 模拟网络请求,测试响应处理逻辑。
  • 模拟复杂的对象或系统行为,以简化测试环境。

常用的 mock 框架有 Python 的 unittest.mock 和 JavaScript 的 Jest Mocks。

Mock 对象

上文的 TestUserServiceWithEmail 中,MagicMock 对象模拟了 EmailServicesend_email,演示了 Mock 方法。此处我们再举一个例子,演示一下 Mock 对象

假设我们有一段依赖外部服务的代码:

# user_service.py
class UserService:
def __init__(self, api_client):
self.api_client = api_client

def get_user_data(self, user_id):
# 依赖 api_client 所调用的外部服务
response = self.api_client.get(f"/users/{user_id}")
if response.status_code == 200:
return response.json()
else:
raise ValueError("User not found")

我们可以使用 Mock 对象来模拟 api_client 的行为,以便测试 UserServiceget_user_data 方法:

# test_user_service.py
import unittest
from user_service import UserService
from unittest.mock import MagicMock

class TestUserService(unittest.TestCase):

def setUp(self):
# 使用 MagicMock 对象模拟 api_client
self.api_client = MagicMock()
self.user_service = UserService(self.api_client)

def test_get_user_data_success(self):
# 让 user_service.get_user_data 中,模拟的外部服务调用(api_client.get)直接返回成功(200)的结果
self.api_client.get.return_value.status_code = 200
self.api_client.get.return_value.json.return_value = {"id": 1, "name": "John"}

user_data = self.user_service.get_user_data(1)
self.assertEqual(user_data, {"id": 1, "name": "John"})

def test_get_user_data_failure(self):
# 让 user_service.get_user_data 中,模拟的外部服务调用(api_client.get)直接返回失败(404)的结果
self.api_client.get.return_value.status_code = 404

with self.assertRaises(ValueError):
self.user_service.get_user_data(1)

if __name__ == '__main__':
unittest.main()

使用 @patch

使用 @patch 装饰器可以进一步简化和清晰化测试逻辑。它允许我们在测试之前设置 Mock 对象,并在测试结束后自动恢复原始对象,减少手动处理的复杂性。

以下我们使用 @patch 装饰器重构上文中 TestUserServiceWithEmail 的测试代码:

# test_user_service.py
import unittest
from email_service import EmailService
from email_decorator import UserServiceWithEmail
from unittest.mock import patch

class TestUserServiceWithEmail(unittest.TestCase):
...

# 说明:使用 `@patch` 装饰器 Mock `send_email` 方法,使其在测试过程中返回 `True` 而不是实际发送电子邮件。初始化测试对象时,将 Mock 对象传入测试函数。
@patch('email_service.EmailService.send_email', return_value=True)
def setUp(self, mock_send_email):
self.mock_send_email = mock_send_email
self.service = UserServiceWithEmail(EmailService())

...

@patch 优势

使用 @patch 装饰器重构后的代码与之前手动 Mock 的代码相比,具有以下优点和特点:

  1. 简化了 Mock 对象的创建和恢复:

    • 之前:需要在 setUp 方法中手动创建 Mock 对象,并在测试方法中调用它。
    • 现在:通过 @patch 装饰器,可以直接在测试方法中获得 Mock 对象,同时测试结束后自动恢复原始对象,减少了手动操作和错误可能性。
    # 使用 MagicMock 手动 Mock
    self.email_service.send_email = MagicMock(return_value=True)

    # 使用 @patch 自动 Mock
    @patch('email_service.EmailService.send_email', return_value=True)
  2. 增强代码清晰度:

    • 之前:在进行 Mock 时,代码中需要额外维护 Mock 对象的状态。
    • 现在: @patch 装饰器使得测试代码更加简洁和直观,将 Mock 逻辑与实际测试逻辑解耦。
    # 使用 MagicMock 手动 Mock
    self.email_service.send_email.assert_called_once_with(...)

    # 使用 @patch 自动 Mock
    self.mock_send_email.assert_called_once_with(...)
  3. 集中管理 Mock:

    • 之前:需要在每个测试方法中手动处理 Mock 对象。
    • 现在:通过 @patch 装饰器,可以在类级别或方法级别集中管理 Mock 对象,使得 Mock 配置更容易理解和维护。

此外使用 @patch 还有以下特点:

  • 上下文管理@patch 的另一优势在于它能够管理 Mock 对象的生命周期,上下文管理器特性使得在大的测试类或测试文件中不会出现混乱的状态问题。
  • 测试隔离性:使用 @patch 时,各个测试方法之间的 Mock 状态是相互隔离的。这确保了一个测试方法中的 Mock 不会影响其他测试方法,增强了测试的可靠性。

使用 @patch 装饰器可以显著改善单元测试代码的简洁性和可维护性,使 Mock 对象的配置和恢复更为直观和自动化。它有助于提高测试代码的清晰度和隔离性,特别适用于复杂的测试场景和依赖多个外部服务的系统。掌握和应用这一技术,是提高代码质量和测试效率的重要工具。

Mock 测试的思考

Mock 测试使我们可以隔离单元测试,确保单元功能的正确性,不受外部依赖的影响,但也带来一些挑战和需要注意的事项:

  • 过度 Mock:过度依赖 Mock 可能导致测试体系与实际运行环境脱节。应当只 Mock 那些外部依赖,而不是系统内部逻辑。
  • 行为验证 vs 状态验证:Mock 更关注行为验证(验证某些调用是否发生),而非状态验证(验证某些状态值)。在实际测试中,二者需要平衡使用。
  • 保持一致性:Mock 对象的行为应当尽可能与真实对象一致,以避免测试结果和实际情况差异过大。

最佳实践

Arrange-Act-Assert 模式

  • Arrange:设置测试场景和准备所需的状态。
  • Act:调用待测试的方法或函数。
  • Assert:验证结果是否符合预期。

单测技巧

  1. 使用 setUptearDown:使用 setUptearDown 方法来准备和清理测试环境,减少重复代码。确保每个测试在一个确定的状态下开始。
  2. 使用 assertRaises:在异常处理中使用 assertRaises 方法来断言代码会在特定情况下抛出预期的异常。例如,尝试添加已经存在的用户 ID,需要抛出 ValueError
  3. 状态验证 vs 行为验证
    • 状态验证:通过检查方法调用后的状态确保系统行为正确。
    • 行为验证:用 mock 验证方法的调用行为,比如利用 assert_called_once_with 检查方法被正确调用。
  4. 使用 unittest.mock:模拟外部依赖的行为。隔离单元测试,确保它们独立于外部系统(如网络、数据库)。验证调用次数和参数,确保函数的行为符合预期。
  5. 使用 @patch:简化 Mock 对象的创建和管理,提供更清晰、更易读的测试代码。

Tips

  1. 独立性:每个单元测试应独立运行,确保不会互相影响。这有助于更容易发现问题来源,便于调试。
  2. 小范围测试:每个测试应专注于一项功能,保持测试的精细度。避免在同一方法中进行多个断言,保持测试的明确性。
  3. 清晰的命名和编码:测试方法应有意义的命名,指明测试内容和预期结果,以便描述他们的测试内容和预期行为。确保测试代码易于理解,结构清晰,注释明了。
  4. 模拟外部依赖:使用 @patchMagicMock 来隔离测试,保持测试专注于单元逻辑,不让外部因素(如数据库、网络请求等)影响结果。保障单测的可重复性,提高稳定性和速度。
  5. 全面覆盖:尽量编写覆盖各种可能情况的测试案例,包括边界条件和异常情况。以确保代码在各种情况下表现正确。确保代码健壮性。
  6. 持续集成:将单元测试集成到持续集成(CI)工作流中,确保每次代码变更后都能自动测试,避免引入新的缺陷。
  7. 及时更新:保持测试代码与生产代码同步更新,以避免测试数据的陈旧和不一致。保持测试代码清晰,易于理解和维护。
  8. 覆盖率工具:使用代码覆盖率工具(如 coverage.py)来确保测试覆盖了代码的各个部分,但也要注意覆盖率不是唯一的质量指标。

通过遵循这些最佳实践,可以确保单元测试的质量,提高代码的可靠性和可维护性。

结语

单元测试是确保代码质量和可靠性的关键手段之一。通过对代码的最小单元进行独立测试,开发者可以更早地发现和修复缺陷,同时在进行代码重构和变更时保持稳妥。Mock 测试可以有效地隔离外部依赖,使测试更加独立和可重复,但需要谨慎使用,以避免过度 Mock 导致的测试与实际场景脱节。从实际开发经验中,逐步积累完善单元测试技术和最佳实践,能显著提升开发效率和代码质量。