最近使用 FastAPI
框架开发了一个 WEB 服务。
为了充分利用 FastAPI
作为一个 ASGI 框架的原生异步支持特性,很多业务代码也改成了异步函数,并且使用了异步的 HTTP 库httpx
和 MongoDB 的异步 Python drivermotor
。
由此带来的一个问题就是异步 Python 代码的单元测试的编写问题。
测试异步函数 编写测试代码 Python 的异步函数返回的是一个协程对象(coroutine
),需要在前面加await
才能获取异步函数的返回值,而只有在异步函数中才能使用await
语句,这也意味着一般异步函数的测试代码本身也需要是一个异步函数。
1 2 3 4 5 6 async def add (a:int , b:int ): return a + b async def testAdd (): ret = await add(1 , 2 ) assert ret == 3
运行测试代码 与 Javascript 不同,Python 的异步代码需要显示地运行在事件循环中。
Python3.7 以上的版本中可以直接调用asyncio.run
函。
如果使用的是更早的 Python 版本,就需要指定一个事件循环对象来运行异步代码。
1 2 3 4 5 6 7 8 import asyncioasyncio.run(testAdd()) loop = asyncio.new_event_loop() loop.run_until_complete(testAdd())
使用 Pytest 运行异步测试代码 Pytest 是一个广为流行的 Python 测试框架,借助pytest-asyncio
插件,我们可以更方便地编写异步测试代码。
1 2 3 4 5 6 7 8 9 10 11 import pytestasync def add (a: int , b: int ): return a + b @pytest.mark.asyncio async def testAdd (): assert await add(1 , 2 ) == 3
1 2 3 4 5 6 7 8 9 10 pytest .\code\testasync.py =============================================================================== test session starts =============================================================================== platform win32 -- Python 3.8.6, pytest-6.2.1, py-1.10.0, pluggy-1.0.0.dev0 rootdir: C:\Users\duyix\Documents\md plugins: asyncio-0.14.0 collected 1 item code\testasync.py . [100%] ================================================================================ 1 passed in 0.04s ================================================================================
我们可以修改一下测试代码,让单元测试运行失败。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 pytest .\code\testasync.py =============================================================================== test session starts =============================================================================== platform win32 -- Python 3.8.6, pytest-6.2.1, py-1.10.0, pluggy-1.0.0.dev0 rootdir: C:\Users\duyix\Documents\md plugins: asyncio-0.14.0 collected 1 item code\testasync.py F [100%] ==================================================================================== FAILURES ===================================================================================== _____________________________________________________________________________________ testAdd _____________________________________________________________________________________ @pytest.mark.asyncio async def testAdd(): > assert await add(1, 2) == 4 E assert 3 == 4 code\testasync.py:10: AssertionError ============================================================================= short test summary info ============================================================================= FAILED code/testasync.py::testAdd - assert 3 == 4 ================================================================================ 1 failed in 0.13s ================================================================================
mock 对象与异步测试 单元测试测试的是当前函数的行为,函数内部对于其他模块和组件的调用一般通过 mock 对象来模拟。
例如我们需要测试一个getIP
函数,函数内通过向https://httpbin.org/ip
接口发送请求来获取当前机器的 ip。
为了避免单元测试访问外部网络,同时消除在不同机器或者网络环境下getIP
函数每次返回结果会不一样的影响,我们可以mock
调网络请求部分的函数调用。
先看一下使用requests
库的同步版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import requestsfrom unittest import mockdef getIP (): resp = requests.get("https://httpbin.org/ip" ) return resp.json()["origin" ] @mock.patch("requests.get" ) def testGetIP (mock_get ): mock_response = mock.Mock() mock_response.json.return_value = {"origin" : "127.0.0.1" } mock_get.return_value = mock_response assert getIP() == "127.0.0.1" mock_get.assert_called_once_with("https://httpbin.org/ip" )
如果换一个asyncio
的HTTP
库的话,简单的mock
就会失败。。
1 2 3 4 5 6 7 8 9 10 import httpxclient = httpx.AsyncClient() async def getIP (): resp = await client.get("http://httpbin.org/ip" ) return resp.json()["origin" ]
1 2 3 4 5 6 7 8 9 10 from getip import getIPimport pytestfrom unittest import mock@pytest.mark.asyncio @mock.patch("getip.client" ) async def testGetIP (mock_client ): await getIP()
我们先把client
对象mock
掉来简单的调用一下getIP
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ===================================================================================== FAILURES ====================================================================================== _____________________________________________________________________________________ testGetIP _____________________________________________________________________________________ mock_client = <MagicMock name='client' id ='2180140905136' > @pytest.mark.asyncio @mock.patch("getip.client" ) async def testGetIP(mock_client): > await getIP() testhttpx.py:9: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ async def getIP(): > resp = await client.get("http://httpbin.org/ip" ) E TypeError: object MagicMock can't be used in ' await' expression getip.py:8: TypeError ============================================================================== short test summary info ============================================================================== FAILED testhttpx.py::testGetIP - TypeError: object MagicMock can' t be used in 'await' expression================================================================================= 1 failed in 0.36s =================================================================================
可以看到默认的 mock 对象并不支持在await
语句中使用。
解决方法也很简单,我们只需要指定需要mock
的函数或方法的返回值为一个asyncio.Future
对象。
A Future represents an eventual result of an asynchronous operation. Not thread-safe. Future is an awaitable object. Coroutines can await on Future objects until they either have a result or an exception set, or until they are cancelled.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import asynciofrom unittest import mockimport pytestfrom getip import getIP@pytest.mark.asyncio @mock.patch("getip.client" ) async def testGetIP (mock_client ): mock_response = mock.Mock() mock_response.json.return_value = {"origin" : "127.0.0.1" } future = asyncio.Future() future.set_result(mock_response) mock_client.get.return_value = future assert await getIP() == "127.0.0.1"
我们也可以通过set_exception
方法来指定asyncio.Future
对象抛出的异常。
1 2 3 4 5 6 7 8 @pytest.mark.asyncio @mock.patch("getip.client" ) async def testGetIPFailed (mock_client ): future = asyncio.Future() future.set_exception(Exception()) mock_client.get.return_value = future with pytest.raises(Exception): await getIP()
值得注意的是如果不调用asyncio.Future
对象的set_result
方法或者set_exception
方法的话,await
语句会一直拿不到返回,程序会阻塞住。
总结 在这里总结一下异步 Python 代码的单元测试的要点:
测试代码也需要是异步代码
可以通过pytest-asyncio
插件配合pytest
简化异步测试代码的编写
对于需要mock
的异步对象,可以指定mock
方法或者函数返回一个asyncio.Future
对象