monkeypatch — controlling the environment, time and the network
Testing code that depends on environment variables, the clock, randomness or the network. setenv, delenv, setattr, setitem, chdir, the automatic undo after every test, the "patch where it is looked up" rule, and dependency injection that needs no patching at all.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
Here is a function that decides whether an application runs in debug mode. It reads an environment variable:
settings.py:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"test_settings.py:
from settings import debug_enabled
def test_debug_is_off_by_default():
assert debug_enabled() is FalseRun pytest -q in your terminal and it passes. Run it again on a machine where someone has exported APP_DEBUG=1 — a colleague's laptop, or a CI server set up for debugging — and the same test, on the same code, fails:
$ pytest -q
. [100%]
1 passed in 0.01s
$ APP_DEBUG=1 pytest -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_debug_is_off_by_default _________________________
def test_debug_is_off_by_default():
> assert debug_enabled() is False
E assert True is False
E + where True = debug_enabled()
test_settings.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_settings.py::test_debug_is_off_by_default - assert True is False
1 failed in 0.01sNothing in the code changed. What changed was the world around it. Environment variables, the current time, random numbers, the current directory, the network — code that reads any of these gives a different answer depending on where and when it runs, and a test that does not control them is not testing your code alone.
The test needs to say: for the length of this test, the world looks like this — and then put everything back. That is what the built-in monkeypatch fixture does.
By the end of this chapter you can
- Set and remove environment variables for one test with
monkeypatch.setenvandmonkeypatch.delenv - Replace a function with
monkeypatch.setattr, using either an object and a name or a"module.attr"string - Change one key of a dictionary with
monkeypatch.setitem, and the working directory withmonkeypatch.chdir - Show that every change is undone when the test ends
- Patch a name where it is looked up, and explain why patching
random.randintmissesfrom random import randint - Pass a clock or a random function in as an argument, so that no patching is needed at all
Prerequisites: built-in fixtures.
Before you write the test
With code like this, most of the work happens before the first def test_. Four questions, in order.
1. What does the function promise — and what does it read that you did not pass in? Read the function and mark every line that takes something from outside its arguments: os.environ, datetime.now(), random, a relative file path, a module-level dictionary, a network call. Each one is a hidden input. The promise of the function is "given these hidden inputs, I return this", and a test has to fix every hidden input, or it is testing whatever the machine happens to hold.
2. What must be in place? The usual: a virtual environment with pytest installed, and the module importable from the test (the test file beside it, or the project installed). Then, for this topic, one decision per hidden input:
- an environment variable —
setenvit, ordelenvit withraising=False; - a function that reaches the network, the disk or a random source — replace it with
setattr, in the module that calls it; - a module-level dictionary — change one key with
setitem; - a relative path —
chdirinto atmp_path; - a clock or random choice in code you are writing now — pass it in as an argument instead.
3. Which cases? Take the configuration loader you will build at the end of this chapter. It reads defaults, then an optional app.cfg, then APP_* environment variables. Its plan, before any code:
| Case | The world during the test | Expected | |---|---|---| | Nothing set | empty folder, no APP_* variables | port 8000, debug False, region "bd" | | File overrides defaults | app.cfg contains port = 9000 | port 9000 | | Environment overrides file | app.cfg says 9000, APP_PORT=7000 | port 7000 | | A default changes | DEFAULTS["region"] is "ae" | region "ae" | | Invalid value | APP_PORT=70000 | ValueError mentioning "out of range" | | Reader swapped out | read_file returns {"port": "1234"} | port 1234 | | Noise in your shell | APP_PORT=5 exported before running pytest | every row above still holds |
The last row is the one people forget. It is not a test of its own — it is a property of the whole suite, and it is what decides that every test must clear the variables first, not only the ones that set them.
4. What not to test. Do not test that os.environ stores strings or that urllib can fetch a page — that is Python's job, and it is tested already. Do not let a unit test touch the real network: it is slow, it fails for reasons unrelated to your code, and the weather changes. And never assert on the fake itself. This test passes and proves nothing:
import weather
def test_pointless(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.fetch_temperature("Dhaka") == 35. [100%]
1 passed in 0.01sIt checks that the patch is in place, not that any of your code works. The fake exists so that other code — advice, below — can be tested on top of it. If the assertion names only the thing you replaced, delete the test.
The sections that follow implement this plan one tool at a time.
setenv and delenv — the environment, per test
monkeypatch is a fixture like tmp_path: ask for it by name in the test's parameters. Here are four tests for a slightly larger settings.py:
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"
def api_port():
return int(os.environ.get("APP_PORT", "8000"))from settings import api_port, debug_enabled
def test_debug_on(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert debug_enabled() is True
def test_debug_off_by_default(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert debug_enabled() is False
def test_port_from_env(monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
assert api_port() == 9000
def test_port_default(monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
assert api_port() == 8000$ pytest -q
.... [100%]
4 passed in 0.01s
$ APP_DEBUG=1 APP_PORT=1234 pytest -q
.... [100%]
4 passed in 0.01sThe second run is the one that matters. The shell exported values that would have broken two of these tests, and all four still pass, because each test states the environment it needs.
setenv(name, value) sets a variable. delenv(name) removes one — and by default it raises KeyError if the variable was not there to begin with. A test for the "not set" case does not care whether it was set, only that it is not set now, so raising=False is what you almost always want.
Values are strings. The environment holds only text, and so does os.environ. Converting "9000" into 9000 is your code's job, and that conversion is often exactly what is worth testing.
Everything is undone — prove it
The claim is that monkeypatch restores what it changed when the test finishes. Here is the evidence:
import os
def test_first(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))pytest -q -s (-s lets print through):
inside first : 1
.inside second: None
.
2 passed in 0.01sThe second test did not ask for monkeypatch and still sees nothing — the variable is gone. Compare what happens when you write to os.environ directly:
import os
def test_first():
os.environ["APP_DEBUG"] = "1"
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))inside first : 1
.inside second: 1
.
2 passed in 0.01sThe value has leaked into the next test, and into every test after it in the same run. This is the kind of bug that makes a test pass when run alone and fail when run with the others — or worse, the other way round.
There is no magic in the undo. Before each change, monkeypatch records the old value — or the fact that there was none — and during teardown it puts every recorded value back, in reverse order. Teardown runs whether the test passed, failed or raised, so a failing assertion halfway through cannot leave the environment dirty. A hand-written os.environ[...] = ... followed by a hand-written clean-up line at the end of the test would be skipped by exactly that failing assertion.
setattr — replace a function for one test
Environment variables are the gentle case. The harder one is code that talks to the network:
weather.py:
import json
import urllib.request
def fetch_temperature(city):
url = f"https://api.example.com/weather?city={city}"
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)["temp_c"]
def advice(city):
temp = fetch_temperature(city)
if temp >= 30:
return "Hot: carry water"
if temp <= 10:
return "Cold: take a jacket"
return "Mild"The interesting logic is in advice: the thresholds, the messages. Test it as it is and the test depends on a server, a network connection and today's weather. On a machine with no network:
$ pytest -q --tb=line test_real.py
F [100%]
=================================== FAILURES ===================================
E socket.gaierror: [Errno -5] No address associated with hostname
During handling of the above exception, another exception occurred:
E urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
/usr/lib/python3.12/urllib/request.py:1347: urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
1 failed in 0.01smonkeypatch.setattr swaps fetch_temperature for something you control, for the length of one test:
import weather
def test_hot_day(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.advice("Dhaka") == "Hot: carry water"
def test_cold_day(monkeypatch):
monkeypatch.setattr("weather.fetch_temperature", lambda city: 4)
assert weather.advice("Oslo") == "Cold: take a jacket".. [100%]
2 passed in 0.01sThere are two ways to name the target, and they do the same thing:
setattr(weather, "fetch_temperature", new)— the object, then the attribute name as a string.setattr("weather.fetch_temperature", new)— one dotted string; pytest importsweatherand splits off the last part.
Use the object form when the test already imports the module; use the string form when it does not need to. Either way, notice what is being tested now: not fetch_temperature, which we replaced, but the thresholds and messages in advice, which is the code we wrote. No network, no waiting, the same answer every time — and a temperature of 35 or 4 on demand, which the real weather would never give you on cue.
Why setattr and not just weather.fetch_temperature = lambda city: 35 in the test? For the same reason as with os.environ: the plain assignment is never undone, and every later test in the run would get the fake.
The rule: patch where it is looked up
Now the mistake that everyone makes once. A small module that rolls a die:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6To test the jackpot, force randint to return 6. It lives in random, so patch it there:
import random
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.is_jackpot() is TrueF [100%]
=================================== FAILURES ===================================
_________________________________ test_jackpot _________________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fc1b2fdc9b0>
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
> assert dice.is_jackpot() is True
E assert False is True
E + where False = <function is_jackpot at 0x7fc1b2fcd800>()
E + where <function is_jackpot at 0x7fc1b2fcd800> = dice.is_jackpot
test_dice.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_dice.py::test_jackpot - assert False is True
1 failed in 0.01sThe patch was applied, and dice never saw it. Run the test a few more times and it will sometimes pass — whenever the real randint happens to roll a 6. A test that is wrong and occasionally green is worse than one that is always red.
The reason is what from random import randint actually does. It does not create a link to random.randint. It copies the reference into a new name, dice.randint, at import time. After that there are two names pointing at the same function, and setattr moves only one of them:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseWhen roll() runs, Python looks up randint in the module dice — that is where the name is looked up. So that is where it has to be replaced:
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(dice, "randint", lambda a, b: 6)
assert dice.is_jackpot() is True
def test_no_jackpot(monkeypatch):
monkeypatch.setattr("dice.randint", lambda a, b: 3)
assert dice.is_jackpot() is False.. [100%]
2 passed in 0.01sFive runs in a row, five passes. The other fix is to change how dice.py imports. With import random and a call to random.randint(1, 6), the lookup happens in the module random on every call, and patching random.randint then works:
import random
def roll():
return random.randint(1, 6)
def is_jackpot():
return roll() == 6$ pytest -q # the original test, patching random.randint
. [100%]
1 passed in 0.01sThe rule in one sentence: find the line that calls the function, see which module it gets the name from, and patch the name there.
setitem — one key of a dictionary
Settings are often kept in a module-level dictionary. Replacing the whole dictionary would be clumsy; setitem changes one key and restores it afterwards:
shop.py:
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
def price_with_tax(amount):
total = round(amount * (1 + SETTINGS["tax_rate"]), 2)
return f"{total} {SETTINGS['currency']}"import shop
def test_tax_free_day(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
assert shop.price_with_tax(100) == "100.0 BDT"
def test_other_currency(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "currency", "INR")
assert shop.price_with_tax(100) == "115.0 INR"
def test_settings_are_back():
assert shop.SETTINGS == {"currency": "BDT", "tax_rate": 0.15}pytest -v:
test_shop.py::test_tax_free_day PASSED [ 33%]
test_shop.py::test_other_currency PASSED [ 66%]
test_shop.py::test_settings_are_back PASSED [100%]
============================== 3 passed in 0.01s ===============================The third test is the proof again: after two tests each changed a key, the dictionary is exactly as it started. Its partner, monkeypatch.delitem(d, key), removes a key for one test.
chdir and syspath_prepend
Code that opens a relative path like "notes.txt" depends on the directory it runs from. Combine chdir with tmp_path from the last chapter:
notes.py:
from pathlib import Path
def read_notes():
return Path("notes.txt").read_text().splitlines()from pathlib import Path
from notes import read_notes
def test_reads_from_current_directory(tmp_path, monkeypatch):
(tmp_path / "notes.txt").write_text("buy milk\ncall Rina\n")
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
assert read_notes() == ["buy milk", "call Rina"]
def test_import_from_a_new_folder(tmp_path, monkeypatch):
(tmp_path / "helper.py").write_text("ANSWER = 42\n")
monkeypatch.syspath_prepend(tmp_path)
import helper
assert helper.ANSWER == 42.. [100%]
2 passed in 0.01schdir moves the working directory for one test and moves it back afterwards. The second test shows syspath_prepend in its one line of use: it puts a folder at the front of sys.path so a module written there can be imported, and removes it again at the end — handy when testing code that loads plugins from a folder.
Better still: pass the dependency in
Patching works, but every patch is a small admission that the code reached out and grabbed something from the world. Some things cannot be patched comfortably at all. datetime.now is one:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'The alternative is a design choice: let the function receive the thing it depends on, with the real one as the default:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Production code calls is_open() and gets the real clock. A test passes its own clock:
from datetime import datetime
from shop_hours import is_open
def test_closed_just_before_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 8, 59)) is False
def test_open_at_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 9, 0)) is True
def test_closed_at_nine_pm():
assert is_open(now=lambda: datetime(2026, 1, 5, 21, 0)) is False... [100%]
3 passed in 0.01sNo fixture, nothing to undo, no question of where a name is looked up. The test reads as a plain sentence — "at 8:59 the shop is closed" — and the boundary minutes that matter are easy to hit exactly.
The same shape works for randomness: def pick_winner(customers, choose=random.choice), and a test passes choose=lambda items: items[0]. This is called dependency injection, and it needs no framework: a default argument is enough. Use it when you write the code. Reach for monkeypatch when you are testing code you cannot or should not change, or when the dependency is buried several calls deep.
A complete example
This is the configuration loader planned in "Before you write the test": three layers — built-in defaults, an optional app.cfg file in the current directory, and environment variables that override both. Each row of the plan becomes one test.
app_config.py:
import os
from pathlib import Path
DEFAULTS = {"port": "8000", "debug": "0", "region": "bd"}
def read_file(name="app.cfg"):
path = Path(name)
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_config():
# Lowest priority first: defaults, then the file, then the environment.
raw = dict(DEFAULTS)
raw.update(read_file())
for key in raw:
env_name = "APP_" + key.upper()
if env_name in os.environ:
raw[key] = os.environ[env_name]
port = int(raw["port"])
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return {"port": port, "debug": raw["debug"] == "1", "region": raw["region"]}test_app_config.py:
import pytest
import app_config
@pytest.fixture
def clean_env(tmp_path, monkeypatch):
# An empty folder and no APP_ variables: the test starts from nothing.
monkeypatch.chdir(tmp_path)
for name in ("APP_PORT", "APP_DEBUG", "APP_REGION"):
monkeypatch.delenv(name, raising=False)
return tmp_path
def test_defaults(clean_env):
assert app_config.load_config() == {"port": 8000, "debug": False, "region": "bd"}
def test_file_beats_defaults(clean_env):
(clean_env / "app.cfg").write_text("port = 9000\nregion = in\n")
config = app_config.load_config()
assert config["port"] == 9000
assert config["region"] == "in"
def test_env_beats_file(clean_env, monkeypatch):
(clean_env / "app.cfg").write_text("port = 9000\n")
monkeypatch.setenv("APP_PORT", "7000")
assert app_config.load_config()["port"] == 7000
def test_changed_default(clean_env, monkeypatch):
monkeypatch.setitem(app_config.DEFAULTS, "region", "ae")
assert app_config.load_config()["region"] == "ae"
def test_bad_port(clean_env, monkeypatch):
monkeypatch.setenv("APP_PORT", "70000")
with pytest.raises(ValueError, match="out of range"):
app_config.load_config()
def test_reader_can_be_replaced(clean_env, monkeypatch):
monkeypatch.setattr(app_config, "read_file", lambda: {"port": "1234"})
assert app_config.load_config()["port"] == 1234pytest -v:
collected 6 items
test_app_config.py::test_defaults PASSED [ 16%]
test_app_config.py::test_file_beats_defaults PASSED [ 33%]
test_app_config.py::test_env_beats_file PASSED [ 50%]
test_app_config.py::test_changed_default PASSED [ 66%]
test_app_config.py::test_bad_port PASSED [ 83%]
test_app_config.py::test_reader_can_be_replaced PASSED [100%]
============================== 6 passed in 0.01s ===============================Three things are worth noticing.
First, the clean_env fixture uses monkeypatch itself. A fixture and the test that requests it share one monkeypatch object, so the test can add its own setenv on top, and every change from both is undone together at the end.
Second, the fixture removes the variables instead of trusting that they are absent. That is what makes the suite give the same answer with APP_PORT=5 APP_REGION=xx pytest -q as without — try it; it still says 6 passed. That is the last row of the plan, satisfied by design rather than by a test of its own.
Third, the last test patches app_config.read_file — the module where load_config looks the name up. Because both functions live in the same module, that is the one place that matters.
When it breaks
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' A typo in the attribute name. setattr refuses to create an attribute that does not already exist, which protects you from "patching" a name nobody ever reads. The string form says it differently — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — but means the same. Pass raising=False only if you really mean to add a new attribute.
The patch is applied but the real function still runs No error at all: the test fails with the real value, or passes by luck. You patched the name where it is defined (random.randint) instead of where it is looked up (dice.randint). Look at the import line in the code under test.
KeyError: 'APP_DEBUG' from monkeypatch.delenv The variable was not set, and delenv raises by default. Add raising=False. The same applies to delitem and delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly You wrote setenv("APP_PORT", 9000). Environment variables are text; write "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' Built-in types such as datetime.datetime cannot have their methods replaced. Pass the clock in as an argument instead, as in the is_open example.
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. A fixture with scope="module" (or "session") asked for monkeypatch, which lives for one test only. Keep the fixture at function scope, or use pytest.MonkeyPatch.context() inside the wider fixture and manage the undo yourself.
Step 4 of 6 — Predict
Check your understanding
Run with pytest -q -s. What do the two print calls show (CITY is not set beforehand)?
import os
def test_one(monkeypatch):
monkeypatch.setenv("CITY", "Dhaka")
print(os.environ.get("CITY", "none"))
def test_two():
print(os.environ.get("CITY", "none"))- ADhaka, then Dhaka
- Bnone, then none
- CDhaka, then none
- DDhaka, then an error: KeyError: 'CITY'
This test fails most of the time and passes now and then. What is wrong?
# dice.py
from random import randint
def roll():
return randint(1, 6)
# test_dice.py
import random
import dice
def test_roll_six(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.roll() == 6- AThe `lambda` should not take two parameters
- BThe patch is on `random`, but `roll()` looks the name up in `dice` — patch `dice.randint`
- C`monkeypatch` cannot patch standard-library modules
- DIt needs `setitem` instead of `setattr`
Which line best replaces ..., so that later tests see 0.15 again?
# shop.py
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
# test_shop.py
import shop
def test_tax_free(monkeypatch):
... # set tax_rate to 0.0 for this test only- Ashop.SETTINGS["tax_rate"] = 0.0
- Bmonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Write greeter.py with a function greeting(name) that returns:
"Good morning, <name>"before 12:00,"Good afternoon, <name>"from 12:00 until 18:00, and"Good evening, <name>"after that- in upper case when the environment variable
GREETER_LOUDis"1"
and a module-level dictionary TEMPLATES = {"morning": "Good morning", ...} that the function reads its words from.
Then write test_greeter.py that:
- Uses
setenvanddelenv(..., raising=False)to test the loud and quiet cases, and passes whether or not your shell hasGREETER_LOUDset - Uses
setitemto change the morning word to"Shubho shokal"for one test, and a second test that provesTEMPLATESis back to normal - Tests the time boundaries — 11:59, 12:00, 17:59, 18:00
For step 3, write it twice. First with from datetime import datetime inside greeter.py, and try monkeypatch.setattr(datetime, "now", ...) — note exactly what error you get. (Replacing the whole name greeter.datetime with a small fake class of your own does work; notice how much code that takes.) Then change greeting to accept now=datetime.now and test it by passing a clock.
Which of the two versions would you rather keep? The second one is the answer most experienced testers give, and after trying both you will know why from your own hands, not from this page.
Solution
Start with the plan, as in "Before you write the test". The hidden inputs of greeting are the clock, the GREETER_LOUD variable and the TEMPLATES dictionary. The cases: the four boundary minutes, quiet and loud, a changed template, and a check that the template came back.
greeter.py:
import os
from datetime import datetime
TEMPLATES = {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
}
def greeting(name, now=datetime.now):
hour = now().hour
if hour < 12:
part = "morning"
elif hour < 18:
part = "afternoon"
else:
part = "evening"
text = f"{TEMPLATES[part]}, {name}"
if os.environ.get("GREETER_LOUD") == "1":
text = text.upper()
return texttest_greeter.py:
from datetime import datetime
import pytest
import greeter
def at(hour, minute=0):
# A fake clock: a function that always returns the same moment.
return lambda: datetime(2026, 1, 5, hour, minute)
@pytest.fixture
def quiet(monkeypatch):
monkeypatch.delenv("GREETER_LOUD", raising=False)
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(11, 59, "Good morning, Rina"),
(12, 0, "Good afternoon, Rina"),
(17, 59, "Good afternoon, Rina"),
(18, 0, "Good evening, Rina"),
],
)
def test_time_boundaries(quiet, hour, minute, expected):
assert greeter.greeting("Rina", now=at(hour, minute)) == expected
def test_quiet_by_default(quiet):
assert greeter.greeting("Arif", now=at(9)) == "Good morning, Arif"
def test_loud(monkeypatch):
monkeypatch.setenv("GREETER_LOUD", "1")
assert greeter.greeting("Arif", now=at(9)) == "GOOD MORNING, ARIF"
def test_custom_morning_word(quiet, monkeypatch):
monkeypatch.setitem(greeter.TEMPLATES, "morning", "Shubho shokal")
assert greeter.greeting("Maya", now=at(8)) == "Shubho shokal, Maya"
def test_templates_are_back():
assert greeter.TEMPLATES["morning"] == "Good morning"pytest -v:
collected 8 items
test_greeter.py::test_time_boundaries[11-59-Good morning, Rina] PASSED [ 12%]
test_greeter.py::test_time_boundaries[12-0-Good afternoon, Rina] PASSED [ 25%]
test_greeter.py::test_time_boundaries[17-59-Good afternoon, Rina] PASSED [ 37%]
test_greeter.py::test_time_boundaries[18-0-Good evening, Rina] PASSED [ 50%]
test_greeter.py::test_quiet_by_default PASSED [ 62%]
test_greeter.py::test_loud PASSED [ 75%]
test_greeter.py::test_custom_morning_word PASSED [ 87%]
test_greeter.py::test_templates_are_back PASSED [100%]
============================== 8 passed in 0.01s ===============================And with the variable exported in the shell, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sThe decisions behind it:
- The clock is an argument, not a patch. The first attempt,
monkeypatch.setattr(datetime, "now", ...), stops withTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. Rather than build a fakedatetimeclass,greetingacceptsnow=datetime.now. Real callers writegreeting("Rina")and notice nothing; tests pass an exact moment. at(hour, minute)builds the clock. A tiny helper keeps each test to one readable line. It returns a function, becausegreetingcallsnow()— passing adatetimeobject directly would fail withTypeError: 'datetime.datetime' object is not callable.- Boundaries in pairs. 11:59 and 12:00, 17:59 and 18:00: one minute either side of each edge. If someone writes
hour <= 12by mistake, the 12:00 case catches it. quietis a fixture, not an assumption. Every test whose expected text is mixed case asks for it, because it depends onGREETER_LOUDbeing absent. That is why the second run, with the variable exported, still gives eight passes.test_louddoes not need it — it sets the variable itself.setitem, then proof.test_custom_morning_wordchanges one key;test_templates_are_backasks for no fixture at all and confirms the key is back, which is the undo made visible.- Not tested: that
str.upper()works, or thatdatetime.now()returns the current time. Both belong to Python.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz