Skip to content
aviral gupta

// 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.

Lesson 2 of 5 in I5 Testing and project tooling

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
  1. Warm-up · Activity 1 of 7

    Warm-up from the previous lesson: which statements about unittest are true? Pick all that apply.

    Select all that apply.

  2. 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)
  3. 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")
  4. 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")
  5. Practice · Activity 5 of 7

    Match each way of configuring a Mock to what a call does.

  6. 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))
  7. 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.py

Output

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
  1. Hint 1

    Wrap the first call in try and except ConnectionError. The except block makes the second attempt.

  2. Hint 2

    The second attempt needs its own try and except ConnectionError, which returns "offline".

  3. 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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 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
  1. Hint 1

    greeting.py says from users import lookup, so greet looks the name up in greeting. The target is "greeting.lookup".

  2. Hint 2

    with patch(...) as fake: gives you the mock. After the block, fake.assert_called_once_with(7).

  3. 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.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common 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 2

Why, 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.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A Mock stands in for anything

from unittest.mock import Mock gives you an object that accepts any call and almost any attribute: m.save, m.load.data and m(1, 2) all work, and each returns another Mock. Names with two underscores at both ends, such as __len__, raise AttributeError. You decide what it does. m.return_value = 3 makes every call return 3. m.side_effect goes further: an exception class or instance is raised when the mock is called, an iterable hands out its next value on each call, and a function is called with the same arguments and its result returned.

patch replaces a name for one test

patch("module.name") swaps a name in a module for a Mock, and when the with block or the decorated test ends, the original is put back. As a decorator, @patch passes the mock to the test as an extra argument. Patch where the name is looked up, not where it is defined: if shop.py does from rates import fetch_rate, shop holds its own name, so the target is "shop.fetch_rate". Patching "rates.fetch_rate" leaves shop’s copy untouched.

Checking how the mock was called

A mock records every call. m.called says whether it was called at all, m.call_count how often, and m.call_args holds the arguments of the last call, shown as call(2, key='x'). The assert methods fail the test with a clear message: assert_called_once_with(args) needs exactly one call with these arguments, assert_called_with checks only the last call, assert_any_call any call, and assert_not_called none at all. A name that starts with assert or a common misspelling of it (assret, asert, aseert, assrt) must be a real assert method, or AttributeError is raised. Other typos, such as asset_called_once_with, pass silently.

Sources

Last reviewed September 29, 2026