为什么你的模拟测试会失败 (Wèi shénme nǐ de mónǐ cèshì huì shībài) or, more simply: 模拟测试为什么会坏 (Mónǐ cèshì wèishénme huì huài)
An overly aggressive mock can work fine, but break much later

原始链接: https://nedbatchelder.com/blog/202511/why_your_mock_breaks_later.html

## 模拟的陷阱:为什么你的测试会稍后失效 模拟可以是一个强大的测试工具,但一个常见的错误会导致后续出现意外的失败。关键原则是**模拟对象被*使用*的地方,而不是被*定义*的地方**。 考虑一个读取设置文件的代码场景。对 `open()` 函数的看似有效的模拟可以在测试期间绕过对真实文件的需求。然而,这种广泛的模拟可能会干扰其他工具——例如代码覆盖率库——这些库也会在内部*使用* `open()`。这种干扰表现为错误(例如 `TypeError: replace() argument 1 must be str, not bytes`),当这些工具尝试运行时会出现。 解决方案?仅在*使用*它的模块内修补 `open()`,而不是全局修补。这隔离了模拟的效果,防止了意想不到的后果。 最近,`coverage.py` 的作者甚至添加了一个安全措施,以在其模块内恢复原始的 `open()` 函数,以减轻此问题,并认识到过度模拟的普遍性。最终,有针对性的模拟可以减少摩擦并确保测试的稳定性。

## Hacker News 讨论总结:避免过度 Mocking Hacker News 的讨论围绕着单元测试中过度 Mocking 的陷阱,尤其是在 Python 中。核心论点,源于一篇链接的博文,是**过度 Mocking 会导致脆弱的测试,在代码重构时容易出错。** 开发者应该**将代码重构为将纯逻辑与不纯副作用(如 I/O)分离**,而不是 Mocking 内部依赖项,例如文件 I/O。 理想的方法是设计函数,使其接受数据作为参数,而不是直接与外部系统交互。这使得使用真实数据或简单的测试替身进行测试更加容易。 许多评论者提倡**依赖注入**和创建**“假对象”(fakes)**——外部依赖项的轻量级替代品——而不是严格的 Mock,后者会验证实现细节。 讨论强调了测试速度/隔离(通过 Mocking 实现)与长期可维护性之间的矛盾。 许多人同意**测试应该验证行为,而不是实现**,并且过度 Mocking 通常表明代码设计不良。 也有人建议使用真实的、隔离的数据库或容器化服务等替代方案。 最终,共识倾向于尽量减少 Mocking,并优先考虑促进可测试性的代码结构,而不要依赖于脆弱的 Mock。
相关文章

原文

Sunday 16 November 2025

An overly aggressive mock can work fine, but then break much later. Why?

In Why your mock doesn’t work I explained this rule of mocking:

Mock where the object is used, not where it’s defined.

That blog post explained why that rule was important: often a mock doesn’t work at all if you do it wrong. But in some cases, the mock will work even if you don’t follow this rule, and then it can break much later. Why?

Let’s say you have code like this:

# user.py

def get_user_settings() -> str:
    with open(Path("~/settings.json").expanduser()) as f:
        return json.load(f)

def add_two_settings() -> int:
    settings = get_user_settings()
    return settings["opt1"] + settings["opt2"]

You write a simple test:

def test_add_two_settings():
    # NOTE: need to create ~/settings.json for this to work:
    #   {"opt1": 10, "opt2": 7}
    assert add_two_settings() == 17

As the comment in the test points out, the test will only pass if you create the correct settings.json file in your home directory. This is bad: you don’t want to require finicky environments for your tests to pass.

The thing we want to avoid is opening a real file, so it’s a natural impulse to mock out open():

# test_user.py

from io import StringIO
from unittest.mock import patch

@patch("builtins.open")
def test_add_two_settings(mock_open):
    mock_open.return_value = StringIO('{"opt1": 10, "opt2": 7}')
    assert add_two_settings() == 17

Nice, the test works without needing to create a file in our home directory!

One day your test suite fails with an error like:

...
  File ".../site-packages/coverage/python.py", line 55, in get_python_source
    source_bytes = read_python_source(try_filename)
  File ".../site-packages/coverage/python.py", line 39, in read_python_source
    return source.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
           ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^
TypeErrorreplace() argument 1 must be str, not bytes

What happened!? Coverage.py code runs during your tests, invoked by the Python interpreter. The mock in the test changed the builtin open, so any use of it anywhere during the test is affected. In some cases, coverage.py needs to read your source code to record the execution properly. When that happens, coverage.py unknowingly uses the mocked open, and bad things happen.

When you use a mock, patch it where it’s used, not where it’s defined. In this case, the patch would be:

@patch("myproduct.user.open")
def test_add_two_settings(mock_open):
    ... etc ...

With a mock like this, the coverage.py code would be unaffected.

Keep in mind: it’s not just coverage.py that could trip over this mock. There could be other libraries used by your code, or you might use open yourself in another part of your product. Mocking the definition means anything using the object will be affected. Your intent is to only mock in one place, so target that place.

I decided to add some code to coverage.py to defend against this kind of over-mocking. There is a lot of over-mocking out there, and this problem only shows up in coverage.py with Python 3.14. It’s not happening to many people yet, but it will happen more and more as people start testing with 3.14. I didn’t want to have to answer this question many times, and I didn’t want to force people to fix their mocks.

From a certain perspective, I shouldn’t have to do this. They are in the wrong, not me. But this will reduce the overall friction in the universe. And the fix was really simple:

open = open

This is a top-level statement in my module, so it runs when the module is imported, long before any tests are run. The assignment to open will create a global in my module, using the current value of open, the one found in the builtins. This saves the original open for use in my module later, isolated from how builtins might be changed later.

This is an ad-hoc fix: it only defends one builtin. Mocking other builtins could still break coverage.py. But open is a common one, and this will keep things working smoothly for those cases. And there’s precedent: I’ve already been using a more involved technique to defend against mocking of the os module for ten years.

No blog post about mocking is complete without encouraging a number of other best practices, some of which could get you out of the mocking mess:

联系我们 contact @ memedata.com