{"slug": "how-to-tell-if-your-python-mock-is-actually-working", "title": "How to Tell if Your Python Mock Is Actually Working", "summary": "A Python test that mocks a third-party API call can pass for the wrong reason if the mock does not actually intercept the call. Developers can verify mock interception by adding `mock_get.assert_called_once()` after the test assertion, ensuring the mock was invoked instead of the real API. The article also emphasizes patching the name where it is used, not where it is defined, to avoid silent test failures.", "body_md": "# How to Tell if Your Python Mock Is Actually Working\n\n*Working on something challenging? I coach developers 1:1 on the judgment behind the code, not just the syntax. How it works →*\n\nA test can pass for the wrong reason. When you're mocking a third-party API call, the test might look green because the real API happened to return an error, not because your mock did anything at all.\n\nThis came up in a recent session in our [agentic AI cohort](https://pythonagenticai.com) where we were looking at a test to verify that converting to an invalid currency raised an exception. The test passed. But something felt off.\n\n## The test that passed for the wrong reason\n\nThe code under test calls the ExchangeRate API and raises `CurrencyConversionError`\n\nwhen the response signals failure:\n\n``` php\ndef convert_currency(amount: Decimal, from_currency: str, to_currency: str) -> Decimal:\n    if from_currency == to_currency:\n        return amount\n    response = requests.get(\n        f\"https://v6.exchangerate-api.com/v6/{EXCHANGE_RATE_API_KEY}/pair/{from_currency}/{to_currency}\"\n    )\n    data = response.json()\n    if data[\"result\"] != \"success\":\n        raise CurrencyConversionError(f\"{data['error-type']}\")\n    return Decimal(data[\"conversion_rate\"]) * amount\n```\n\nThe test set up a `mock_response`\n\n, patched `requests.get`\n\nto return it (`mock_get.return_value = mock_response`\n\n), but configured it as a *successful* response:\n\n```\nmock_response.json.return_value = {\n    \"result\": \"success\",   # <-- this will never raise CurrencyConversionError\n    \"conversion_rate\": 1.5,\n}\n```\n\nIf the mock was intercepting, the function would return normally and `pytest.raises`\n\nwould fail. But the test was passing. That meant the mock wasn't intercepting at all: the real API was being hit, and it was returning an error for the bogus \"CTM\" code.\n\n## Proving the mock actually intercepted\n\nMy instinct was to add `print(\"calling external api\")`\n\nbefore `requests.get`\n\n. That proves the code reached that line. It does not prove whether the mock intercepted the call or the real network was hit.\n\nAt this point you can put a `breakpoint()`\n\nin the actual `requests.get`\n\ncode in your venv, but there is a better way: `mock_get.assert_called_once()`\n\n:\n\n```\nwith pytest.raises(CurrencyConversionError):\n    convert_currency(\n        amount=Decimal(\"1.00\"),\n        from_currency=\"CAD\",\n        to_currency=\"CTM\",  # Canadian Tire Money, not a real currency\n    )\nmock_get.assert_called_once()\n```\n\nIf the mock was never called, this assertion fails and tells you directly: your patch didn't intercept the request. If the mock was called, the assertion passes and you know for sure that the test is relying on the mock, not the real API.\n\nRunning the test with this assertion in place settled it. Once the patch targeted the right name (the fix in the next section), the mock intercepted the call and `pytest.raises`\n\nfailed with `DID NOT RAISE`\n\n. That flip is the proof: a real call for \"CTM\" would have raised, so a non-raising run means the mock was in control. The earlier green had been the real API answering, never the mock. With the success response still in place, nothing raised. Fixing the response to signal an error made the test pass for the right reason, and `assert_called_once()`\n\nthen confirmed the call went through the mock and not the network:\n\n```\nmock_get.return_value.json.return_value = {\n    \"result\": \"error\",\n    \"error-type\": \"unknown-code\",\n}\n```\n\n## Patch where the name is used, not where it's defined\n\nThe currency module does `import requests`\n\nthen calls `requests.get(...)`\n\n, so patching `expenses_ai_agent.utils.currency.requests.get`\n\ntargets the call site. With this `import requests`\n\nstyle, patching `requests.get`\n\nhappens to work too, since both names point at the same module object. The rule bites when a module does `from requests import get`\n\n: now `get`\n\nis a local name in the currency module, and you must patch `expenses_ai_agent.utils.currency.get`\n\n, not `requests.get`\n\n. Patching the wrong location is a common mistake that leads to the mock not intercepting and the real API being called.\n\n## The cleaned-up test with pytest-mock\n\nOnce the mock response was correct and interception was verified, the test got two more improvements. First, the intermediate `mock_response`\n\nvariable is unnecessary: chain directly off `mock_get.return_value`\n\n, as in the snippet above. Second, `pytest-mock`\n\n(added with `uv add --dev pytest-mock`\n\n) replaces the nested `with patch(...)`\n\ncontext managers with a `mocker`\n\nfixture. The result is flatter and easier to scan. Annotated:\n\n``` python\ndef test_bad_currency_conversion_raises(self, mocker):\n    \"\"\"Converting to a non-existing currency should raise an exception.\"\"\"\n    # Patch requests.get *as imported inside the currency module* so no\n    # real HTTP call is made; patch target must match where the name is used\n    mock_get = mocker.patch(\"expenses_ai_agent.utils.currency.requests.get\")\n    # Simulate the API response for an unrecognised currency code\n    mock_get.return_value.json.return_value = {\n        \"result\": \"error\",\n        \"error-type\": \"unknown-code\",\n    }\n\n    with pytest.raises(CurrencyConversionError):\n        convert_currency(\n            amount=Decimal(\"1.00\"),\n            from_currency=\"CAD\",\n            to_currency=\"CTM\",\n        )\n    # Confirm the mock intercepted the call; if this fails, the real API was hit\n    mock_get.assert_called_once()\n```\n\n`mocker`\n\nalso handles teardown automatically via the fixture lifecycle, so you don't need `with`\n\nto ensure cleanup.\n\n## Another reason to mock: forcing a collision\n\nSo far the mock has stood in for a network call. That's not the only reason to reach for one. Here's a test from [my simple CRM](/blog/build-the-simplest-thing-that-works/) that stores contacts as files on disk:\n\n``` python\ndef create_contact(\n    name: str, email: str = \"\", company: str = \"\", product: str = \"\"\n) -> str:\n    contacts_dir().mkdir(parents=True, exist_ok=True)\n    code = next_code(name)\n    path = contact_path(code)\n    if path.exists():\n        raise FileExistsError(f\"Contact {code} already exists\")\n    path.write_text(...)\n    return code\n```\n\n`next_code`\n\ngenerates a unique code from the name. To test that creating two contacts with the same code raises `FileExistsError`\n\n, you need both calls to produce the *same* code. That's nondeterministic by design, so you patch `next_code`\n\nto pin it:\n\n``` python\n@patch(\"crm.data.next_code\")\ndef test_cannot_create_contact_with_same_code(mock_next_code):\n    mock_next_code.return_value = \"jd1\"\n    data.create_contact(\"Jane Doe\")\n    with pytest.raises(FileExistsError):\n        data.create_contact(\"Jane Doe\")\n```\n\nNote the patch target again: `crm.data.next_code`\n\n, where the function is *used*. Same rule as before. And note that's the *only* mock here.\n\nIsolation matters as much as the mock, but it doesn't belong in this test. An autouse fixture already points the data dir at a fresh `tmp_path`\n\n:\n\n``` python\n@pytest.fixture(autouse=True)\ndef crm_data(tmp_path, monkeypatch):\n    monkeypatch.setenv(\"CRM_DATA\", str(tmp_path))\n    (tmp_path / \"contacts\").mkdir()\n    return tmp_path\n```\n\n`create_contact`\n\ncalls `path.write_text(...)`\n\n, so the first call writes a real `jd1`\n\nfile. Because every test runs against a fresh `tmp_path`\n\n, that file lives only for the test: the collision can only come from the second call, nothing leaks between runs, and the test fails solely when the duplicate guard fires. Without that isolation, a leftover `jd1`\n\nfrom a previous run makes the *first* call raise, `pytest.raises`\n\nstill passes, and you've tested nothing.\n\n**Update: I later dropped this mock for an explicit override parameter.** Instead of patching `next_code`\n\n, I gave `create_contact`\n\nan optional `code`\n\nparameter (keyword-only, so it can't be passed by accident):\n\n``` python\ndef create_contact(name: str, *, email: str = \"\", company: str = \"\",\n                    product: str = \"\", code: str | None = None) -> str:\n    ...\n    code = code if code is not None else next_code(name)\n```\n\nThe test pins the code through the public surface, no patching:\n\n``` python\ndef test_cannot_create_contact_with_same_code():\n    data.create_contact(\"Jane Doe\")\n    with pytest.raises(FileExistsError):\n        data.create_contact(\"Jane Doe\", code=\"jd1\")\n```\n\nOne naming caveat, since this post points to Harry Percival's \"Stop Using Mocks\" below: this isn't dependency injection, tempting as it is to call it that. DI would pass `next_code`\n\nitself in and let the test swap a fake. Here I pass the *value* the dependency would have produced, so it's really an explicit override parameter, the simpler tool. Real DI, with an injected collaborator, comes up at the end of this post.\n\nThe trade-off is worth being honest about: I added a production parameter partly to make the test simpler. That's the \"test-induced design damage\" critics of mocking warn about: a seam that exists *only* to serve tests. I think it's justified here because `code`\n\ndoubles as a real feature: an explicit-code escape hatch for imports or restoring from backup. The test just happens to use it. If the parameter was only added for the test, I'd consider leaving the mock.\n\n## Unit vs integration: where does this test belong?\n\nAll this then led to a related question:\n\nHow should you organize tests that hit real external services?\n\nThe convention that holds up in practice:\n\n```\ntests/\n├── unit/        # fast, fully mocked, no network, no secrets\n└── integration/ # slower, hits real DB / LLM / API endpoints\n```\n\nThe currency test above belongs in `unit/`\n\n: it mocks `requests.get`\n\nand never touches the network. A test that actually calls the ExchangeRate API to verify end-to-end behavior belongs in `integration/`\n\n.\n\nA `@pytest.mark.integration`\n\nmarker is a lighter-weight way to get the same split without moving files. Register it in `pyproject.toml`\n\n, then skip those tests in CI with `pytest -m 'not integration'`\n\n.\n\nBoth work, but the directory structure makes the distinction obvious at a glance. Explicit is better than implicit.\n\nThe practical rule: if your test needs an environment variable or some external service to do its real work, it's an integration test. Mock that dependency out and it becomes a unit test. Or [put it at the boundary](/blog/repository-pattern-swappable-data-sources/) so you can inject a fake in unit tests and the real thing in integration tests (if still needed).\n\nFor a practical example of test organization, see this video: [Python Unit vs. Functional Testing: Understanding the Difference + Practical Example](https://www.youtube.com/watch?v=krb9b6eRinY).\n\n## When mocks are the wrong tool\n\nThere's a broader point underneath all this. Every time you patch `requests.get`\n\nyou're writing a test that's tightly coupled to one import path. Change `import requests`\n\nto `from requests import get`\n\nand every patch breaks. The tests test implementation, not behavior.\n\nI highly recommend watching [Harry Percival's PyCon talk \"Stop Using Mocks\"](https://www.youtube.com/watch?v=rk-f3B-eMkI). He makes the case for alternatives: build an adapter class that owns the external call, write a fake in-memory implementation of it, and use dependency injection to pass it in. The [repository pattern](/blog/repository-pattern-swappable-data-sources/) is the same idea: your test passes in a fake, your production code passes in the real thing, and neither needs patching.\n\nMocks are still the right choice here: we want to test one small unit whose only external dependency is well contained.\n\n## Keep reading\n\nTutorials teach syntax. Courses teach patterns. AI gives unvetted code. None of them review *your* decisions on *your* code. That's what 1:1 coaching is for. [Here's how it works →](/coaching/)", "url": "https://wpnews.pro/news/how-to-tell-if-your-python-mock-is-actually-working", "canonical_source": "https://belderbos.dev/blog/python-mock-patch-verify-interception/", "published_at": "2026-06-03 00:00:00+00:00", "updated_at": "2026-06-17 10:01:29.542595+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Python", "ExchangeRate API", "CurrencyConversionError", "requests"], "alternates": {"html": "https://wpnews.pro/news/how-to-tell-if-your-python-mock-is-actually-working", "markdown": "https://wpnews.pro/news/how-to-tell-if-your-python-mock-is-actually-working.md", "text": "https://wpnews.pro/news/how-to-tell-if-your-python-mock-is-actually-working.txt", "jsonld": "https://wpnews.pro/news/how-to-tell-if-your-python-mock-is-actually-working.jsonld"}}