monkeypatch — controllare l'ambiente, il tempo e la rete
Testare codice che dipende da variabili d'ambiente, dall'orologio, dal caso o dalla rete. setenv, delenv, setattr, setitem, chdir, il ripristino automatico dopo ogni test, la regola "applica la patch dove il nome viene cercato" e la dependency injection che non richiede alcun patching.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco una funzione che stabilisce se un'applicazione è eseguita in modalità di debug. Legge una variabile d'ambiente:
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 FalseEsegui pytest -q nel tuo terminale e il test passa. Eseguilo di nuovo su una macchina in cui qualcuno ha esportato APP_DEBUG=1 — il laptop di un collega o un server di CI configurato per il debug — e lo stesso test, sullo stesso codice, fallisce:
$ 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.01sNulla nel codice è cambiato. Ciò che è cambiato è il mondo circostante. Variabili d'ambiente, l'ora corrente, numeri casuali, la cartella di lavoro, la rete — il codice che legge uno qualsiasi di questi elementi restituisce una risposta diversa a seconda di dove e quando viene eseguito, e un test che non li controlla non sta testando solo il tuo codice.
Il test deve poter dire: per la durata di questo test, il mondo appare così — e poi rimettere tutto a posto. Questo è esattamente ciò che fa la fixture integrata monkeypatch.
Alla fine di questo capitolo sarai in grado di
- Impostare e rimuovere variabili d'ambiente per un singolo test con
monkeypatch.setenvemonkeypatch.delenv - Sostituire una funzione con
monkeypatch.setattr, usando un oggetto e un nome oppure una stringa"module.attr" - Modificare una chiave di un dizionario con
monkeypatch.setitem, e la cartella di lavoro conmonkeypatch.chdir - Dimostrare che ogni modifica viene annullata al termine del test
- Applicare la patch a un nome nel punto in cui viene cercato, e spiegare perché applicare la patch a
random.randintnon ha effetto sufrom random import randint - Passare un orologio o una funzione casuale come argomento, così da non aver bisogno di alcuna patch
Prerequisiti: fixture integrate.
Prima di scrivere il test
Con un codice di questo tipo, la maggior parte del lavoro si fa prima del primo def test_. Quattro domande, in ordine.
1. Cosa promette la funzione — e cosa legge che tu non abbia passato come argomento? Leggi la funzione e contrassegna ogni riga che prende qualcosa dall'esterno dei suoi argomenti: os.environ, datetime.now(), random, un percorso di file relativo, un dizionario a livello di modulo, una chiamata di rete. Ciascuno di essi è un input nascosto. La promessa della funzione è "dati questi input nascosti, io restituisco questo", e un test deve fissare ogni input nascosto, altrimenti starà testando qualunque cosa la macchina si trovi ad avere in quel momento.
2. Cosa deve essere predisposto? Il solito: un ambiente virtuale con pytest installato e il modulo importabile dal test (il file di test accanto a esso, o il progetto installato). Poi, per questo argomento, una decisione per ciascun input nascosto:
- una variabile d'ambiente — impostala con
setenv, o rimuovila condelenvusandoraising=False; - una funzione che accede alla rete, al disco o a una sorgente casuale — sostituiscila con
setattr, nel modulo che la chiama; - un dizionario a livello di modulo — modifica una chiave con
setitem; - un percorso relativo — usa
chdirper spostarti in untmp_path; - un orologio o una scelta casuale nel codice che stai scrivendo ora — passalo invece come argomento.
3. Quali casi? Prendi il caricatore di configurazione che costruirai alla fine di questo capitolo. Legge i valori predefiniti, poi un file opzionale app.cfg, poi le variabili d'ambiente APP_*. Il suo piano, prima di scrivere qualsiasi riga di codice:
| Caso | Il contesto durante il test | Valore atteso | |---|---|---| | Nulla impostato | cartella vuota, nessuna variabile APP_* | porta 8000, debug False, regione "bd" | | Il file sovrascrive i valori predefiniti | app.cfg contiene port = 9000 | porta 9000 | | L'ambiente sovrascrive il file | app.cfg indica 9000, APP_PORT=7000 | porta 7000 | | Un valore predefinito cambia | DEFAULTS["region"] è "ae" | regione "ae" | | Valore non valido | APP_PORT=70000 | ValueError che menziona "out of range" | | Lettore sostituito | read_file restituisce {"port": "1234"} | porta 1234 | | Interferenze nella shell | APP_PORT=5 esportata prima di eseguire pytest | ogni riga sopra è ancora valida |
L'ultima riga è quella che ci si dimentica più spesso. Non è un test a sé stante — è una proprietà dell'intera suite, ed è ciò che determina che ogni test debba prima ripulire le variabili, non solo quelli che le impostano.
4. Cosa non testare. Non testare che os.environ memorizzi stringhe o che urllib sia in grado di scaricare una pagina — questo è compito di Python, ed è già testato. Non lasciare che un unit test tocchi la rete reale: è lento, fallisce per motivi non legati al tuo codice, e il meteo cambia. E non fare mai asserzioni sul fake stesso. Questo test passa e non dimostra nulla:
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.01sVerifica solo che la patch sia applicata, non che il tuo codice funzioni. Il fake esiste affinché altro codice — come advice, qui sotto — possa essere testato basandosi su di esso. Se l'asserzione nomina solo la cosa che hai sostituito, elimina il test.
Le sezioni seguenti implementano questo piano uno strumento alla volta.
setenv e delenv — l'ambiente, per ogni test
monkeypatch è una fixture come tmp_path: richiedila per nome nei parametri del test. Ecco quattro test per una versione leggermente più ampia di 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.01sLa seconda esecuzione è quella che conta. La shell ha esportato valori che avrebbero rotto due di questi test, eppure tutti e quattro passano comunque, perché ogni test dichiara l'ambiente di cui ha bisogno.
setenv(name, value) imposta una variabile. delenv(name) ne rimuove una — e per impostazione predefinita solleva KeyError se la variabile non era presente all'inizio. Un test per il caso "non impostata" non si preoccupa se era impostata o meno, ma solo che non sia impostata adesso, quindi raising=False è quasi sempre ciò che desideri.
I valori sono stringhe. L'ambiente contiene solo testo, e lo stesso vale per os.environ. Convertire "9000" in 9000 è compito del tuo codice, e quella conversione è spesso proprio ciò che vale la pena testare.
Tutto viene ripristinato — dimostriamolo
L'affermazione è che monkeypatch ripristina ciò che ha modificato al termine del test. Ecco la prova:
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 lascia passare i print):
inside first : 1
.inside second: None
.
2 passed in 0.01sIl secondo test non ha richiesto monkeypatch e continua a non vedere nulla — la variabile è sparita. Confronta cosa succede quando scrivi direttamente in os.environ:
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.01sIl valore è trapelato nel test successivo, e in ogni test dopo di esso nella stessa esecuzione. Questo è il genere di bug che fa passare un test se eseguito da solo e lo fa fallire se eseguito insieme agli altri — o peggio, il contrario.
Non c'è alcuna magia nel ripristino. Prima di ogni modifica, monkeypatch registra il vecchio valore — o il fatto che non ce ne fosse alcuno — e durante il teardown rimette a posto ogni valore registrato, in ordine inverso. Il teardown viene eseguito sia che il test sia passato, fallito o abbia sollevato un'eccezione, quindi un'asserzione che fallisce a metà non può lasciare l'ambiente sporco. Una riga scritta a mano come os.environ[...] = ... seguita da una riga di pulizia manuale alla fine del test verrebbe saltata proprio a causa di quell'asserzione fallita.
setattr — sostituire una funzione per un singolo test
Le variabili d'ambiente sono il caso più semplice. Quello più difficile è il codice che comunica con la rete:
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 logica interessante si trova in advice: le soglie, i messaggi. Se la testi così com'è, il test dipenderà da un server, da una connessione di rete e dal meteo di oggi. Su una macchina senza rete:
$ 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 sostituisce fetch_temperature con qualcosa che controlli tu, per la durata di un singolo 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.01sCi sono due modi per indicare il target, ed entrambi fanno la stessa cosa:
setattr(weather, "fetch_temperature", new)— l'oggetto, poi il nome dell'attributo come stringa.setattr("weather.fetch_temperature", new)— una singola stringa con punti; pytest importaweathere separa l'ultima parte.
Usa la forma a oggetto quando il test importa già il modulo; usa la forma a stringa quando non è necessario importarlo. In entrambi i casi, nota cosa viene testato adesso: non fetch_temperature, che abbiamo sostituito, ma le soglie e i messaggi in advice, che è il codice che abbiamo scritto noi. Niente rete, nessuna attesa, la stessa risposta ogni volta — e una temperatura di 35 o 4 su richiesta, cosa che il meteo reale non ti darebbe mai a comando.
Perché usare setattr e non semplicemente weather.fetch_temperature = lambda city: 35 nel test? Per lo stesso motivo visto con os.environ: la semplice assegnazione non viene mai annullata, e ogni test successivo nell'esecuzione riceverebbe il fake.
La regola: applica la patch dove il nome viene cercato
Ed ecco l'errore che tutti fanno almeno una volta. Un piccolo modulo che lancia un dado:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Per testare il jackpot, forza randint a restituire 6. Risiede in random, quindi applichiamo la patch lì:
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.01sLa patch è stata applicata, ma dice non l'ha mai vista. Esegui il test qualche altra volta e vedrai che a volte passerà — ogni volta che il vero randint finisce per fare un 6. Un test sbagliato che occasionalmente è verde è peggiore di uno che è sempre rosso.
Il motivo risiede in ciò che from random import randint fa realmente. Non crea un collegamento a random.randint. Copia il riferimento in un nuovo nome, dice.randint, al momento dell'importazione. Dopodiché ci sono due nomi che puntano alla stessa funzione, e setattr ne modifica solo uno:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseQuando roll() viene eseguito, Python cerca randint nel modulo dice — è lì che il nome viene cercato. Quindi è lì che deve essere sostituito:
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.01sCinque esecuzioni consecutive, cinque successi. L'altra soluzione consiste nel modificare il modo in cui dice.py esegue l'importazione. Con import random e una chiamata a random.randint(1, 6), la ricerca del nome avviene nel modulo random a ogni chiamata, e in quel caso applicare la patch a random.randint funziona:
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 regola in una frase: individua la riga che chiama la funzione, guarda da quale modulo ottiene il nome e applica la patch al nome in quel punto.
setitem — una singola chiave di un dizionario
Le impostazioni sono spesso conservate in un dizionario a livello di modulo. Sostituire l'intero dizionario sarebbe poco pratico; setitem modifica una sola chiave e la ripristina al termine:
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 ===============================Il terzo test è ancora una volta la prova: dopo che due test hanno modificato ciascuno una chiave, il dizionario è esattamente come all'inizio. Il suo corrispettivo, monkeypatch.delitem(d, key), rimuove una chiave per un singolo test.
chdir e syspath_prepend
Il codice che apre un percorso relativo come "notes.txt" dipende dalla cartella da cui viene eseguito. Combina chdir con tmp_path del capitolo precedente:
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 sposta la directory di lavoro per un singolo test e la riporta indietro al termine. Il secondo test mostra syspath_prepend nella sua unica riga di utilizzo: posiziona una cartella in cima a sys.path in modo che un modulo scritto lì possa essere importato, e la rimuove di nuovo alla fine — molto utile quando si testa codice che carica plugin da una cartella.
Ancora meglio: passa la dipendenza come argomento
Applicare una patch funziona, ma ogni patch è una piccola ammissione del fatto che il codice è andato all'esterno a prendersi qualcosa dal mondo circostante. Alcune cose non possono essere patchate facilmente per niente. datetime.now è una di queste:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'L'alternativa è una scelta architetturale: lascia che la funzione riceva ciò da cui dipende, con quello reale come valore predefinito:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Il codice di produzione chiama is_open() e ottiene l'orologio reale. Un test passa il proprio orologio:
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.01sNessuna fixture, nulla da annullare, nessun dubbio su dove un nome venga cercato. Il test si legge come una frase semplice — "alle 8:59 il negozio è chiuso" — e i minuti limite che contano sono facili da centrare con esattezza.
La stessa struttura funziona per la casualità: def pick_winner(customers, choose=random.choice), e un test passa choose=lambda items: items[0]. Questa tecnica si chiama dependency injection, e non richiede alcun framework: un argomento predefinito è sufficiente. Usala quando scrivi il codice. Ricorri a monkeypatch quando testi codice che non puoi o non devi modificare, o quando la dipendenza è sepolta più in profondità, a diverse chiamate di distanza.
Un esempio completo
Questo è il caricatore di configurazione pianificato in "Prima di scrivere il test": tre livelli — valori predefiniti integrati, un file opzionale app.cfg nella cartella corrente e variabili d'ambiente che sovrascrivono entrambi. Ogni riga del piano diventa 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 ===============================Vale la pena notare tre cose.
Primo, la fixture clean_env usa a sua volta monkeypatch. Una fixture e il test che la richiede condividono lo stesso oggetto monkeypatch, quindi il test può aggiungere il proprio setenv sopra di essa, e ogni modifica apportata da entrambi viene annullata insieme alla fine.
Secondo, la fixture rimuove le variabili anziché fidarsi del fatto che siano assenti. Questo è ciò che fa sì che la suite dia lo stesso risultato con APP_PORT=5 APP_REGION=xx pytest -q o senza — prova; dirà comunque 6 passed. Questa è l'ultima riga del piano, soddisfatta dalla progettazione piuttosto che da un test a sé stante.
Terzo, l'ultimo test applica la patch ad app_config.read_file — il modulo in cui load_config cerca il nome. Poiché entrambe le funzioni risiedono nello stesso modulo, quello è l'unico punto che conta.
Quando qualcosa va storto
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Un errore di battitura nel nome dell'attributo. setattr si rifiuta di creare un attributo che non esiste già, proteggendoti dall'applicare una patch a un nome che nessuno leggerà mai. La forma a stringa lo formula in modo diverso — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — ma significa la stessa cosa. Passa raising=False solo se intendi davvero aggiungere un nuovo attributo.
La patch è applicata ma la funzione reale continua a essere eseguita Nessun errore evidente: il test fallisce con il valore reale, oppure passa per pura fortuna. Hai applicato la patch al nome nel punto in cui è definito (random.randint) anziché dove viene cercato (dice.randint). Guarda la riga di import nel codice sotto test.
KeyError: 'APP_DEBUG' da monkeypatch.delenv La variabile non era impostata, e delenv solleva un'eccezione per impostazione predefinita. Aggiungi raising=False. Lo stesso vale per delitem e delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly Hai scritto setenv("APP_PORT", 9000). Le variabili d'ambiente sono testo; scrivi "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' I tipi integrati come datetime.datetime non possono avere i propri metodi sostituiti. Passa invece l'orologio come argomento, come nell'esempio di 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") ha richiesto monkeypatch, che vive per un solo test. Mantieni la fixture con scope di funzione, oppure usa pytest.MonkeyPatch.context() all'interno della fixture più ampia e gestisci tu stesso il ripristino.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Esegui con pytest -q -s. Cosa mostrano le due chiamate a print (CITY non è impostata in precedenza)?
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, poi un errore: KeyError: 'CITY'
- Bnone, poi none
- CDhaka, poi none
- DDhaka, poi Dhaka
Questo test fallisce quasi sempre e passa solo ogni tanto. Cosa c'è che non va?
# 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- AServe `setitem` al posto di `setattr`
- BLa `lambda` non dovrebbe accettare due parametri
- C`monkeypatch` non può applicare patch ai moduli della libreria standard
- DLa patch è su `random`, ma `roll()` cerca il nome in `dice` — applica la patch a `dice.randint`
Quale riga sostituisce al meglio ..., in modo che i test successivi vedano di nuovo 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
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi greeter.py con una funzione greeting(name) che restituisca:
"Good morning, <name>"prima delle 12:00,"Good afternoon, <name>"dalle 12:00 fino alle 18:00 e"Good evening, <name>"dopo quell'orario- in lettere maiuscole quando la variabile d'ambiente
GREETER_LOUDè"1"
e un dizionario a livello di modulo TEMPLATES = {"morning": "Good morning", ...} da cui la funzione legge le proprie parole.
Poi scrivi test_greeter.py che:
- Usi
setenvedelenv(..., raising=False)per testare i casi a volume alto e normale, e passi sia che la tua shell abbiaGREETER_LOUDimpostata o meno - Usi
setitemper cambiare la parola del mattino in"Shubho shokal"per un singolo test, e un secondo test che dimostri cheTEMPLATESè tornato normale - Testi i valori limite dell'orario — 11:59, 12:00, 17:59, 18:00
Per il punto 3, scrivilo due volte. Prima con from datetime import datetime all'interno di greeter.py, provando monkeypatch.setattr(datetime, "now", ...) — nota esattamente quale errore ottieni. (Sostituire l'intero nome greeter.datetime con una tua piccola classe fake funziona; nota quanto codice richiede.) Poi modifica greeting per accettare now=datetime.now e testalo passando un orologio.
Quale delle due versioni preferiresti mantenere? La seconda è la risposta che dà la maggior parte dei tester esperti, e dopo aver provato entrambe saprai il perché toccandolo con mano, non solo leggendolo da questa pagina.
Soluzione
Inizia con il piano, come in "Prima di scrivere il test". Gli input nascosti di greeting sono l'orologio, la variabile GREETER_LOUD e il dizionario TEMPLATES. I casi: i quattro minuti limite, normale e ad alto volume, un template modificato e una verifica che il template sia tornato allo stato iniziale.
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 ===============================E con la variabile esportata nella shell, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sLe decisioni alla base:
- L'orologio è un argomento, non una patch. Il primo tentativo,
monkeypatch.setattr(datetime, "now", ...), si blocca conTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. Invece di costruire una classedatetimefake,greetingaccettanow=datetime.now. Nel codice reale si scrivegreeting("Rina")e non si nota nulla; i test passano un momento esatto. at(hour, minute)costruisce l'orologio. Un piccolo helper mantiene ogni test su una singola riga leggibile. Restituisce una funzione, perchégreetingchiamanow()— passare direttamente un oggettodatetimefallirebbe conTypeError: 'datetime.datetime' object is not callable.- I valori limite a coppie. 11:59 e 12:00, 17:59 e 18:00: un minuto da ciascun lato di ogni soglia. Se qualcuno scrive
hour <= 12per errore, il caso delle 12:00 lo intercetta. quietè una fixture, non un presupposto. Ogni test il cui testo atteso è a lettere miste la richiede, perché dipende dall'assenza diGREETER_LOUD. Ecco perché la seconda esecuzione, con la variabile esportata, dà comunque otto successi.test_loudnon ne ha bisogno — imposta la variabile da sé.setitem, poi la prova.test_custom_morning_wordmodifica una chiave;test_templates_are_backnon richiede alcuna fixture e conferma che la chiave è tornata a posto, rendendo visibile il ripristino.- Non testato: che
str.upper()funzioni o chedatetime.now()restituisca l'ora corrente. Entrambi appartengono a Python.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz