pytest官方文档 6.2 中文翻译版(第七章):猴子补丁/模拟模块和环境

猴子补丁(MonkeyPatch)/模拟(Mock)模块和环境

有些时候,测试需要调用那些依赖于全局设置的功能或者调用例如网络访问这种不易被测试的功能。猴子补丁 夹具可以帮助你安全的设置和删除一个属性,字典中的一项或环境变量,还可以改变 sys.path。

猴子补丁(monkeypatch) 家具提供了下面一些有用的方法来安全的 模拟或者补丁测试中的一些功能:

monkeypatch.setattr(obj, name, value, raising=True)
monkeypatch.delattr(obj, name, raising=True)
monkeypatch.setitem(mapping, name, value)
monkeypatch.delitem(obj, name, raising=True)
monkeypatch.setenv(name, value, prepend=False)
monkeypatch.delenv(name, raising=True)
monkeypatch.syspath_prepend(path)
monkeypatch.chdir(path)

所有的修改在调用它的测试程序或者夹具结束之后都会被复原。如果 设置/删除 的操作不存在,后面的 raising 决定了是否引发一个 KeyError 或 AttributeError。

考虑一下下面的场景:

  1. 为测试修改一个函数的行为或者一个类的属性。例如:有一个API的调用或者数据库连接,你知道要输出什么,但是它并不是需要被测试的一部分。使用 monkeypatch.setattr 来补全你希望模拟的函数或者属性。这也可以包含你自己的函数。使用 monkeypatch.delattr 来为一个测试删除一个函数或者属性。
  2. 修改一个值或者一个字典。例如:你有一个全局的配置,但是有一个测试你希望修改这个全局的配置。使用 monkeypatch.setitem 来修改一个字典。monkeypatch.delitem 可以删除一个字典项。
  3. 为测试修改环境变量。例如:测试当某一个环境变量未设置的情况,或者为一个已知的变量设置多个值。使用 monkeypatch.setenv 和 monkeypatch.delenv 可以为这些情况打补丁。
  4. 使用 monkeypatch.setenv(“PATH”, value, prepend=os.pathsep) 可以直接修改 $PATH,monkeypatch.chdir 可以修改当前运行测试的目录。
  5. 使用 monkeypatch.syspath_prepend 不仅会修改 sys,path,还会同时调用 pkg_resources.fixup_namespace_packages 和 importlib.invalidate_caches()

这里有更多关于猴子补丁用法的介绍以及设计动机的讨论。

7.1 简单的例子:猴子补丁函数

想象这样一个场景,你在当前用户路径下工作,但是在测试场景中,你不希望测试依赖于当前的用户。猴子补丁可以用来”修补(patch)“依赖用户的函数,让他永远返回一个特定的值。
在这个例子中,monkeypatch.setattr被用来修补 Path.home,这样在测试中,已知的测试路径Path("/abc")就会一直被使用了。这样解除了用于测试目的所有关于用户的依赖。monkeypatch.setattr必须在被修改的函数调用之前调用。在测试函数运行完成之后,Path.home 的修改会被改回去。

# contents of test_module.py with source code and the test
from pathlib import Path


def getssh():
    """返回用户家目录下的.ssh路径."""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
    # mock一个一直返回 '/abc' 的函数,用于替代Path.home
    def mockreturn():
        return Path("/abc")

    # 猴子补丁的应用
    monkeypatch.setattr(Path, "home", mockreturn)
    # 调用 getssh() 的时候,会使用猴子补丁的值替换 Path.home
    x = getssh()
    assert x == Path("/abc/.ssh")

7.2 猴子补丁返回对象:构建模拟的类

monkeypatch.setattr 可以用来与类结合之后从函数中返回一个对象替代原有的值。想象一个API函数,接收一个url参数,返回一个json的response:

# contents of app.py, a simple API retrieval example
import requests


def get_json(url):
    """接收url,返回response."""
    r = requests.get(url)
    return r.json()

