Warm-up · Activity 1 of 7
// I5.2 · ~30 min · Intermediate
Test doubles with unittest.mock
After this lesson you can replace a slow or unreliable dependency with a Mock, make it return values or raise errors, patch it in the right module, and check how your code called it.
You will be able to
- Create a Mock and configure it with return_value and side_effect
- Replace a dependency during a test with patch, patching the name where it is looked up
- Check how a mock was called with called, call_count, call_args and assert_called_once_with
Predict · Activity 2 of 7
Predict before you read on: weather is a Mock, not a real service. What does this print?
from unittest.mock import Mock weather = Mock() weather.today.return_value = "sunny" print(weather.today("Berlin"), weather.today.call_count)Practice · Activity 3 of 7
Fill in the attribute so that calling api.fetch raises ConnectionError("offline"), as a real network call might.
api.fetch.____ = ConnectionError("offline")api.fetch. = ConnectionError("offline")Practice · Activity 4 of 7
side_effect is a list. What does this print?
from unittest.mock import Mock m = Mock(side_effect=[1, 2]) print(m(), m()) try: m() except StopIteration: print("no values left")Practice · Activity 5 of 7
Match each way of configuring a Mock to what a call does.
Brain teaser · Activity 6 of 7
Brain teaser. The code patches math.floor. What does it print?
import math from math import floor from unittest.mock import patch with patch("math.floor", return_value=99): print(math.floor(1.5), floor(1.5))Apply · Activity 7 of 7
Mini-task. send_welcome(email, mailer) checks the address and calls mailer.send(email, "Welcome!"). Test it without sending mail: pass a Mock as mailer. One test checks that send was called once with the right arguments; another checks that an address without @ raises ValueError and sends nothing.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Prices without a network
shop.price_in converts a price with an exchange rate from rates.fetch_rate, which would need the network. The tests patch the rate: once as a decorator with a fixed return_value, once as a context manager with side_effect=ConnectionError. The third test shows the trap: it patches rates.fetch_rate, but shop imported its own name, so the real function runs and raises RuntimeError.
main.py
import unittest
from unittest.mock import patch
import shop
class TestPriceIn(unittest.TestCase):
@patch("shop.fetch_rate", return_value=1.1)
def test_converts(self, fake_rate):
self.assertEqual(shop.price_in(1000, "USD"), "11.00 USD")
fake_rate.assert_called_once_with("USD")
def test_offline(self):
with patch("shop.fetch_rate", side_effect=ConnectionError):
self.assertEqual(shop.price_in(1000, "USD"), "price unavailable")
def test_wrong_target(self):
# rates.fetch_rate is replaced, but shop already holds its own name
with patch("rates.fetch_rate", return_value=1.1):
with self.assertRaises(RuntimeError):
shop.price_in(1000, "USD")
suite = unittest.TestLoader().loadTestsFromTestCase(TestPriceIn)
result = unittest.TestResult()
suite.run(result)
print("tests run:", result.testsRun)
print("all passed:", result.wasSuccessful())
print("after the tests, shop.fetch_rate is a", type(shop.fetch_rate).__name__)
shop.py
from rates import fetch_rate
def price_in(cents: int, currency: str) -> str:
"""A price in euro cents, shown in another currency."""
try:
rate = fetch_rate(currency)
except ConnectionError:
return "price unavailable"
return f"{cents * rate / 100:.2f} {currency}"
rates.py
def fetch_rate(currency: str) -> float:
"""Would ask a web service for today's exchange rate."""
raise RuntimeError("no network access in tests")
Run it with
python main.pyOutput
tests run: 3
all passed: True
after the tests, shop.fetch_rate is a function- The decorator hands the mock to test_converts as fake_rate, which the test uses to check the call.
- side_effect=ConnectionError makes the mock raise, so the offline branch of price_in is tested without pulling a network cable.
- test_wrong_target passes only because it expects the RuntimeError of the real fetch_rate.
- The last line shows that each patch was undone: shop.fetch_rate is the real function again.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
Retry once
Write fetch_with_retry(fetch, url). It calls fetch(url) and returns the result. If fetch raises ConnectionError, it tries exactly once more; if that fails too, it returns "offline". Other exceptions must pass through untouched. The tests pass in Mocks whose side_effect raises or returns values, and count the calls. Run shows a flaky fetch that fails once.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
Wrap the first call in try and except ConnectionError. The except block makes the second attempt.
Hint 2
The second attempt needs its own try and except ConnectionError, which returns "offline".
Hint 3
Catch only ConnectionError: a ValueError must leave the function after one call.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
from collections.abc import Callable
from unittest.mock import Mock
def fetch_with_retry(fetch: Callable[[str], str], url: str) -> str:
try:
return fetch(url)
except ConnectionError:
try:
return fetch(url)
except ConnectionError:
return "offline"
if __name__ == "__main__":
flaky = Mock(side_effect=[ConnectionError("down"), "<html>hello</html>"])
print(fetch_with_retry(flaky, "https://example.com"))
print("calls:", flaky.call_count)
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
from collections.abc import Callable
from unittest.mock import Mock
def fetch_with_retry(fetch: Callable[[str], str], url: str) -> str:
# Call fetch(url). If it raises ConnectionError, try exactly once more.
# If the second try fails too, return "offline".
return fetch(url)
if __name__ == "__main__":
flaky = Mock(side_effect=[ConnectionError("down"), "<html>hello</html>"])
print(fetch_with_retry(flaky, "https://example.com"))
print("calls:", flaky.call_count)
test_main.py
from unittest.mock import Mock
from main import fetch_with_retry
def test_first_try():
"""A working fetch is called once and its result returned"""
fetch = Mock(return_value="page")
got = fetch_with_retry(fetch, "u")
assert got == "page", f"returned {got!r}, expected 'page'"
assert fetch.call_count == 1, f"fetch was called {fetch.call_count} times, expected 1"
fetch.assert_called_once_with("u")
def test_retry_succeeds():
"""One ConnectionError, then success: two calls"""
fetch = Mock(side_effect=[ConnectionError("down"), "page"])
got = fetch_with_retry(fetch, "u")
assert got == "page", f"returned {got!r}, expected 'page'"
assert fetch.call_count == 2, f"fetch was called {fetch.call_count} times, expected 2"
def test_offline():
"""Two ConnectionErrors give "offline", and no third try"""
fetch = Mock(side_effect=ConnectionError("down"))
got = fetch_with_retry(fetch, "u")
assert got == "offline", f"returned {got!r}, expected 'offline'"
assert fetch.call_count == 2, f"fetch was called {fetch.call_count} times, expected 2"
def test_other_errors_pass():
"""Other exceptions are not caught or retried"""
fetch = Mock(side_effect=ValueError("bad url"))
try:
fetch_with_retry(fetch, "u")
except ValueError:
pass
else:
assert False, "a ValueError from fetch was swallowed; catch only ConnectionError"
assert fetch.call_count == 1, f"fetch was called {fetch.call_count} times after a ValueError, expected 1"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
Patch the right name
greeting.greet(user_id) gets the user from users.lookup, which needs a database. Finish TestGreet. Fix the patch target, so the test uses a mock instead of the real lookup, and check that lookup was called once with 7. Then add test_unknown_user: lookup raises KeyError, and greet returns "Hello, guest!". Call greet as greeting.greet. The checks also run your tests against broken versions of greet.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
greeting.py says from users import lookup, so greet looks the name up in greeting. The target is "greeting.lookup".
Hint 2
with patch(...) as fake: gives you the mock. After the block, fake.assert_called_once_with(7).
Hint 3
For the unknown user: with patch("greeting.lookup", side_effect=KeyError(7)): and then assertEqual on greeting.greet(7).
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import unittest
from unittest.mock import patch
import greeting
class TestGreet(unittest.TestCase):
def test_known_user(self) -> None:
with patch("greeting.lookup", return_value={"name": "Ada"}) as fake:
self.assertEqual(greeting.greet(7), "Hello, Ada!")
fake.assert_called_once_with(7)
def test_unknown_user(self) -> None:
with patch("greeting.lookup", side_effect=KeyError(7)):
self.assertEqual(greeting.greet(7), "Hello, guest!")
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestGreet)
unittest.TextTestRunner(verbosity=2).run(suite)
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import unittest
from unittest.mock import patch
import greeting
class TestGreet(unittest.TestCase):
def test_known_user(self) -> None:
# 1. This patches the wrong name: greeting.py has its own "lookup".
with patch("users.lookup", return_value={"name": "Ada"}) as fake:
self.assertEqual(greeting.greet(7), "Hello, Ada!")
# 2. Check that lookup was called exactly once, with 7.
# 3. Add test_unknown_user: lookup raises KeyError, greet says "Hello, guest!".
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestGreet)
unittest.TextTestRunner(verbosity=2).run(suite)
test_main.py
import unittest
import greeting
import main
def run_against(implementation):
saved = greeting.greet
greeting.greet = implementation
try:
result = unittest.TestResult()
unittest.TestLoader().loadTestsFromTestCase(main.TestGreet).run(result)
finally:
greeting.greet = saved
return result
def ignores_name(user_id):
greeting.lookup(user_id)
return "Hello, user!"
def no_guest(user_id):
user = greeting.lookup(user_id)
return f"Hello, {user['name']}!"
def wrong_id(user_id):
try:
user = greeting.lookup(user_id + 1)
except KeyError:
return "Hello, guest!"
return f"Hello, {user['name']}!"
def test_correct_code_passes():
"""Your tests pass on the real greet"""
result = run_against(greeting.greet)
problems = [trace.strip().splitlines()[-1] for _, trace in result.failures + result.errors]
assert result.wasSuccessful(), f"your tests fail on correct code: {problems}"
assert result.testsRun >= 2, f"{result.testsRun} tests ran; write at least 2"
def test_catches_ignored_name():
"""Your tests catch a greet that ignores the user's name"""
result = run_against(ignores_name)
assert not result.wasSuccessful(), "every test passes when greet always says 'Hello, user!'"
def test_catches_missing_guest():
"""Your tests catch a greet that crashes for an unknown user"""
result = run_against(no_guest)
assert not result.wasSuccessful(), "every test passes when greet lets the KeyError escape; test an unknown user"
def test_catches_wrong_id():
"""Your tests check the id that lookup was called with"""
result = run_against(wrong_id)
assert not result.wasSuccessful(), "every test passes when greet looks up the wrong id; use assert_called_once_with(7)"
greeting.py
from users import lookup
def greet(user_id: int) -> str:
try:
user = lookup(user_id)
except KeyError:
return "Hello, guest!"
return f"Hello, {user['name']}!"
users.py
def lookup(user_id: int) -> dict[str, str]:
"""Would read the user from a database."""
raise RuntimeError("the user database is not available in tests")
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Patching where the function is defined
from math import floor
from unittest.mock import patch
with patch("math.floor", return_value=0):
result = floor(2.5)
assert result == 0, f"floor was not replaced: got {result}"
What Python prints
AssertionError: floor was not replaced: got 2Why, and the fix
from math import floor gave this module its own name floor, bound to the real function before the patch. patch("math.floor") changes only the attribute on math. Patch the name where your code looks it up: in a module that did from math import floor, that is "yourmodule.floor". Or write import math and call math.floor.
An assert method without assert
from unittest.mock import Mock
mailer = Mock()
mailer.send("ada@example.com")
mailer.send.called_once_with("ada@example.com")
What Python prints
AttributeError: 'called_once_with' is not a valid assertion. Use a spec for the mock if 'called_once_with' is meant to be an attribute.. Did you mean: 'assert_called_once_with'?Why, and the fix
A Mock creates any attribute you ask for, so a misspelt check would be just another call that can never fail. Mock refuses a few names like this one that look like a forgotten assert_, and the message ends by naming the real method, assert_called_once_with. Check the spelling yourself; a typo such as asset_called_once_with passes silently.
A target without a module
from unittest.mock import patch
def fetch_rate(currency):
raise RuntimeError("no network")
with patch("fetch_rate", return_value=1.1):
print(fetch_rate("USD"))
What Python prints
TypeError: Need a valid target to patch. You supplied: 'fetch_rate'Why, and the fix
patch needs a dotted path, "module.name", so it can import the module and replace the attribute. Put the code under test in its own module, such as shop.py, and patch "shop.fetch_rate". To replace an attribute of an object you already hold, use patch.object(obj, "name").
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.