How to Tell if Your Python Mock Is Actually Working 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. How to Tell if Your Python Mock Is Actually Working Working on something challenging? I coach developers 1:1 on the judgment behind the code, not just the syntax. How it works → A 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. This 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. The test that passed for the wrong reason The code under test calls the ExchangeRate API and raises CurrencyConversionError when the response signals failure: php def convert currency amount: Decimal, from currency: str, to currency: str - Decimal: if from currency == to currency: return amount response = requests.get f"https://v6.exchangerate-api.com/v6/{EXCHANGE RATE API KEY}/pair/{from currency}/{to currency}" data = response.json if data "result" = "success": raise CurrencyConversionError f"{data 'error-type' }" return Decimal data "conversion rate" amount The test set up a mock response , patched requests.get to return it mock get.return value = mock response , but configured it as a successful response: mock response.json.return value = { "result": "success", <-- this will never raise CurrencyConversionError "conversion rate": 1.5, } If the mock was intercepting, the function would return normally and pytest.raises would 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. Proving the mock actually intercepted My instinct was to add print "calling external api" before requests.get . That proves the code reached that line. It does not prove whether the mock intercepted the call or the real network was hit. At this point you can put a breakpoint in the actual requests.get code in your venv, but there is a better way: mock get.assert called once : with pytest.raises CurrencyConversionError : convert currency amount=Decimal "1.00" , from currency="CAD", to currency="CTM", Canadian Tire Money, not a real currency mock get.assert called once If 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. Running 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 failed with DID NOT RAISE . 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 then confirmed the call went through the mock and not the network: mock get.return value.json.return value = { "result": "error", "error-type": "unknown-code", } Patch where the name is used, not where it's defined The currency module does import requests then calls requests.get ... , so patching expenses ai agent.utils.currency.requests.get targets the call site. With this import requests style, patching requests.get happens to work too, since both names point at the same module object. The rule bites when a module does from requests import get : now get is a local name in the currency module, and you must patch expenses ai agent.utils.currency.get , not requests.get . Patching the wrong location is a common mistake that leads to the mock not intercepting and the real API being called. The cleaned-up test with pytest-mock Once the mock response was correct and interception was verified, the test got two more improvements. First, the intermediate mock response variable is unnecessary: chain directly off mock get.return value , as in the snippet above. Second, pytest-mock added with uv add --dev pytest-mock replaces the nested with patch ... context managers with a mocker fixture. The result is flatter and easier to scan. Annotated: python def test bad currency conversion raises self, mocker : """Converting to a non-existing currency should raise an exception.""" Patch requests.get as imported inside the currency module so no real HTTP call is made; patch target must match where the name is used mock get = mocker.patch "expenses ai agent.utils.currency.requests.get" Simulate the API response for an unrecognised currency code mock get.return value.json.return value = { "result": "error", "error-type": "unknown-code", } with pytest.raises CurrencyConversionError : convert currency amount=Decimal "1.00" , from currency="CAD", to currency="CTM", Confirm the mock intercepted the call; if this fails, the real API was hit mock get.assert called once mocker also handles teardown automatically via the fixture lifecycle, so you don't need with to ensure cleanup. Another reason to mock: forcing a collision So 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: python def create contact name: str, email: str = "", company: str = "", product: str = "" - str: contacts dir .mkdir parents=True, exist ok=True code = next code name path = contact path code if path.exists : raise FileExistsError f"Contact {code} already exists" path.write text ... return code next code generates a unique code from the name. To test that creating two contacts with the same code raises FileExistsError , you need both calls to produce the same code. That's nondeterministic by design, so you patch next code to pin it: python @patch "crm.data.next code" def test cannot create contact with same code mock next code : mock next code.return value = "jd1" data.create contact "Jane Doe" with pytest.raises FileExistsError : data.create contact "Jane Doe" Note the patch target again: crm.data.next code , where the function is used . Same rule as before. And note that's the only mock here. Isolation 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 : python @pytest.fixture autouse=True def crm data tmp path, monkeypatch : monkeypatch.setenv "CRM DATA", str tmp path tmp path / "contacts" .mkdir return tmp path create contact calls path.write text ... , so the first call writes a real jd1 file. Because every test runs against a fresh tmp path , 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 from a previous run makes the first call raise, pytest.raises still passes, and you've tested nothing. Update: I later dropped this mock for an explicit override parameter. Instead of patching next code , I gave create contact an optional code parameter keyword-only, so it can't be passed by accident : python def create contact name: str, , email: str = "", company: str = "", product: str = "", code: str | None = None - str: ... code = code if code is not None else next code name The test pins the code through the public surface, no patching: python def test cannot create contact with same code : data.create contact "Jane Doe" with pytest.raises FileExistsError : data.create contact "Jane Doe", code="jd1" One 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 itself 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. The 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 doubles 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. Unit vs integration: where does this test belong? All this then led to a related question: How should you organize tests that hit real external services? The convention that holds up in practice: tests/ ├── unit/ fast, fully mocked, no network, no secrets └── integration/ slower, hits real DB / LLM / API endpoints The currency test above belongs in unit/ : it mocks requests.get and never touches the network. A test that actually calls the ExchangeRate API to verify end-to-end behavior belongs in integration/ . A @pytest.mark.integration marker is a lighter-weight way to get the same split without moving files. Register it in pyproject.toml , then skip those tests in CI with pytest -m 'not integration' . Both work, but the directory structure makes the distinction obvious at a glance. Explicit is better than implicit. The 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 . For 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 . When mocks are the wrong tool There's a broader point underneath all this. Every time you patch requests.get you're writing a test that's tightly coupled to one import path. Change import requests to from requests import get and every patch breaks. The tests test implementation, not behavior. I 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. Mocks are still the right choice here: we want to test one small unit whose only external dependency is well contained. Keep reading Tutorials 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/