单元测试中的模拟、存根和间谍

时间:2021-06-03 00:56:49

单元测试中的模拟、存根和间谍

在昨天的测试策略文章之后,我收到了一位读者的邮件:

嘿,介意写一篇解释测试替身的文章吗?我有点理解它,但我正在尝试寻找一个更简单的定义,比如间谍、假货、存根,以及一个更好看的例子,说明何时使用这些类型的测试替身。

好问题!

如果你不知道模拟、间谍、替身、假货,随便你怎么称呼他们;不用担心!我有你想要的!

Mocking 定义

当您测试您的代码时,有时您想要调用代码的某个部分,但您不希望它全部运行。

在大多数测试库中,都有用于拦截函数调用(或整个类/对象)以伪造它们的实现和响应的工具。

我将这些伪造的实现称为“模拟”。这就是我将在本文中使用的术语。

一个例子

模拟在实践中有何帮助?

免责声明:此代码仅用于演示目的。它接近于正确的代码,但它肯定不起作用,目前无法运行!不要像教程一样复制粘贴并期望它有效。

这是一些代码:

from datetime import datetime
from fastapi import FastApi, HTTPException
import requests

app = FastAPI()

API_BASE_URL = 'www.external-weather-api.com/api'

@app.get("/temperature/{location_str}")
def get_temperature_for_location(location_str: str)
    """Get the current weather for a location"""
    weather_response = fetch_current_weather(location_str)
    return {'temperature': weather_response.body['temperature']}

def fetch_current_weather(location_str: str) -> dict:
    """Query external API for current weather in a location"""
    now_isoformat = datetime.utcnow().isoformat()
    location_id = get_location_id(location_str)
    weather_response = requests.get(
        f'{API_BASE_URL}/weather/{location_id}?date={now_isoformat}'
    )
    if weather_response.status_code != 200:
        raise HttpException(
            status_code=weather_response.status_code, 
            detail=weather_response.reason,
        )
    return weather_response.json()

def fetch_location_id(location_str: str) -> str:
    """Query external API for a location and return the location ID"""
    location_response = requests.get(
        f'{API_BASE_URL}/locations?search={location}'
    )
    if location_response.status_code != 200:
        raise HttpException(
            status_code=location_response.status_code, 
            detail=location_response.reason,
        )

    location_id = location_response.json()['data']['id']
    return location_id

基本上,我们查询外部 API 以获取我们应用程序中某个位置的当前温度。

在编写单元测试时,最佳做法是在运行测试时不要查询外部 API。因此,我们需要某种方式来模拟外部 API 响应。

模拟外部 API

每个测试库实现模拟都略有不同。对于这些示例,我将使用 Python 的标准 unittest

我开始用最小的、最低级别的单元编写单元测试。在这种情况下,fetch_location_id() 可能是最好的起点。

import unittest

class TemperatureTestCase(unittest.TestCase):
    def test_fetch_location_id_success(self):
        mock_location_response = unittest.mock.MagicMock()
        mock_location_response.status_code = 200
        mock_location_response.json.return_value = {'location_id': 'foobar'}

        with unittest.mock.patch(
            'requests.get', return_value=mock_location_response
        ):
            actual_response = fetch_location_id('bazqux')

        self.assertEqual(actual_response, 'foobar')

在这里,当代码在 with 块内运行时,我使用 unittest.mock 来替换 requests.get 的实现。

unittest.mock 允许我劫持执行并返回我自己的假响应,而不是向外部 API 发出实际请求。 这样,我们就不会在每次运行单元测试时都实际调用外部 API。

模拟失败

我还可以用来 unittest.mock 模拟外部 API 中的故障。

这是一个失败的单元测试:

class TemperatureTestCase(unittest.TestCase):
    def test_fetch_location_id_error(self):
        mock_location_response = unittest.mock.MagicMock()
        mock_location_response.status_code = 400
        mock_location_response.reason = 'Some error'

        with unittest.mock.patch(
            'requests.get', return_value=mock_location_response
        ):
            with self.assertRaises(HttpException) as exc:
                fetch_location_id('bazqux')

            self.assertEqual(exc.errors[0].status_code, 400)
            self.assertEqual(exc.errors[0].detail, 'Some error')

我可以让模拟调用做各种事情,包括引发错误。如果我使用类似的东西unittest.mock.patch('some_function', side_effect=Exception('Scary error!')),我可以模拟错误的发生并测试我的异常处理。

什么时候使用 Mocking

Mocking 对于编写好的测试非常有价值。以下是何时使用它的一些想法:

  • 您的应用程序调用外部 API
  • 您对超出当前单元测试范围的应用程序的另一部分进行服务调用
  • 您希望将测试隔离到单个函数,而不是在调用堆栈中调用更深层次的函数
  • 您需要标准化/稳定一些数据,例如响应值、日期等
  • 您希望避免重复调用一段缓慢的逻辑

所有其他词是什么意思?

早些时候我们看到了一些与 Mocking 有关的其他词。这是一个小词汇表。

来自Stack Overflow,这是一个很好的概述:

  • 测试替身 是在测试中模拟数据/调用的各种方式的通用术语
  • 虚拟 对象被传递但从未真正使用过。通常它们只是用来填充参数列表。
  • 对象实际上有工作实现,但通常会采取一些捷径,这使得它们不适合生产(内存数据库就是一个很好的例子)。
  • 存根 为测试期间发出的呼叫提供固定答案,通常根本不响应测试编程之外的任何内容。存根还可以记录有关呼叫的信息,例如电子邮件网关存根会记住它“发送”的消息,或者可能只记住它“发送”的消息数。
  • 模拟 是我们在这里谈论的:预先编程的对象,这些对象形成了他们期望接收的调用的规范。
  • 间谍 是专门模拟的术语,其目的是测试对模拟的调用。

这些都是微不足道的区别,而且在我看来是不必要的分裂。我几乎把所有东西都称为“模拟”。

如果您想更深入地了解这个术语,并在此过程中了解很多关于测试的知识,您可以查看 Martin Fowler 的“Mocks Aren't Stubs”

每日清单

我每天早上都会为软件开发人员写一些新东西。

如果你喜欢我的文章,点赞,关注,转发!