目录
慢服务快速测试:为何应选用已验证的模拟对象
假设你的代码需要与某些缓慢或昂贵的外部服务交互 —— 比如 Twitter API。到了编写测试时,你将面临一个两难的选择:
- 一方面,直接与真实 API 交互会使测试变得缓慢、难以执行且不稳定。
- 另一方面,如果使用虚假或模拟的测试替身,你如何确保代码在现实世界中真正有效?毕竟,假的就是假的。你的代码并未与真实对象进行交互。
您追求的是正确性与速度:确信测试通过后代码能在生产环境中运行,同时拥有一个快速且稳健的测试套件。
解决方案是一种特殊的测试替身:经过验证的假对象。与常规测试替身不同,后者是用任意对象替换真实对象,而经过验证的假对象则需证明其行为与真实对象完全一致。
在本文中,我将探讨:
- 测试替身的简要介绍。
- 如何编写一个已验证的假对象,及其独特之处。
- 已验证假对象的局限性及其适用场景。
使用测试替身进行测试
在探讨验证假对象之前,我们先简要了解为何测试替身如此有用。假设你有一个想要测试的 MessageService 类,它依赖于 TwitterClient。
MessageService → TwitterClient
你可以这样编写测试:
Tests → MessageService → TwitterClient
但这样一来,你的测试最终会与真实的 Twitter API 进行交互。因此,你转而创建一个 FakeTwitterClient,并在测试中使用它:
Tests → MessageService → FakeTwitterClient
现在,您的测试无需与 Twitter API 交互即可验证 MessageService 的功能。
唯一的问题是:你在假设 TwitterClient 和 FakeTwitterClient 的行为相同,却没有任何证据支持这一点。确实,像 Python 中的 mock 库这样的工具让这一点变得稍微容易些,但充其量它们只能确保你匹配了函数签名。
它们并不验证任何关于行为的内容。
从模拟对象到验证过的模拟对象
为了让 FakeTwitterClient 成为一个可信的验证假体,你需要编写一套额外的测试,这套测试需同时针对 TwitterClient 和 FakeTwitterClient 运行。这些测试旨在验证你期望两种实现都应遵循的某种契约或接口。
对两种实现运行相同的测试,确保两个版本的行为一致:
TwitterContractTests → TwitterClient
TwitterContractTests → FakeTwitterClient
一个详细的示例
假设 TwitterClient 是这样的:
class TwitterClient(object):
"""A client for the Twitter API."""
def tweet(self, message):
"""Tweet a message for the user."""
# ... implementation ...
def list_tweets(self):
"""Return a list of the user's tweets."""
# ... implementation ...
该客户端提供了一种行为保证,某种形式的契约:如果一条消息被发布,它将会出现在推文列表中。你可以将这个契约编码成一个测试:
def test_tweet_listed(client):
"A tweeted messages shows up in the list of messages."
message = generate_random_message()
client.tweet(message)
assert message in client.list_tweets()
您希望 FakeTwitterClient 能提供相同的保证,以便您可以放心地将其作为 TwitterClient 的直接替代品使用。因此,您实现了一个 FakeTwitterClient,它遵循这一契约:
class FakeTwitterClient(object):
"""A fake client."""
def __init__(self):
self.messages = []
def tweet(self, message):
"""Tweet a message for the user."""
self.messages.append(message)
def list_tweets(self):
"""Return a list of the user's tweets."""
return self.messages
关键点在于:你需要运行‘test_tweet_listed‘两次,一次针对真实客户端,另一次针对模拟客户端,以确保两者行为一致。
针对 FakeTwitterClient 运行的 test_tweet_listed 版本,仅是一个快速的内存测试,因此任何人都可以在任何地方运行它。
针对真实客户端运行的版本需采用真实的 Twitter 登录(可能为某种测试账户)。这意味着其运行速度会较慢,且不适宜开发者频繁使用。因此,TwitterClient 的合约验证测试可配置为仅在 CI 服务器上每晚执行一次,或在相关代码变更时运行。
一旦你让 test_tweet_listed 针对两个类运行,你就有了 TwitterClient 和 FakeTwitterClient 行为一致的保证。这意味着你可以确信,即使 MessageService 的测试依赖于 FakeTwitterClient 而非真实服务,这些测试也是有效的。
已验证假象的局限
TwitterClient 在某些情况下可能会产生错误,例如,若消息过长,则可能抛出 InvalidMessageException。此类错误可轻松在 FakeTwitterClient 中模拟,并通过合约测试进行验证。
然而,某些错误无法通过合约测试来验证。例如,如果 Twitter API 服务器存在漏洞,它可能会返回一个错误,导致 TwitterClient 抛出异常。或者,网络错误可能导致 socket.error 异常的发生。
这些错误的问题在于,它们难以或无法在您的合约验证测试中可靠地触发。如果无法在合约验证测试中触发某个边缘情况,那么针对该边缘情况,您就没有一个经过验证的模拟方案。
若想在 MessageService 测试中触发这些错误,最佳做法是采用常规的测试替身策略,使用未经验证的假对象或模拟对象。
何时应使用已验证的模拟?
经过验证的仿制品能更确保你的测试确实在检验你所设想的内容,因为测试替身已被证实能像真实对象一样运作。然而,这需要投入更多工作,因为你必须额外创建一套契约验证测试。
这意味着在以下条件适用时,经过验证的模拟是有意义的:
- **你想要模拟的 API 设置起来既慢又贵。**既然如此,何不直接使用真实的接口呢?
- **你想要模拟的 API 在测试代码中频繁使用。**如果它仅被使用一次,或许直接使用真实接口更为合适。
- **未捕获的 bug 代价高昂。**如果 bug 的代价不高,可能不值得额外投入精力编写额外的契约测试。
下次当你准备编写一个模拟对象时,不妨思考一下这里是否适合使用经过验证的模拟。只需稍加努力,你就能得到既快速又准确的测试。