monkeypatch — controlar el entorno, el tiempo y la red
Probar código que depende de variables de entorno, el reloj, la aleatoriedad o la red. setenv, delenv, setattr, setitem, chdir, la restauración automática tras cada test, la regla de «parchear donde se busca», y la inyección de dependencias que no necesita parches en absoluto.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes una función que decide si una aplicación se ejecuta en modo depuración. Lee una variable de entorno:
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 FalseEjecuta pytest -q en tu terminal y pasará. Ejecútalo de nuevo en una máquina donde alguien haya exportado APP_DEBUG=1 —el portátil de un colega o un servidor de CI configurado para depuración— y el mismo test, con el mismo código, fallará:
$ 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.01sNada en el código cambió. Lo que cambió fue el mundo a su alrededor. Variables de entorno, la hora actual, números aleatorios, el directorio actual, la red: el código que lee cualquiera de estas cosas da una respuesta distinta según dónde y cuándo se ejecute, y un test que no las controla no está probando únicamente tu código.
El test necesita decir: mientras dure este test, el mundo tiene este aspecto —y después devolverlo todo a su lugar. Eso es lo que hace la fixture integrada monkeypatch.
Al terminar este capítulo podrás
- Definir y eliminar variables de entorno para un solo test con
monkeypatch.setenvymonkeypatch.delenv - Reemplazar una función con
monkeypatch.setattr, usando ya sea un objeto y un nombre o una cadena"module.attr" - Cambiar una clave de un diccionario con
monkeypatch.setitem, y el directorio de trabajo conmonkeypatch.chdir - Demostrar que cada cambio se deshace cuando el test termina
- Parchear un nombre donde se busca, y explicar por qué parchear
random.randintno afecta afrom random import randint - Pasar un reloj o una función aleatoria como argumento, de modo que no haga falta parchear en absoluto
Requisitos previos: fixtures integradas.
Antes de escribir el test
Con código como este, la mayor parte del trabajo se hace antes del primer def test_. Cuatro preguntas, en orden.
1. ¿Qué promete la función... y qué lee que tú no le pasaste? Lee la función y marca cada línea que toma algo de fuera de sus argumentos: os.environ, datetime.now(), random, una ruta de archivo relativa, un diccionario a nivel de módulo, una llamada de red. Cada una de ellas es una entrada oculta. La promesa de la función es «dadas estas entradas ocultas, devuelvo esto», y un test tiene que fijar cada entrada oculta; de lo contrario, estará probando lo que sea que la máquina contenga en ese momento.
2. ¿Qué debe estar listo? Lo habitual: un entorno virtual con pytest instalado y el módulo importable desde el test (el archivo de test a su lado o el proyecto instalado). Luego, para este tema, una decisión por cada entrada oculta:
- una variable de entorno: establécela con
setenv, o elimínala condelenvusandoraising=False; - una función que accede a la red, al disco o a una fuente aleatoria: reemplázala con
setattr, en el módulo que la llama; - un diccionario a nivel de módulo: cambia una clave con
setitem; - una ruta relativa: haz
chdirhacia untmp_path; - un reloj o una elección aleatoria en código que estés escribiendo ahora: pásalo como argumento en su lugar.
3. ¿Qué casos? Toma el cargador de configuración que construirás al final de este capítulo. Lee valores por defecto, luego un archivo opcional app.cfg, y después las variables de entorno APP_*. Su plan, antes de escribir nada de código:
| Caso | El mundo durante el test | Esperado | |---|---|---| | Nada definido | carpeta vacía, sin variables APP_* | puerto 8000, depuración False, región "bd" | | El archivo anula los valores por defecto | app.cfg contiene port = 9000 | puerto 9000 | | El entorno anula el archivo | app.cfg dice 9000, APP_PORT=7000 | puerto 7000 | | Un valor por defecto cambia | DEFAULTS["region"] es "ae" | región "ae" | | Valor no válido | APP_PORT=70000 | ValueError mencionando "out of range" | | Lector reemplazado | read_file devuelve {"port": "1234"} | puerto 1234 | | Ruido en tu shell | APP_PORT=5 exportada antes de ejecutar pytest | todas las filas anteriores se siguen cumpliendo |
La última fila es la que la gente olvida. No es un test independiente: es una propiedad de toda la suite, y es lo que determina que cada test deba limpiar las variables primero, no solo los que las definen.
4. Qué no probar. No pruebes que os.environ almacena cadenas ni que urllib puede obtener una página: ese es el trabajo de Python y ya está probado. No dejes que un test unitario toque la red real: es lento, falla por razones ajenas a tu código y el clima cambia. Y nunca hagas un assert sobre el propio fake. Este test pasa y no demuestra nada:
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.01sComprueba que el parche está aplicado, no que tu código funcione. El fake existe para que otro código —advice, más adelante— pueda probarse sobre él. Si el assert solo nombra lo que reemplazaste, borra el test.
Las secciones siguientes implementan este plan herramienta por herramienta.
setenv y delenv — el entorno, por test
monkeypatch es una fixture como tmp_path: pídela por su nombre en los parámetros del test. Aquí tienes cuatro tests para un settings.py un poco más grande:
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.01sLa segunda ejecución es la que importa. La shell exportó valores que habrían roto dos de estos tests, y los cuatro siguen pasando porque cada test declara el entorno que necesita.
setenv(name, value) define una variable. delenv(name) elimina una —y por defecto lanza KeyError si la variable no existía desde el principio. A un test para el caso «no definida» no le importa si estaba definida antes, solo que no esté definida ahora, por lo que raising=False es casi siempre lo que quieres.
Los valores son cadenas. El entorno solo contiene texto, y lo mismo ocurre con os.environ. Convertir "9000" en 9000 es tarea de tu código, y esa conversión es a menudo justo lo que vale la pena probar.
Todo se deshace — compruébalo
La afirmación es que monkeypatch restaura lo que cambió cuando el test finaliza. Aquí tienes la prueba:
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 deja pasar las llamadas a print):
inside first : 1
.inside second: None
.
2 passed in 0.01sEl segundo test no solicitó monkeypatch y aun así no ve nada: la variable ya no está. Compara lo que ocurre cuando escribes en os.environ directamente:
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.01sEl valor se ha filtrado al siguiente test, y a cada test posterior en la misma ejecución. Este es el tipo de error que hace que un test pase cuando se ejecuta solo y falle cuando se ejecuta con los demás —o peor aún, al revés.
No hay magia en cómo se deshace. Antes de cada cambio, monkeypatch registra el valor anterior —o el hecho de que no existía ninguno— y durante el desmontaje restaura cada valor registrado, en orden inverso. El desmontaje se ejecuta tanto si el test pasó, falló o lanzó una excepción, por lo que un assert que falle a mitad de camino no puede dejar sucio el entorno. Una asignación escrita a mano os.environ[...] = ... seguida de una línea de limpieza escrita a mano al final del test sería omitida precisamente por ese assert que falla.
setattr — reemplazar una función para un test
Las variables de entorno son el caso sencillo. El caso más difícil es el código que se comunica con la red:
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"La lógica interesante está en advice: los umbrales, los mensajes. Pruébala tal cual y el test dependerá de un servidor, una conexión de red y el clima de hoy. En una máquina sin red:
$ 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 intercambia fetch_temperature por algo que tú controlas, mientras dure un solo 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.01sHay dos formas de indicar el objetivo, y hacen lo mismo:
setattr(weather, "fetch_temperature", new)— el objeto, y luego el nombre del atributo como cadena.setattr("weather.fetch_temperature", new)— una cadena con puntos; pytest importaweathery separa la última parte.
Usa la forma de objeto cuando el test ya importe el módulo; usa la forma de cadena cuando no necesite hacerlo. De cualquier modo, observa qué se está probando ahora: no fetch_temperature, que fue lo que reemplazamos, sino los umbrales y mensajes de advice, que es el código que nosotros escribimos. Sin red, sin esperas, la misma respuesta cada vez —y una temperatura de 35 o 4 bajo demanda, algo que el clima real nunca te daría a pedido.
¿Por qué usar setattr y no simplemente weather.fetch_temperature = lambda city: 35 en el test? Por la misma razón que con os.environ: la asignación directa nunca se deshace, y cada test posterior en la ejecución recibiría el fake.
La regla: parchear donde se busca
Ahora el error que todo el mundo comete alguna vez. Un módulo pequeño que lanza un dado:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Para probar el premio mayor (jackpot), fuerza a randint a devolver 6. Vive en random, así que lo parcheas allí:
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.01sEl parche se aplicó y dice nunca lo vio. Ejecuta el test unas cuantas veces más y a veces pasará —siempre que el randint real dé la casualidad de sacar un 6. Un test que es incorrecto y ocasionalmente pasa en verde es peor que uno que siempre falla en rojo.
La razón es lo que hace realmente from random import randint. No crea un enlace a random.randint. Copia la referencia en un nombre nuevo, dice.randint, en el momento de la importación. Después de eso hay dos nombres que apuntan a la misma función, y setattr mueve solo uno de ellos:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseCuando roll() se ejecuta, Python busca randint en el módulo dice —ahí es donde el nombre se busca. Por lo tanto, ahí es donde tiene que ser reemplazado:
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.01sCinco ejecuciones seguidas, cinco tests pasados. La otra solución es cambiar la forma en que dice.py realiza la importación. Con import random y una llamada a random.randint(1, 6), la búsqueda ocurre en el módulo random en cada llamada, y entonces parchear random.randint sí funciona:
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.01sLa regla en una sola frase: encuentra la línea que llama a la función, mira de qué módulo obtiene el nombre y parchea el nombre allí.
setitem — una clave de un diccionario
La configuración a menudo se guarda en un diccionario a nivel de módulo. Reemplazar todo el diccionario sería engorroso; setitem cambia una clave y la restaura después:
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 ===============================El tercer test es la demostración de nuevo: después de que dos tests modificaran una clave cada uno, el diccionario está exactamente como empezó. Su homólogo, monkeypatch.delitem(d, key), elimina una clave para un solo test.
chdir y syspath_prepend
El código que abre una ruta relativa como "notes.txt" depende del directorio desde el que se ejecuta. Combina chdir con tmp_path del capítulo anterior:
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 traslada el directorio de trabajo para un solo test y lo devuelve a su lugar después. El segundo test muestra syspath_prepend en su única línea de uso: coloca una carpeta al principio de sys.path para que un módulo escrito allí pueda ser importado, y la vuelve a quitar al final —muy útil al probar código que carga plugins desde una carpeta.
Mejor aún: pasar la dependencia como argumento
Parchear funciona, pero cada parche es una pequeña confesión de que el código fue hacia afuera a buscar algo del mundo exterior. Algunas cosas ni siquiera se pueden parchear cómodamente. datetime.now es una de ellas:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'La alternativa es una decisión de diseño: permitir que la función reciba aquello de lo que depende, teniendo la versión real como valor por defecto:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21El código de producción llama a is_open() y obtiene el reloj real. Un test pasa su propio reloj:
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.01sSin fixtures, sin nada que deshacer, sin preguntarse en qué lugar se busca un nombre. El test se lee como una frase sencilla —«a las 8:59 la tienda está cerrada»— y es fácil acertar con exactitud en los minutos límite que importan.
La misma estructura funciona para la aleatoriedad: def pick_winner(customers, choose=random.choice), y un test pasa choose=lambda items: items[0]. Esto se conoce como inyección de dependencias (dependency injection), y no necesita ningún framework: un argumento por defecto es suficiente. Úsala cuando escribas el código. Recurre a monkeypatch cuando estés probando código que no puedes o no deberías cambiar, o cuando la dependencia esté enterrada a varios niveles de llamadas de profundidad.
Un ejemplo completo
Este es el cargador de configuración planificado en «Antes de escribir el test»: tres capas —valores por defecto integrados, un archivo opcional app.cfg en el directorio actual y variables de entorno que anulan a ambos. Cada fila del plan se convierte en un 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 ===============================Hay tres cosas que vale la pena notar.
Primero, la fixture clean_env usa monkeypatch internamente. Una fixture y el test que la solicita comparten un solo objeto monkeypatch, por lo que el test puede agregar su propio setenv por encima, y cada cambio de ambos se deshace en conjunto al final.
Segundo, la fixture elimina las variables en lugar de confiar en que no estén presentes. Eso es lo que hace que la suite dé la misma respuesta con APP_PORT=5 APP_REGION=xx pytest -q que sin ellas —pruébalo; sigue diciendo 6 passed. Esa es la última fila del plan, satisfecha por diseño en lugar de por un test independiente.
Tercero, el último test parchea app_config.read_file: el módulo donde load_config busca el nombre. Dado que ambas funciones residen en el mismo módulo, ese es el único lugar que importa.
Cuando algo falla
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Una errata en el nombre del atributo. setattr se niega a crear un atributo que no exista previamente, lo que te protege de «parchear» un nombre que nadie leerá jamás. La forma de cadena lo expresa de otra manera —AttributeError: 'module' object at weather has no attribute 'fetch_temprature'— pero significa lo mismo. Pasa raising=False únicamente si tu intención real es añadir un atributo nuevo.
El parche está aplicado, pero la función real se sigue ejecutando Ningún error en absoluto: el test falla con el valor real, o pasa por pura suerte. Parcheaste el nombre donde está definido (random.randint) en lugar de donde se busca (dice.randint). Mira la línea de importación en el código bajo prueba.
KeyError: 'APP_DEBUG' desde monkeypatch.delenv La variable no estaba definida, y delenv lanza una excepción por defecto. Añade raising=False. Lo mismo se aplica a delitem y delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly Escribiste setenv("APP_PORT", 9000). Las variables de entorno son texto; escribe "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' A los tipos integrados como datetime.datetime no se les pueden reemplazar los métodos. Pasa el reloj como argumento en su lugar, como en el ejemplo de is_open.
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. Una fixture con scope="module" (o "session") solicitó monkeypatch, que vive únicamente para un test. Mantén la fixture con alcance de función, o usa pytest.MonkeyPatch.context() dentro de la fixture de mayor alcance y gestiona la restauración tú mismo.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Ejecuta con pytest -q -s. ¿Qué muestran las dos llamadas a print (CITY no está definida de antemano)?
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, luego un error: KeyError: 'CITY'
- Bnone, luego none
- CDhaka, luego none
- DDhaka, luego Dhaka
Este test falla la mayor parte del tiempo y pasa de vez en cuando. ¿Qué está mal?
# 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- ANecesita `setitem` en lugar de `setattr`
- BLa `lambda` no debería tomar dos parámetros
- C`monkeypatch` no puede parchear módulos de la biblioteca estándar
- DEl parche está en `random`, pero `roll()` busca el nombre en `dice` — parchea `dice.randint`
¿Qué línea reemplaza mejor a ..., de modo que los tests posteriores vuelvan a ver 0.15?
# 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- Amonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Bmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dshop.SETTINGS["tax_rate"] = 0.0
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe greeter.py con una función greeting(name) que devuelva:
"Good morning, <name>"antes de las 12:00,"Good afternoon, <name>"desde las 12:00 hasta las 18:00, y"Good evening, <name>"después de eso- en mayúsculas cuando la variable de entorno
GREETER_LOUDsea"1"
y un diccionario a nivel de módulo TEMPLATES = {"morning": "Good morning", ...} del cual la función lee sus palabras.
Luego escribe test_greeter.py, que:
- Use
setenvydelenv(..., raising=False)para probar los casos con y sin mayúsculas, y pase tanto si tu shell tieneGREETER_LOUDdefinida como si no - Use
setitempara cambiar el saludo de la mañana a"Shubho shokal"para un solo test, y un segundo test que demuestre queTEMPLATESha vuelto a la normalidad - Pruebe los límites de tiempo: 11:59, 12:00, 17:59, 18:00
Para el paso 3, escríbelo dos veces. Primero con from datetime import datetime dentro de greeter.py, e intenta monkeypatch.setattr(datetime, "now", ...) —fíjate exactamente en qué error obtienes. (Reemplazar el nombre completo greeter.datetime con una pequeña clase simulada propia sí funciona; observa cuánto código requiere). Luego cambia greeting para que acepte now=datetime.now y pruébalo pasándole un reloj.
¿Cuál de las dos versiones preferirías conservar? La segunda es la respuesta que da la mayoría de los testers con experiencia, y tras probar ambas sabrás por qué por tu propia experiencia práctica, no por esta página.
Solución
Empieza con el plan, como en «Antes de escribir el test». Las entradas ocultas de greeting son el reloj, la variable GREETER_LOUD y el diccionario TEMPLATES. Los casos: los cuatro minutos límite, en minúsculas y mayúsculas, una plantilla modificada y una comprobación de que la plantilla volvió a su estado inicial.
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 ===============================Y con la variable exportada en la shell, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sEl porqué de cada decisión:
- El reloj es un argumento, no un parche. El primer intento,
monkeypatch.setattr(datetime, "now", ...), se detiene conTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. En lugar de construir una clasedatetimesimulada,greetingaceptanow=datetime.now. Las llamadas reales escribengreeting("Rina")y no notan nada; los tests pasan un momento exacto. at(hour, minute)construye el reloj. Una pequeña función auxiliar mantiene cada test en una sola línea legible. Devuelve una función, porquegreetingllama anow()—pasar un objetodatetimedirectamente fallaría conTypeError: 'datetime.datetime' object is not callable.- Límites en pares. 11:59 y 12:00, 17:59 y 18:00: un minuto a cada lado de cada límite. Si alguien escribe
hour <= 12por error, el caso de las 12:00 lo detecta. quietes una fixture, no una suposición. Cada test cuyo texto esperado esté en mayúsculas y minúsculas la solicita, porque depende de queGREETER_LOUDno esté definida. Por eso la segunda ejecución, con la variable exportada, sigue dando ocho tests pasados.test_loudno la necesita: define la variable él mismo.setitem, y luego la comprobación.test_custom_morning_wordcambia una clave;test_templates_are_backno solicita ninguna fixture y confirma que la clave ha vuelto, haciendo visible la restauración.- Qué no se prueba: que
str.upper()funcione, ni quedatetime.now()devuelva la hora actual. Ambos pertenecen a Python.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz