新聞中心
本文轉(zhuǎn)載自微信公眾號「游戲不存在」,作者肖恩 。轉(zhuǎn)載本文請聯(lián)系游戲不存在公眾號。

pytest是python的單元測試框架,簡單易用,在很多知名項(xiàng)目中應(yīng)用。requests是python知名的http爬蟲庫,同樣簡單易用,是python開源項(xiàng)目的TOP10。關(guān)于這2個項(xiàng)目,之前都有過介紹,本文主要介紹requests項(xiàng)目如何使用pytest進(jìn)行單元測試,希望達(dá)到下面3個目標(biāo):
- 熟練pytest的使用
- 學(xué)習(xí)如何對項(xiàng)目進(jìn)行單元測試
- 深入requests的一些實(shí)現(xiàn)細(xì)節(jié)
本文分如下幾個部分:
- requests項(xiàng)目單元測試狀況
- 簡單工具類如何測試
- request-api如何測試
- 底層API測試
requests項(xiàng)目單元測試狀況
requests的單元測試代碼全部在 tests 目錄,使用 pytest.ini 進(jìn)行配置。測試除pytest外,還需要安裝:
| 庫名 | 描述 |
|---|---|
| httpbin | 一個使用flask實(shí)現(xiàn)的http服務(wù),可以客戶端定義http響應(yīng),主要用于測試http協(xié)議 |
| pytest-httpbin | pytest的插件,封裝httpbin的實(shí)現(xiàn) |
| pytest-mock | pytest的插件,提供mock |
| pytest-cov | pytest的插件,提供覆蓋率 |
上述依賴 master 版本在requirement-dev文件中定義;2.24.0版本會在pipenv中定義。
測試用例使用make命令,子命令在Makefile中定義, 使用make ci運(yùn)行所有單元測試結(jié)果如下:
- $ make ci
- pytest tests --junitxml=report.xml
- ======================================================================================================= test session starts =======================================================================================================
- platform linux -- Python 3.6.8, pytest-3.10.1, py-1.10.0, pluggy-0.13.1
- rootdir: /home/work6/project/requests, inifile: pytest.ini
- plugins: mock-2.0.0, httpbin-1.0.0, cov-2.9.0
- collected 552 items
- tests/test_help.py ... [ 0%]
- tests/test_hooks.py ... [ 1%]
- tests/test_lowlevel.py ............... [ 3%]
- tests/test_packages.py ... [ 4%]
- tests/test_requests.py .................................................................................................................................................................................................... [ 39%]
- 127.0.0.1 - - [10/Aug/2021 08:41:53] "GET /stream/4 HTTP/1.1" 200 756
- .127.0.0.1 - - [10/Aug/2021 08:41:53] "GET /stream/4 HTTP/1.1" 500 59
- ----------------------------------------
- Exception happened during processing of request from ('127.0.0.1', 46048)
- Traceback (most recent call last):
- File "/usr/lib64/python3.6/wsgiref/handlers.py", line 138, in run
- self.finish_response()
- x......................................................................................... [ 56%]
- tests/test_structures.py .................... [ 59%]
- tests/test_testserver.py ......s.... [ 61%]
- tests/test_utils.py ..s................................................................................................................................................................................................ssss [ 98%]
- ssssss..... [100%]
- ----------------------------------------------------------------------------------- generated xml file: /home/work6/project/requests/report.xml -----------------------------------------------------------------------------------
- ======================================================================================= 539 passed, 12 skipped, 1 xfailed in 64.16 seconds ========================================================================================
可以看到requests在1分鐘內(nèi),總共通過了539個測試用例,效果還是不錯。使用 make coverage 查看單元測試覆蓋率:
- $ make coverage
- ----------- coverage: platform linux, python 3.6.8-final-0 -----------
- Name Stmts Miss Cover
- -------------------------------------------------
- requests/__init__.py 71 71 0%
- requests/__version__.py 10 10 0%
- requests/_internal_utils.py 16 5 69%
- requests/adapters.py 222 67 70%
- requests/api.py 20 13 35%
- requests/auth.py 174 54 69%
- requests/certs.py 4 4 0%
- requests/compat.py 47 47 0%
- requests/cookies.py 238 115 52%
- requests/exceptions.py 35 29 17%
- requests/help.py 63 19 70%
- requests/hooks.py 15 4 73%
- requests/models.py 455 119 74%
- requests/packages.py 16 16 0%
- requests/sessions.py 283 67 76%
- requests/status_codes.py 15 15 0%
- requests/structures.py 40 19 52%
- requests/utils.py 465 170 63%
- -------------------------------------------------
- TOTAL 2189 844 61%
- Coverage XML written to file coverage.xml
結(jié)果顯示requests項(xiàng)目總體覆蓋率61%,每個模塊的覆蓋率也清晰可見。
單元測試覆蓋率使用代碼行數(shù)進(jìn)行判斷,Stmts顯示模塊的有效行數(shù),Miss顯示未執(zhí)行到的行。如果生成html的報(bào)告,還可以定位到具體未覆蓋到的行;pycharm的coverage也有類似功能。
tests下的文件及測試類如下表:
| 文件 | 描述 |
|---|---|
| compat | python2和python3兼容 |
| conftest | pytest配置 |
| test_help,test_packages,test_hooks,test_structures | 簡單測試類 |
| utils.py | 工具函數(shù) |
| test_utils | 測試工具函數(shù) |
| test_requests | 測試requests |
| testserver\server | 模擬服務(wù) |
| test_testserver | 模擬服務(wù)測試 |
| test_lowlevel | 使用模擬服務(wù)測試模擬網(wǎng)絡(luò)測試 |
簡單工具類如何測試
test_help 實(shí)現(xiàn)分析
先從最簡單的test_help上手,測試類和被測試對象命名是對應(yīng)的。先看看被測試的模塊help.py。這個模塊主要是2個函數(shù) info 和 _implementation:
- import idna
- def _implementation():
- ...
- def info():
- ...
- system_ssl = ssl.OPENSSL_VERSION_NUMBER
- system_ssl_info = {
- 'version': '%x' % system_ssl if system_ssl is not None else ''
- }
- idna_info = {
- 'version': getattr(idna, '__version__', ''),
- }
- ...
- return {
- 'platform': platform_info,
- 'implementation': implementation_info,
- 'system_ssl': system_ssl_info,
- 'using_pyopenssl': pyopenssl is not None,
- 'pyOpenSSL': pyopenssl_info,
- 'urllib3': urllib3_info,
- 'chardet': chardet_info,
- 'cryptography': cryptography_info,
- 'idna': idna_info,
- 'requests': {
- 'version': requests_version,
- },
- }
info提供系統(tǒng)環(huán)境的信息,_implementation是其內(nèi)部實(shí)現(xiàn),以下劃線*_*開頭。再看測試類test_help:
- from requests.help import info
- def test_system_ssl():
- """Verify we're actually setting system_ssl when it should be available."""
- assert info()['system_ssl']['version'] != ''
- class VersionedPackage(object):
- def __init__(self, version):
- self.__version__ = version
- def test_idna_without_version_attribute(mocker):
- """Older versions of IDNA don't provide a __version__ attribute, verify
- that if we have such a package, we don't blow up.
- """
- mocker.patch('requests.help.idna', new=None)
- assert info()['idna'] == {'version': ''}
- def test_idna_with_version_attribute(mocker):
- """Verify we're actually setting idna version when it should be available."""
- mocker.patch('requests.help.idna', new=VersionedPackage('2.6'))
- assert info()['idna'] == {'version': '2.6'}
首先從頭部的導(dǎo)入信息可以看到,僅僅對info函數(shù)進(jìn)行測試,這個容易理解。info測試通過,自然覆蓋到_implementation這個內(nèi)部函數(shù)。這里可以得到單元測試的第1個技巧:
1.僅對public的接口進(jìn)行測試
test_idna_without_version_attribute和test_idna_with_version_attribute均有一個mocker參數(shù),這是pytest-mock提供的功能,會自動注入一個mock實(shí)現(xiàn)。使用這個mock對idna模塊進(jìn)行模擬
- # 模擬空實(shí)現(xiàn)
- mocker.patch('requests.help.idna', new=None)
- # 模擬版本2.6
- mocker.patch('requests.help.idna', new=VersionedPackage('2.6'))
可能大家會比較奇怪,這里patch模擬的是 requests.help.idna , 而我們在help中導(dǎo)入的是 inda 模塊。這是因?yàn)樵趓equests.packages中對inda進(jìn)行了模塊名重定向:
- for package in ('urllib3', 'idna', 'chardet'):
- locals()[package] = __import__(package)
- # This traversal is apparently necessary such that the identities are
- # preserved (requests.packages.urllib3.* is urllib3.*)
- for mod in list(sys.modules):
- if mod == package or mod.startswith(package + '.'):
- sys.modules['requests.packages.' + mod] = sys.modules[mod]
使用mocker后,idna的__version__信息就可以進(jìn)行控制,這樣info中的idna結(jié)果也就可以預(yù)期。那么可以得到第2個技巧:
2.使用mock輔助單元測試
test_hooks 實(shí)現(xiàn)分析
我們繼續(xù)查看hooks如何進(jìn)行測試:
- from requests import hooks
- def hook(value):
- return value[1:]
- @pytest.mark.parametrize(
- 'hooks_list, result', (
- (hook, 'ata'),
- ([hook, lambda x: None, hook], 'ta'),
- )
- )
- def test_hooks(hooks_list, result):
- assert hooks.dispatch_hook('response', {'response': hooks_list}, 'Data') == result
- def test_default_hooks():
- assert hooks.default_hooks() == {'response': []}
hooks模塊的2個接口default_hooks和dispatch_hook都進(jìn)行了測試。其中default_hooks是純函數(shù),無參數(shù)有返回值,這種函數(shù)最容易測試,僅僅檢查返回值是否符合預(yù)期即可。dispatch_hook會復(fù)雜一些,還涉及對回調(diào)函數(shù)(hook函數(shù))的調(diào)用:
- def dispatch_hook(key, hooks, hook_data, **kwargs):
- """Dispatches a hook dictionary on a given piece of data."""
- hooks = hooks or {}
- hooks = hooks.get(key)
- if hooks:
- # 判斷鉤子函數(shù)
- if hasattr(hooks, '__call__'):
- hooks = [hooks]
- for hook in hooks:
- _hook_data = hook(hook_data, **kwargs)
- if _hook_data is not None:
- hook_data = _hook_data
- return hook_data
pytest.mark.parametrize提供了2組參數(shù)進(jìn)行測試。第一組參數(shù)hook和ata很簡單,hook是一個函數(shù),會對參數(shù)裁剪,去掉首位,ata是期望的返回值。test_hooks的response的參數(shù)是Data,所以結(jié)果應(yīng)該是ata。第二組參數(shù)中的第一個參數(shù)會復(fù)雜一些,變成了一個數(shù)組,首位還是hook函數(shù),中間使用一個匿名函數(shù),匿名函數(shù)沒有返回值,這樣覆蓋到 if _hook_data is not None: 的旁路分支。執(zhí)行過程如下:
- hook函數(shù)裁剪Data首位,剩余ata
- 匿名函數(shù)不對結(jié)果修改,剩余ata
- hook函數(shù)繼續(xù)裁剪ata首位,剩余ta
經(jīng)過測試可以發(fā)現(xiàn)dispatch_hook的設(shè)計(jì)十分巧妙,使用pipeline模式,將所有的鉤子串起來,這是和事件機(jī)制不一樣的地方。細(xì)心的話,我們可以發(fā)現(xiàn) if hooks: 并未進(jìn)行旁路測試,這個不夠嚴(yán)謹(jǐn),有違我們的第3個技巧:
3.測試盡可能覆蓋目標(biāo)函數(shù)的所有分支
test_structures 實(shí)現(xiàn)分析
LookupDict的測試用例如下:
- class TestLookupDict:
- @pytest.fixture(autouse=True)
- def setup(self):
- """LookupDict instance with "bad_gateway" attribute."""
- self.lookup_dict = LookupDict('test')
- self.lookup_dict.bad_gateway = 502
- def test_repr(self):
- assert repr(self.lookup_dict) == "
" - get_item_parameters = pytest.mark.parametrize(
- 'key, value', (
- ('bad_gateway', 502),
- ('not_a_key', None)
- )
- )
- @get_item_parameters
- def test_getitem(self, key, value):
- assert self.lookup_dict[key] == value
- @get_item_parameters
- def test_get(self, key, value):
- assert self.lookup_dict.get(key) == value
可以發(fā)現(xiàn)使用setup方法配合@pytest.fixture,給所有測試用例初始化了一個lookup_dict對象;同時pytest.mark.parametrize可以在不同的測試用例之間復(fù)用的,我們可以得到第4個技巧:
4.使用pytest.fixture復(fù)用被測試對象,使用pytest.mark.parametriz復(fù)用測試參數(shù)
通過TestLookupDict的test_getitem和test_get可以更直觀的了解LookupDict的get和__getitem__方法的作用:
- class LookupDict(dict):
- ...
- def __getitem__(self, key):
- # We allow fall-through here, so values default to None
- return self.__dict__.get(key, None)
- def get(self, key, default=None):
- return self.__dict__.get(key, default)
- get自定義字典,使其可以使用 get 方法獲取值
- __getitem__自定義字典,使其可以使用 [] 符合獲取值
CaseInsensitiveDict的測試用例在test_structures和test_requests中都有測試,前者主要是基礎(chǔ)測試,后者偏向業(yè)務(wù)使用層面,我們可以看到這兩種差異:
- class TestCaseInsensitiveDict:
- # 類測試
- def test_repr(self):
- assert repr(self.case_insensitive_dict) == "{'Accept': 'application/json'}"
- def test_copy(self):
- copy = self.case_insensitive_dict.copy()
- assert copy is not self.case_insensitive_dict
- assert copy == self.case_insensitive_dict
- class TestCaseInsensitiveDict:
- # 使用方法測試
- def test_delitem(self):
- cid = CaseInsensitiveDict()
- cid['Spam'] = 'someval'
- del cid['sPam']
- assert 'spam' not in cid
- assert len(cid) == 0
- def test_contains(self):
- cid = CaseInsensitiveDict()
- cid['Spam'] = 'someval'
- assert 'Spam' in cid
- assert 'spam' in cid
- assert 'SPAM' in cid
- assert 'sPam' in cid
- assert 'notspam' not in cid
借鑒上面的測試方法,不難得出第5個技巧:
5.可以從不同的層面對同一個對象進(jìn)行單元測試
后面的test_lowlevel和test_requests也應(yīng)用了這種技巧
utils.py
utils中構(gòu)建了一個可以寫入env的生成器(由yield關(guān)鍵字提供),可以當(dāng)上下文裝飾器使用:
- import contextlib
- import os
- @contextlib.contextmanager
- def override_environ(**kwargs):
- save_env = dict(os.environ)
- for key, value in kwargs.items():
- if value is None:
- del os.environ[key]
- else:
- os.environ[key] = value
- try:
- yield
- finally:
- os.environ.clear()
- os.environ.update(save_env)
下面是使用方法示例:
- # test_requests.py
- kwargs = {
- var: proxy
- }
- # 模擬控制proxy環(huán)境變量
- with override_environ(**kwargs):
- proxies = session.rebuild_proxies(prep, {})
- def rebuild_proxies(self, prepared_request, proxies):
- bypass_proxy = should_bypass_proxies(url, no_proxy=no_proxy)
- def should_bypass_proxies(url, no_proxy):
- ...
- get_proxy = lambda k: os.environ.get(k) or os.environ.get(k.upper())
- ...
6.涉及環(huán)境變量的地方,可以使用上下文裝飾器進(jìn)行模擬多種環(huán)境變量
utils測試用例
utils的測試用例較多,我們選擇部分進(jìn)行分析。先看to_key_val_list函數(shù):
- # 對象轉(zhuǎn)列表
- def to_key_val_list(value):
- if value is None:
- return None
- if isinstance(value, (str, bytes, bool, int)):
- raise ValueError('cannot encode objects that are not 2-tuples')
- if isinstance(value, Mapping):
- value = value.items()
- return list(value)
對應(yīng)的測試用例TestToKeyValList:
- class TestToKeyValList:
- @pytest.mark.parametrize(
- 'value, expected', (
- ([('key', 'val')], [('key', 'val')]),
- ((('key', 'val'), ), [('key', 'val')]),
- ({'key': 'val'}, [('key', 'val')]),
- (None, None)
- ))
- def test_valid(self, value, expected):
- assert to_key_val_list(value) == expected
- def test_invalid(self):
- with pytest.raises(ValueError):
- to_key_val_list('string')
重點(diǎn)是test_invalid中使用pytest.raise對異常的處理:
7.使用pytest.raises對異常進(jìn)行捕獲處理
TestSuperLen介紹了幾種進(jìn)行IO模擬測試的方法:
- class TestSuperLen:
- @pytest.mark.parametrize(
- 'stream, value', (
- (StringIO.StringIO, 'Test'),
- (BytesIO, b'Test'),
- pytest.param(cStringIO, 'Test',
- marks=pytest.mark.skipif('cStringIO is None')),
- ))
- def test_io_streams(self, stream, value):
- """Ensures that we properly deal with different kinds of IO streams."""
- assert super_len(stream()) == 0
- assert super_len(stream(value)) == 4
- def test_super_len_correctly_calculates_len_of_partially_read_file(self):
- """Ensure that we handle partially consumed file like objects."""
- s = StringIO.StringIO()
- s.write('foobarbogus')
- assert super_len(s) == 0
- @pytest.mark.parametrize(
- 'mode, warnings_num', (
- ('r', 1),
- ('rb', 0),
- ))
- def test_file(self, tmpdir, mode, warnings_num, recwarn):
- file_obj = tmpdir.join('test.txt')
- file_obj.write('Test')
- with file_obj.open(mode) as fd:
- assert super_len(fd) == 4
- assert len(recwarn) == warnings_num
- def test_super_len_with_tell(self):
- foo = StringIO.StringIO('12345')
- assert super_len(foo) == 5
- foo.read(2)
- assert super_len(foo) == 3
- def test_super_len_with_fileno(self):
- with open(__file__, 'rb') as f:
- length = super_len(f)
- file_data = f.read()
- assert length == len(file_data)
- 使用StringIO來模擬IO操作,可以配置各種IO的測試。當(dāng)然也可以使用BytesIO/cStringIO, 不過單元測試用例一般不關(guān)注性能,StringIO簡單夠用。
- pytest提供tmpdir的fixture,可以進(jìn)行文件讀寫操作測試
- 可以使用__file__來進(jìn)行文件的只讀測試,__file__表示當(dāng)前文件,不會產(chǎn)生副作用。
8.使用IO模擬配合進(jìn)行單元測試
request-api如何測試
requests的測試需要httpbin和pytest-httpbin,前者會啟動一個本地服務(wù),后者會安裝一個pytest插件,測試用例中可以得到httpbin的fixture,用來操作這個服務(wù)的URL。
| 類 | 功能 |
|---|---|
| TestRequests | requests業(yè)務(wù)測試 |
| TestCaseInsensitiveDict | 大小寫不敏感的字典測試 |
| TestMorselToCookieExpires | cookie過期測試 |
| TestMorselToCookieMaxAge | cookie大小 |
| TestTimeout | 響應(yīng)超時的測試 |
| TestPreparingURLs | URL預(yù)處理 |
| ... | 一些零碎的測試用例 |
坦率的講:這個測試用例內(nèi)容龐大,達(dá)到2500行。看起來是針對各種業(yè)務(wù)的零散case,我并沒有完全理順其組織邏輯。我選擇一些感興趣的業(yè)務(wù)進(jìn)行介紹, 先看TimeOut的測試:
- TARPIT = 'http://10.255.255.1'
- class TestTimeout:
- def test_stream_timeout(self, httpbin):
- try:
- requests.get(httpbin('delay/10'), timeout=2.0)
- except requests.exceptions.Timeout as e:
- assert 'Read timed out' in e.args[0].args[0]
- @pytest.mark.parametrize(
- 'timeout', (
- (0.1, None),
- Urllib3Timeout(connect=0.1, read=None)
- ))
- def test_connect_timeout(self, timeout):
- try:
- requests.get(TARPIT, timeout=timeout)
- pytest.fail('The connect() request should time out.')
- except ConnectTimeout as e:
- assert isinstance(e, ConnectionError)
- assert isinstance(e, Timeout)
test_stream_timeout利用httpbin創(chuàng)建了一個延遲10s響應(yīng)的接口,然后請求本身設(shè)置成2s,這樣可以收到一個本地timeout的錯誤。test_connect_timeout則是訪問一個不存在的服務(wù),捕獲連接超時的錯誤。
TestRequests都是對requests的業(yè)務(wù)進(jìn)程測試,可以看到至少是2種:
- class TestRequests:
- def test_basic_building(self):
- &nbs
網(wǎng)站標(biāo)題:Python單元測試的九個技巧
文章源于:http://fisionsoft.com.cn/article/cceieie.html


咨詢
建站咨詢
