Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.
A patch must replace the name in the module under test, because a from-import binds its own reference that the original module never consults again. Fixture scope is a shared lifetime, parametrisation belongs on the inputs rather than the assertions, and coverage records execution, not verification.
What the interviewer is scoring
- That patching is explained as rebinding a name in a namespace, which is why the target string matters more than the object
- Whether a widened fixture scope is recognised as shared mutable state between tests
- Can they say why a session-scoped fixture cannot request monkeypatch or tmp_path
- Does the candidate parametrise the inputs while keeping a single assertion, rather than branching inside the test body
- Do they treat a coverage percentage as a map of untested code rather than as a quality score
Answer
Start with why the patch missed
mock.patch("some.module.thing") does one thing: it imports some.module and rebinds the attribute thing on it, restoring the original afterwards. It does not follow the object around. So the target string has to name the namespace in which the code under test performs the lookup, and that is frequently not the module where the thing is defined.
The distinction is entirely about how the import was written. import json then json.dumps(...) looks the attribute up on the json module every call, so patching json.dumps works. from json import dumps copies the reference into your module's globals at import time, and your code never touches the json module again — so patching json.dumps succeeds, changes nothing you can observe, and your test quietly runs the real function.
import unittest.mock as mock
from json import dumps # 'dumps' is now a global of THIS module
def render(payload):
return dumps(payload) # resolved here, not in the json module
with mock.patch("json.dumps", return_value="patched"):
print(render({})) # '{}' - the patch was applied where nobody looks
with mock.patch(f"{__name__}.dumps", return_value="patched"):
print(render({})) # 'patched' - patched where the name is resolved
The rule to state out loud is "patch where it is looked up, not where it is defined". The corollary is a diagnostic: if a patch appears to have no effect, the first thing to check is not the mock's configuration but whether the target string names the right namespace. A patch that is never used is silent, which is why this particular bug survives code review.
While there: prefer autospec=True or create_autospec. A bare Mock accepts any call with any arguments and returns another Mock, so a test can go on passing after you rename a parameter on the real function. An autospecced mock validates the signature and fails instead.
Fixtures are injection with a teardown
A fixture is a function whose return value is injected into any test that names it as a parameter, and whose code after yield runs as teardown even if the test fails. Fixtures may request other fixtures, which is how you compose a database, a schema and a seeded row without any test knowing the assembly order. conftest.py makes them visible to everything below it in the tree, with no import.
The scope argument carries the risk, because it is not a performance dial — it decides how much state tests share. function, the default, builds a fresh object per test; class, module and package widen that; session builds one for the entire run. Widening scope to speed up a slow suite is legitimate and common. What makes it dangerous is that a test which mutates a session-scoped object has changed the input to every later test, so the resulting failure depends on ordering and disappears when you run the failing test alone.
pytest enforces the one structural rule for you: a broader-scoped fixture cannot depend on a narrower-scoped one, and asking for that raises ScopeMismatch rather than doing something surprising. This is why a session-scoped fixture cannot request monkeypatch or tmp_path, both of which are function-scoped by design — you use tmp_path_factory, or construct a pytest.MonkeyPatch yourself and undo it in the teardown.
import sqlite3
import pytest
@pytest.fixture(scope="session")
def db_path(tmp_path_factory): # session-safe; tmp_path would not be
path = tmp_path_factory.mktemp("data") / "app.db"
with sqlite3.connect(path) as setup:
setup.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY)")
return path
@pytest.fixture # function-scoped on purpose
def conn(db_path):
connection = sqlite3.connect(db_path)
yield connection
connection.rollback() # so no test can see another's rows
connection.close()
def test_insert_is_isolated(conn):
conn.execute("INSERT INTO orders (id) VALUES (1)")
assert conn.execute("SELECT count(*) FROM orders").fetchone() == (1,)
Parametrise the inputs, not the assertions
@pytest.mark.parametrize turns one test function into one reported test per case, which matters because a loop inside a test body stops at the first failure and reports one name for eight scenarios. Give the cases readable ids when the values do not read well in a report, use pytest.param(..., marks=pytest.mark.xfail(reason=...)) to record a known-broken case without disabling the whole test, and stack two decorators when you genuinely want the cross product.
What parametrisation must not become is a table of inputs with a matching table of expected outputs computed by a second implementation of the logic. If the test body contains an if on the parameter, you have two tests wearing one name, and the shared body will drift until neither case is really checked. Split them.
Reading coverage honestly
Coverage records which lines the interpreter executed while the tests ran. It says nothing about whether anything was asserted. A test that calls a function inside pytest.raises and checks nothing else will happily report the whole function covered, and a suite with no assertions at all can reach a hundred percent. So use coverage the way it is actually informative: as a map of the code no test touches, which is a list of places where you have no signal. Read the uncovered lines, not the number.
Two refinements matter. Branch coverage, enabled with --cov-branch, distinguishes "this if executed" from "both outcomes were taken", and it is the version worth gating on. And a coverage target applied to the diff rather than the whole repository asks for tests on what you just wrote, instead of rewarding a sprint of tests against legacy code nobody is changing. If the question is whether the assertions are real, that needs a different tool: mutation testing changes the code and checks that some test notices.
Likely follow-ups
- When would you use monkeypatch.setattr instead of unittest.mock.patch, and when the reverse?
- What does autospec buy you, and what bug does a plain Mock let through?
- How would you parametrise a case that is expected to fail without disabling the whole test?
- Why can branch coverage fall while line coverage stays at a hundred percent?
Related questions
- Pick one input field from something you have worked on and design its tests with equivalence partitioning and boundary values.mediumAlso on coverage5 min
- How do you test a Node service, and what do you refuse to mock?mediumAlso on mocking5 min
- A test passes on your machine and fails in CI roughly once a week. How do you track it down?mediumAlso on pytest5 min
- You own the API test suite for a service from scratch. How do you structure it, and what do you mock?hardAlso on mocking7 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumSame kind of round: concept4 min
- How do you remove elements from a collection while iterating it, and what makes an iterator fail-fast?mediumSame kind of round: concept5 min