我们需要模拟(mock)r,返回的resoponse对象是用来测试的。r的模拟需要一个返回字典的 .json()方法。在我们的测试中可以定义一个类来代表r:

import requests
# app.py 是包括 get_json() 的上一个例子中的代码
import app


# 自定义的类,有一个json方法,会覆盖requests.Response中的get方法
class MockResponse:
    # mock json() 方法总是return一个指定的值
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


def test_get_json(monkeypatch):
    # 不管传递多少参数,我们的 mock_get() 方法总是会return我们模拟的值
    # 这个方法也只有json方法
    def mock_get(*args, **kwargs):
        return MockResponse()

    # 为 requests.get 应用猴子补丁去模拟 request.get
    monkeypatch.setattr(requests, "get", mock_get)
    # app.get_json 包含了 requests.get
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

猴子补丁使用mock_get方法模拟了requests.get的调用。mock_get方法返回了一个MockResponse的实例,它定义了一个json() 方法返回了一个已知的测试字典,这个方法不包含任何的外部API连接。

你可以根据你正在测试的场景构建具有适当复杂度的MockResponse类。例如,我们可以设置一个永远返回true的ok属性或是根据不同的输入字符串模拟json()方法的不同返回值。

在夹具中进行mock可以被多个测试共享:

# contents of test_app.py, a simple test for our API retrieval
import pytest
import requests

# app.py 包含了 get_json() 方法
import app

# 模拟 requests.get() 返回值的自定义类
class MockResponse:
	@staticmethod
	def json():
		return {"mock_key": "mock_response"}
		
# 在夹具中使用猴子补丁
@pytest.fixture
def mock_response(monkeypatch):
	"""Requests.get() 模拟返回 {'mock_key':'mock_response'}."""
	
	def mock_get(*args, **kwargs):
		return MockResponse()
		
	monkeypatch.setattr(requests, "get", mock_get)
	
# 注意我们的测试不直接使用猴子补丁夹具,而是使用自定义的夹具即可
def test_get_json(mock_response):
	result = app.get_json("https://fakeurl")
	assert result["mock_key"] == "mock_response"

此外,如果mock被设计为应用在全部测试中,这个夹具应该被移到 conftest.py 中,并且使用 autouse=True 选项。

7.3 全局补丁例子:防止来自远程的 “requests”

如果你想组织 “requests”库在所有测试中进行http请求,你可以这样做:

# contents of conftest.py
import pytest


@pytest.fixture(autouse=True)
def no_requests(monkeypatch):
	"""为所有的测试移除 requests.sessions.Session.request."""
	monkeypatch.delattr("requests.sessions.Session.request")

这个自动使用的夹具会被每个测试执行,这个夹具会删除 request.session.
Session.request 方法,这样,在任何测试中尝试进行http请求的操作都会失败。

注意:我们不建议对内置的函数进行补丁操作,例如 open compile 这种,因为这可能会打破pytest的内部结构。如果这种补丁是不可避免的,你可以尝试在命令行中传递 --tb=native, --assert=plain 和 --capture=no,虽然这种方法不能保证一定有用,但是一些情况下可以解决问题。

注意:对 stdlib 中的方法或者一些第三方的库方法进行补丁可能会破坏pytest自身的执行,因此,在这种情况下,建议使用 MonkeyPatch.context() 来限制一下你希望测试的代码快:

import functools

def test_partial(monkeypatch):
	with monkeypatch.context() as m:
		m.setattr(functools, "partial", 3)
		assert functools.partial == 3

查看 #3290 可以查看到更多的信息。

7.4 使用猴子补丁修改坏境变量

如果你由于某些测试原因需要经产安全的改变一些环境变量值或者删除一些环境变量。 猴子补丁提供了使用 setenv 和 delenv 的机制。看看测试的例子:

# contents of our original code file e.g. code.py
import os

def get_os_user_lower():
	"""简单的取值方法,返回一个小写的USER或者引发一个OSError"""
	username = os.getenv("USER")
	
	if username is None:
		raise OSError("USER environment is not set.")
		
	return username.lower()

