为单元测试启动 Web 服务器绝对不是一个好习惯。单元测试应该简单且隔离,这意味着它们应该避免执行例如 IO 操作。
如果你想写的是真正的单元测试,那么你应该制作自己的测试输入,并查看mock objects。 Python 作为一种动态语言,模拟和猴子路径是编写单元测试的简单而强大的工具。特别是看看优秀的Mock module。
简单的单元测试
因此,如果我们查看您的 CssTests 示例,您正在尝试测试 css.getCssUriList 是否能够提取您提供的一段 HTML 中引用的所有 CSS 样式表。您在这个特定的单元测试中所做的并不是测试您可以发送请求并从网站获得响应,对吗?您只是想确保给定一些 HTML,您的函数返回正确的 CSS URL 列表。因此,在这个测试中,您显然不需要与真正的 HTTP 服务器通信。
我会做如下的事情:
import unittest
class CssListTestCase(unittest.TestCase):
def setUp(self):
self.css = core.Css()
def test_css_list_should_return_css_url_list_from_html(self):
# Setup your test
sample_html = """
<html>
<head>
<title>Some web page</title>
<link rel='stylesheet' type='text/css' media='screen'
href='http://example.com/styles/full_url_style.css' />
<link rel='stylesheet' type='text/css' media='screen'
href='/styles/relative_url_style.css' />
</head>
<body><div>This is a div</div></body>
</html>
"""
base_url = "http://example.com/"
# Exercise your System Under Test (SUT)
css_urls = self.css.get_css_uri_list(sample_html, base_url)
# Verify the output
expected_urls = [
"http://example.com/styles/full_url_style.css",
"http://example.com/styles/relative_url_style.css"
]
self.assertListEqual(expected_urls, css_urls)
依赖注入模拟
现在,不太明显的事情是对core.HttpRequests 类的getContent() 方法进行单元测试。我想您正在使用 HTTP 库,而不是在 TCP 套接字上发出自己的请求。
为了使您的测试保持在 unit 级别,您不希望通过网络发送任何内容。您可以做些什么来避免这种情况,即进行测试以确保您正确使用 HTTP 库。这不是测试代码的行为,而是测试它与周围其他对象的交互方式。
这样做的一种方法是明确对该库的依赖:我们可以向HttpRequests.__init__ 添加一个参数,以将一个库的HTTP 客户端实例传递给它。假设我使用了一个 HTTP 库,它提供了一个 HttpClient 对象,我们可以在该对象上调用 get()。你可以这样做:
class HttpRequests(object):
def __init__(self, http_client):
self.http_client = http_client
def get_content(self, url):
# You could imagine doing more complicated stuff here, like checking the
# response code, or wrapping your library exceptions or whatever
return self.http_client.get(url)
我们已经明确了依赖关系,现在需要HttpRequests 的调用者来满足要求:这称为依赖注入 (DI)。
DI 在两件事上非常有用:
- 它避免了您的代码秘密依赖某个对象存在于某处的意外情况
- 它允许编写测试,根据测试的目标注入不同类型的对象
在这里,我们可以使用我们将提供给core.HttpRequests 的模拟对象,并且它会在不知不觉中使用它,就好像它是真正的库一样。之后,我们可以测试交互是否按预期进行。
import core
class HttpRequestsTestCase(unittest.TestCase):
def test_get_content_should_use_get_properly(self):
# Setup
url = "http://example.com"
# We create an object that is not a real HttpClient but that will have
# the same interface (see the `spec` argument). This mock object will
# also have some nice methods and attributes to help us test how it was used.
mock_http_client = Mock(spec=somehttplib.HttpClient)
# Exercise
http_requests = core.HttpRequests(mock_http_client)
content = http_requests.get_content(url)
# Here, the `http_client` attribute of `http_requests` is the mock object we
# have passed it, so the method that is called is `mock.get()`, and the call
# stops in the mock framework, without a real HTTP request being sent.
# Verify
# We expect our get_content method to have called our http library.
# Let's check!
mock_http_client.get.assert_called_with(url)
# We can find out what our mock object has returned when get() was
# called on it
expected_content = mock_http_client.get.return_value
# Since our get_content returns the same result without modification,
# we should have received it
self.assertEqual(content, expected_content)
我们现在已经测试了我们的 get_content 方法与我们的 HTTP 库正确交互。我们已经定义了HttpRequests 对象的边界并对其进行了测试,这就是我们应该在单元测试级别进行的程度。该请求现在已掌握在该库的手中,我们的单元测试套件当然不会负责测试该库是否按预期工作。
猴子补丁
现在假设我们决定使用伟大的requests library。它的 API 更加程序化,它没有提供我们可以抓取来发出 HTTP 请求的对象。相反,我们将导入模块并调用其get 方法。
core.py 中的 HttpRequests 类将如下所示:
import requests
class HttpRequests(object):
# No more DI in __init__
def get_content(self, url):
# We simply delegate the HTTP work to the `requests` module
return requests.get(url)
没有更多的 DI,所以现在,我们想知道:
- 如何防止发生网络交互?
- 如何测试我是否正确使用了
requests 模块?
您可以在这里使用动态语言提供的另一种奇妙但有争议的机制:monkey patching。我们将在运行时将requests 模块替换为我们制作并可以在测试中使用的对象。
我们的单元测试将如下所示:
import core
class HttpRequestsTestCase(unittest.TestCase):
def setUp(self):
# We create a mock to replace the `requests` module
self.mock_requests = Mock()
# We keep a reference to the current, real, module
self.old_requests = core.requests
# We replace the module with our mock
core.requests = self.mock_requests
def tearDown(self):
# It is very important that each unit test be isolated, so we need
# to be good citizen and clean up after ourselves. This means that
# we need to put back the correct `requests` module where it was
core.requests = self.old_requests
def test_get_content_should_use_get_properly(self):
# Setup
url = "http://example.com"
# Exercise
http_client = core.HttpRequests()
content = http_client.get_content(url)
# Verify
# We expect our get_content method to have called our http library.
# Let's check!
self.mock_requests.get.assert_called_with(url)
# We can find out what our mock object has returned when get() was
# called on it
expected_content = self.mock_requests.get.return_value
# Since our get_content returns the same result without modification,
# we should have received
self.assertEqual(content, expected_content)
为了使这个过程不那么冗长,mock 模块有一个 patch 装饰器来处理脚手架。然后我们只需要写:
import core
class HttpRequestsTestCase(unittest.TestCase):
@patch("core.requests")
def test_get_content_should_use_get_properly(self, mock_requests):
# Notice the extra param in the test. This is the instance of `Mock` that the
# decorator has substituted for us and it is populated automatically.
...
# The param is now the object we need to make our assertions against
expected_content = mock_requests.get.return_value
结论
保持单元测试的规模小、简单、快速和独立是非常重要的。依赖另一台服务器运行的单元测试根本不是单元测试。为此,DI 是一种很好的实践,而模拟对象则是一种很好的工具。
首先,要理解模拟的概念以及如何使用它们并不容易。像每个电动工具一样,它们也可能在您的手中爆炸,例如让您相信您已经测试过某些东西,而实际上您并没有。确保模拟对象的行为和输入/输出反映现实至关重要。
附言
鉴于我们从未在单元测试级别与真正的 HTTP 服务器进行过交互,因此编写集成测试以确保我们的应用程序能够与它将在现实生活中处理的那种服务器进行通信非常重要。我们可以使用专门为集成测试设置的成熟服务器来做到这一点,或者编写一个人为的服务器。