为单元测试启动 Web 服务器绝对不是一个好习惯。单元测试应该简单且隔离,这意味着它们应该避免执行例如 IO 操作。
如果您要编写的是真正的单元测试,那么您应该制作自己的测试输入并查看模拟对象。Python 作为一种动态语言,模拟和猴子路径是编写单元测试的简单而强大的工具。特别是看看优秀的Mock 模块。
简单的单元测试
因此,如果我们查看您的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)
使用依赖注入进行模拟
现在,不太明显的事情是对你的类的getContent()
方法进行单元测试。core.HttpRequests
我想您正在使用 HTTP 库,而不是在 TCP 套接字上发出自己的请求。
为了使您的测试保持在单元级别,您不希望通过网络发送任何内容。您可以做些什么来避免这种情况,即进行测试以确保您正确使用 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 库。它的 API 更加程序化,它没有提供我们可以从中获取 HTTP 请求的对象。相反,我们将导入模块并调用它的get
方法。
我们的HttpRequests
类core.py
将如下所示:
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
是否正确使用了该模块?
在这里,您可以使用动态语言提供的另一种奇妙但有争议的机制:猴子补丁。我们将在运行时将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 是一个很好的实践,而模拟对象是一个很好的工具。
首先,要理解模拟的概念以及如何使用它们并不容易。像每个电动工具一样,它们也可能在您的手中爆炸,例如让您相信您已经测试过某些东西,而实际上您并没有。确保模拟对象的行为和输入/输出反映现实是最重要的。
附言
Given that we have never interacted with a real HTTP server at the unit test level, it is important to write Integration Tests that will make sure our application is able to talk to the sort of servers it will deal with in real life. We could do this with a fully fledged server set up specially for Integration Testing, or write a contrived one.