存在两种潜在的路径,第一种是USER环境变量已经设置了一个值,另一种是USER环境变量不存在,使用 猴子补丁 可以在不破坏运行环境的基础上安全的测试这两种情况:

# contents of our test file e.g. test_code.py
import pytest

def test_upper_to_lower(monkeypatch):
	"""设置USER环境变量来断言."""
	monkeypatch.setenv("USER", "TestingUser")
	assert get_os_user_lower() == "testinguser"

def test_raise_exception(monkeypatch):
	"""移除环境变量看异常是不是触发了."""
	monkeypatch.delenv("USER", raising=False)
	
	with pytest.raises(OSError):
		_ = get_os_user_lower()

这个行为也可以移到夹具中被多个测试公用:

# contents of our test file e.g. test_code.py
import pytest


@pytest.fixture
def mock_env_user(monkeypatch):
	monkeypatch.setenv("USER", "TestingUser")

@pytest.fixture
def mock_env_missing(monkeypatch):
	monkeypatch.delenv("USER", raising=False)
	
# 注意测试引用的夹具
def test_upper_to_lower(mock_env_user):
	assert get_os_user_lower() == "testinguser"
	
def test_raise_exception(mock_env_missing):
	with pytest.raises(OSError):
		_ = get_os_user_lower()

7.5 给字典打补丁

monkeypatch.setitem可以用来在测试中安全的给字典的某一项设置一个特定的值。看看这个简单的连接字符串的例子:

# contents of app.py to generate a simple connection string
DEFAULT_CONFIG = {"user": "user1", "database": "db1"}

def create_connection_string(config=None):
	"""使用输入或者默认值来构建一个连接字符串."""
	config = config or DEFAULT_CONFIG
	return f"User Id={config['user']}; Location={config['database']};"

基于测试的目的,我们可以把 DEFAULT_CONFIG 的某一个值改成特定的样子:

# contents of test_app.py
# app.py 是上面的例子,包含了如何创建字符串的逻辑
import app


def test_connection(monkeypatch):

	# 只为这一个测试把 DEFAULT_CONFIG 改成一个特定的值
	monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
	monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
	
	# 基于mock的期望值
	expected = "User Id=test_user; Location=test_db;"
	
	# 使用了猴子补丁修改字典值的测试
	result = app.create_connection_string()
	assert result == expected

你也可以使用 monkeypatch.delitem 去移除一些值:

# contents of test_app.py
import pytest

#  有连接字符串方法的app.py
import app

def test_missing_user(monkeypatch):
	# 删掉 DEFAULT_CONFIG 中的user
	monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
	# 因为我们没有传递值,而默认值中user还缺失了,所以一个KeyError应该被引发
	with pytest.raises(KeyError):
		_ = app.create_connection_string()

模块化的夹具可以给你足够的灵活性去为每一个潜在的可能的测试去定义和分离夹具供它们使用:

# contents of test_app.py
import pytest

# app.py with the connection string function
import app

# 所有的mock都分开到不同的夹具中
@pytest.fixture
def mock_test_user(monkeypatch):
	"""设置DEFAULT_CONFIG中的user."""
	monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
	
@pytest.fixture
def mock_test_database(monkeypatch):
	"""设置DEFAULT_CONFIG中的database."""
	monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
	
@pytest.fixture
def mock_missing_default_user(monkeypatch):
	"""删除 DEFAULT_CONFIG 中的user"""
	monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)

# 测试只引用自己需要的夹具
def test_connection(mock_test_user, mock_test_database):
	expected = "User Id=test_user; Location=test_db;"
	
	result = app.create_connection_string()
	assert result == expected

def test_missing_user(mock_missing_default_user):
	with pytest.raises(KeyError):
		_ = app.create_connection_string()

7.6 API参考

请查阅 MonkeyPatch 类的文档。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值