Mestrer Pythons unittest.mock-bibliotek. En dybdegående gennemgang af test-doubles, mock-objekter, stubs, fakes og patch-dekoratoren til robust, isoleret enhedstestning.
Python Mock-objekter: En omfattende guide til implementering af test-doubles
I den moderne softwareudviklings verden er det kun halvdelen af kampen at skrive kode. At sikre, at koden er pålidelig, robust og fungerer som forventet, er den anden, lige så kritiske halvdel. Det er her, automatiseret testning kommer ind. Enhedstestning er især en grundlæggende praksis, der involverer test af individuelle komponenter eller 'enheder' af en applikation isoleret. Denne isolation er dog ofte lettere sagt end gjort. Realverdensapplikationer er komplekse spind af indbyrdes forbundne objekter, tjenester og eksterne systemer. Hvordan kan du teste en enkelt funktion, hvis den afhænger af en database, en tredjeparts-API eller en anden kompleks del af dit system?
Svaret ligger i en kraftfuld teknik: brugen af Test-doubles. Og i Python-økosystemet er det primære værktøj til at skabe dem det alsidige og uundværlige unittest.mock-bibliotek. Denne guide tager dig med på et dybt dyk ned i en verden af mocks og test-doubles i Python. Vi vil udforske 'hvorfor' bag dem, afmystificere de forskellige typer og give praktiske, virkelige eksempler ved hjælp af unittest.mock for at hjælpe dig med at skrive renere, hurtigere og mere effektive tests.
Hvad er test-doubles, og hvorfor har vi brug for dem?
Forestil dig, at du bygger en funktion, der henter en brugers profil fra din virksomheds database og derefter formaterer den. Funktionssignaturen kunne se sådan ud: get_formatted_user_profile(user_id, db_connection).
For at teste denne funktion står du over for flere udfordringer:
- Afhængighed af et live system: Din test ville kræve en kørende database. Dette gør tests langsomme, komplekse at opsætte og afhængige af et eksternt systems tilstand og tilgængelighed.
- Uforudsigelighed: Dataene i databasen kan ændre sig, hvilket får din test til at fejle, selvom din formateringslogik er korrekt. Dette gør tests 'flaky' eller ikke-deterministiske.
- Svært at teste kanttilfælde: Hvordan vil du teste, hvad der sker, hvis databaseforbindelsen mislykkes, eller hvis den returnerer en bruger, der mangler nogle data? At simulere disse specifikke scenarier med en rigtig database kan være utroligt svært.
En Test-double er en generisk term for ethvert objekt, der træder i stedet for et rigtigt objekt under en test. Ved at erstatte den rigtige db_connection med en test-double, kan vi afbryde afhængigheden af den faktiske database og tage fuld kontrol over testmiljøet.
Brugen af test-doubles giver flere vigtige fordele:
- Isolation: De giver dig mulighed for at teste din kodes enhed (f.eks. formateringslogikken) i fuldstændig isolation fra dens afhængigheder (f.eks. databasen). Hvis testen fejler, ved du, at problemet er i den testede enhed, ikke et andet sted.
- Hastighed: Udskiftning af langsomme operationer som netværksanmodninger eller databaseforespørgsler med en in-memory test-double får din testsuite til at køre dramatisk hurtigere. Hurtige tests køres oftere, hvilket fører til en strammere feedback-loop for udviklere.
- Determinisme: Du kan konfigurere test-doublen til at returnere forudsigelige data hver eneste gang testen køres. Dette eliminerer ustabile tests og sikrer, at en fejlende test indikerer et ægte problem.
- Evne til at teste kanttilfælde: Du kan nemt konfigurere en double til at simulere fejlforhold, såsom at rejse en
ConnectionErroreller returnere tomme data, hvilket giver dig mulighed for at verificere, at din kode håndterer disse situationer elegant.
Taxonomien af test-doubles: Ud over blot "Mocks"
Selvom udviklere ofte bruger udtrykket "mock" generisk til at henvise til enhver test-double, er det nyttigt at forstå den mere præcise terminologi, som Gerard Meszaros har opfundet i sin bog "xUnit Test Patterns." At kende disse forskelle hjælper dig med at tænke klarere over, hvad du forsøger at opnå i din test.
1. Dummy
Et Dummy-objekt er den enkleste test-double. Det sendes rundt for at udfylde en parameterliste, men bruges aldrig rigtigt. Dets metoder kaldes typisk ikke. Du bruger en dummy, når du skal give et argument til en metode, men du er ligeglad med argumentets opførsel i sammenhæng med den specifikke test.
Eksempel: Hvis en funktion kræver et 'logger'-objekt, men din test ikke er bekymret for, hvad der bliver logget, kan du sende et dummy-objekt.
2. Fake
Et Fake-objekt har en fungerende implementering, men det er en meget enklere version af produktionsobjektet. Det bruger ikke eksterne ressourcer og erstatter en letvægtsimplementering med en tungere. Det klassiske eksempel er en in-memory database, der erstatter en rigtig databaseforbindelse. Det virker faktisk – du kan tilføje data til den og læse data fra den – men det er bare en simpel ordbog eller liste under overfladen.
3. Stub
En Stub leverer forudprogrammerede, "konserverede" svar på metodekald foretaget under en test. Den bruges, når din kode skal modtage specifikke data fra en afhængighed. Du kan f.eks. stubbe en metode som api_client.get_user(user_id=123) til altid at returnere en specifik brugerordbog uden faktisk at foretage et API-kald.
4. Spy
En Spy er en stub, der også registrerer nogle oplysninger om, hvordan den blev kaldt. Den kan f.eks. registrere antallet af gange en metode blev kaldt eller de argumenter, der blev sendt til den. Dette giver dig mulighed for at "spionere" på interaktionen mellem din kode og dens afhængighed og derefter foretage påstande om denne interaktion bagefter.
5. Mock
Et Mock-objekt er den mest 'bevidste' type test-double. Det er et objekt, der er forprogrammeret med forventninger om, hvilke metoder der vil blive kaldt, med hvilke argumenter og i hvilken rækkefølge. En test, der bruger et mock-objekt, vil typisk fejle, ikke kun hvis den testede kode producerer det forkerte resultat, men også hvis den ikke interagerer med mock'en på den præcist forventede måde. Mocks er gode til adfærdsverifikation – at sikre, at en specifik rækkefølge af handlinger fandt sted.
Pythons unittest.mock-bibliotek leverer en enkelt, kraftfuld klasse, der kan fungere som en Stub, Spy eller Mock, afhængigt af hvordan du bruger den.
Introduktion til Pythons kraftcenter: `unittest.mock`-biblioteket
En del af Pythons standardbibliotek siden version 3.3, unittest.mock er den kanoniske løsning til at oprette test-doubles. Dens fleksibilitet og kraft gør det til et essentielt værktøj for enhver seriøs Python-udvikler. Hvis du bruger en ældre version af Python, kan du installere det tilbageporterede bibliotek via pip: pip install mock.
Bibliotekets kerne kredser om to nøgleklasser: Mock og dens mere kapable søskende, MagicMock. Disse objekter er designet til at være utroligt fleksible og opretter attributter og metoder "on the fly", når du tilgår dem.
Dybdegående: `Mock`- og `MagicMock`-klasserne
`Mock`-objektet
Et `Mock`-objekt er en kamæleon. Du kan oprette et, og det vil straks svare på enhver attributadgang eller metodekald, og returnere et andet Mock-objekt som standard. Dette giver dig mulighed for nemt at kæde kald sammen under opsætningen.
# I en testfil...
from unittest.mock import Mock
# Opret et mock-objekt
mock_api = Mock()
# Adgang til en attribut opretter den og returnerer et andet mock-objekt
print(mock_api.users)
# Output: <Mock name='mock.users' id='...'>
# Kald af en metode returnerer også et mock-objekt som standard
print(mock_api.users.get(id=1))
# Output: <Mock name='mock.users.get()' id='...'>
Denne standardadfærd er ikke særlig nyttig til testning. Den virkelige kraft kommer fra at konfigurere mock-objektet til at opføre sig som det objekt, det erstatter.
Konfiguration af returværdier og sideeffekter
Du kan fortælle en mock-metode, hvad den skal returnere ved hjælp af attributten return_value. Dette er, hvordan du opretter en Stub.
from unittest.mock import Mock
# Opret et mock-objekt til en datatjeneste
mock_service = Mock()
# Konfigurer returværdien for et metodekald
mock_service.get_data.return_value = {'id': 1, 'name': 'Test Data'}
# Nu, når vi kalder den, får vi vores konfigurerede værdi
result = mock_service.get_data()
print(result)
# Output: {'id': 1, 'name': 'Test Data'}
For at simulere fejl kan du bruge attributten side_effect. Dette er perfekt til at teste din kodes fejlhåndtering.
from unittest.mock import Mock
mock_service = Mock()
# Konfigurer metoden til at rejse en exception
mock_service.get_data.side_effect = ConnectionError("Failed to connect to service")
# Kald af metoden vil nu rejse exceptionen
try:
mock_service.get_data()
except ConnectionError as e:
print(e)
# Output: Failed to connect to service
Påstande til verifikation
Mock-objekter fungerer også som Spies og Mocks ved at registrere, hvordan de bruges. Du kan derefter bruge en række indbyggede påstandsmetoder til at verificere disse interaktioner.
mock_object.method.assert_called(): Bekræfter, at metoden blev kaldt mindst én gang.mock_object.method.assert_called_once(): Bekræfter, at metoden blev kaldt præcis én gang.mock_object.method.assert_called_with(*args, **kwargs): Bekræfter, at metoden sidst blev kaldt med de specificerede argumenter.mock_object.method.assert_any_call(*args, **kwargs): Bekræfter, at metoden blev kaldt med disse argumenter på et hvilket som helst tidspunkt.mock_object.method.assert_not_called(): Bekræfter, at metoden aldrig blev kaldt.mock_object.call_count: En heltalsegenskab, der fortæller dig, hvor mange gange metoden blev kaldt.
from unittest.mock import Mock
mock_notifier = Mock()
# Forestil dig, at dette er vores funktion under test
def process_and_notify(data, notifier):
if data.get('critical'):
notifier.send_alert(message="Critical event occurred!")
# Testtilfælde 1: Kritiske data
process_and_notify({'critical': True}, mock_notifier)
mock_notifier.send_alert.assert_called_once_with(message="Critical event occurred!")
# Nulstil mock-objektet til næste test
mock_notifier.reset_mock()
# Testtilfælde 2: Ikke-kritiske data
process_and_notify({'critical': False}, mock_notifier)
mock_notifier.send_alert.assert_not_called()
`MagicMock`-objektet
Et `MagicMock`-objekt er en underklasse af `Mock` med en vigtig forskel: det har standardimplementeringer for de fleste af Pythons "magiske" eller "dunder"-metoder (f.eks. __len__, __str__, __iter__). Hvis du forsøger at bruge et almindeligt `Mock`-objekt i en kontekst, der kræver en af disse metoder, får du en fejl.
from unittest.mock import Mock, MagicMock
# Brug af et almindeligt Mock-objekt
mock_list = Mock()
try:
len(mock_list)
except TypeError as e:
print(e) # Output: 'Mock' object has no len()
# Brug af et MagicMock-objekt
magic_mock_list = MagicMock()
print(len(magic_mock_list)) # Output: 0 (som standard)
# Vi kan også konfigurere den magiske metodes returværdi
magic_mock_list.__len__.return_value = 100
print(len(magic_mock_list)) # Output: 100
Tommelfingerregel: Start med `MagicMock`. Det er generelt sikrere og dækker flere brugsscenarier, såsom mocking af objekter, der bruges i for-løkker (kræver __iter__) eller with-udsagn (kræver __enter__ og __exit__).
Praktisk implementering: `patch`-dekoratoren og kontekstmanageren
At oprette en mock er én ting, men hvordan får du din kode til at bruge den i stedet for det rigtige objekt? Det er her `patch` kommer ind. `patch` er et kraftfuldt værktøj i `unittest.mock`, der midlertidigt erstatter et mål-objekt med en mock i løbet af en test.
`@patch` som dekorator
Den mest almindelige måde at bruge `patch` på er som en dekorator på din testmetode. Du angiver strengstien til det objekt, du vil erstatte.
Lad os sige, at vi har en funktion, der henter data fra en web-API ved hjælp af det populære `requests`-bibliotek:
# i fil: my_app/data_fetcher.py
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status() # Rejs en exception for dårlige statuskoder
return response.json()
Vi ønsker at teste denne funktion uden at foretage et rigtigt netværkskald. Vi kan patche `requests.get`:
# i fil: tests/test_data_fetcher.py
import unittest
from unittest.mock import patch, Mock
from my_app.data_fetcher import get_user_data
class TestDataFetcher(unittest.TestCase):
@patch('my_app.data_fetcher.requests.get')
def test_get_user_data_success(self, mock_get):
"""Test af succesfuld datahentning."""
# Arranger: Opsæt vores mocks
mock_response = Mock()
mock_response.json.return_value = {'id': 1, 'name': 'John Doe'}
mock_response.raise_for_status.return_value = None # Gør intet ved succes
mock_get.return_value = mock_response
# Kald vores funktion
user_data = get_user_data(1)
# Bekræft, at vores funktion foretog det korrekte API-kald
mock_get.assert_called_once_with('https://api.example.com/users/1')
# Bekræft, at vores funktion returnerede de forventede data
self.assertEqual(user_data, {'id': 1, 'name': 'John Doe'})
Bemærk, hvordan `patch` opretter et `MagicMock`-objekt og sender det ind i vores testmetode som argumentet `mock_get`. Inden for testen omdirigeres ethvert kald til `requests.get` inde i `my_app.data_fetcher` til vores mock-objekt.
`patch` som kontekstmanager
Nogle gange behøver du kun at patche noget for en lille del af en test. At bruge `patch` som en kontekstmanager med en `with`-sætning er perfekt til dette.
# i fil: tests/test_data_fetcher.py
import unittest
from unittest.mock import patch, Mock
from my_app.data_fetcher import get_user_data
class TestDataFetcher(unittest.TestCase):
def test_get_user_data_with_context_manager(self):
"""Test ved brug af patch som en kontekstmanager."""
with patch('my_app.data_fetcher.requests.get') as mock_get:
# Konfigurer mock-objektet inde i 'with'-blokken
mock_response = Mock()
mock_response.json.return_value = {'id': 2, 'name': 'Jane Doe'}
mock_get.return_value = mock_response
user_data = get_user_data(2)
mock_get.assert_called_once_with('https://api.example.com/users/2')
self.assertEqual(user_data, {'id': 2, 'name': 'Jane Doe'})
# Uden for 'with'-blokken er requests.get tilbage til sin oprindelige tilstand
Et afgørende koncept: Hvor skal der patches?
Dette er den enkeltstående mest almindelige kilde til forvirring ved brug af `patch`. Reglen er: Du skal patche objektet, hvor det slås op, ikke hvor det er defineret.
Lad os illustrere med et eksempel. Antag, at vi har to filer:
# i fil: services.py
class Database:
def connect(self):
# ... kompleks forbindelseslogik ...
return "REAL_CONNECTION"
# i fil: main_app.py
from services import Database
def start_app():
db = Database()
connection = db.connect()
print(f"Got connection: {connection}")
return connection
Nu vil vi teste `start_app` i `main_app.py` uden at oprette et rigtigt `Database`-objekt. En almindelig fejl er at forsøge at patche `services.Database`.
# i fil: test_main_app.py
import unittest
from unittest.mock import patch
from main_app import start_app
class TestApp(unittest.TestCase):
# DETTE ER DEN FORKERTE MÅDE AT PATCHE PÅ!
@patch('services.Database')
def test_start_app_incorrectly(self, mock_db):
start_app()
# Denne test vil stadig bruge den RIGTIGE Database-klasse!
# DETTE ER DEN KORREKTE MÅDE AT PATCHE PÅ!
@patch('main_app.Database')
def test_start_app_correctly(self, mock_db_class):
# Vi patcher 'Database' i 'main_app' navnerummet
# Konfigurer den mock-instans, der vil blive oprettet
mock_instance = mock_db_class.return_value
mock_instance.connect.return_value = "MOCKED_CONNECTION"
connection = start_app()
# Bekræft, at vores mock blev brugt
mock_db_class.assert_called_once() # Blev klassen instansieret?
mock_instance.connect.assert_called_once() # Blev connect-metoden kaldt?
self.assertEqual(connection, "MOCKED_CONNECTION")
Hvorfor fejler den første test? Fordi `main_app.py` udfører `from services import Database`. Dette importerer `Database`-klassen til `main_app`-modulets navnerum. Når `start_app` kører, leder den efter `Database` inden for sit eget modul (`main_app`). Patching af `services.Database` ændrer det i `services`-modulet, men `main_app` har allerede sin egen reference til den originale klasse. Den korrekte tilgang er at patche `main_app.Database`, som er det navn, den testede kode faktisk bruger.
Avancerede mocking-teknikker
`spec` og `autospec`: Gør mocks sikrere
En standard `MagicMock` har en potentiel ulempe: den vil give dig mulighed for at kalde enhver metode med ethvert argument, selvom den metode ikke eksisterer på det rigtige objekt. Dette kan føre til tests, der passerer, men skjuler reelle problemer, som stavefejl i metodenavne eller ændringer i et rigtigt objekts API.
# Rigtig klasse
class Notifier:
def send_message(self, text):
# ... sender besked ...
pass
# En test med en stavefejl
from unittest.mock import MagicMock
mock_notifier = MagicMock()
# Ups, en stavefejl! Den rigtige metode er send_message
mock_notifier.send_mesage("hello") # Ingen fejl bliver rejst!
mock_notifier.send_mesage.assert_called_with("hello") # Denne påstand passerer!
# Vores test er grøn, men produktionskoden ville fejle.
For at forhindre dette giver `unittest.mock` argumenterne `spec` og `autospec`.
- `spec=SomeClass`: Dette konfigurerer mock-objektet til at have den samme API som `SomeClass`. Hvis du forsøger at tilgå en metode eller attribut, der ikke eksisterer på den rigtige klasse, vil en `AttributeError` blive rejst.
- `autospec=True` (eller `autospec=SomeClass`): Dette er endnu mere kraftfuldt. Det fungerer som `spec`, men det kontrollerer også kaldssignaturen for eventuelle mocked metoder. Hvis du kalder en metode med det forkerte antal eller navne på argumenter, vil det rejse en `TypeError`, ligesom det rigtige objekt ville gøre.
from unittest.mock import create_autospec
# Opret et mock-objekt, der har den samme grænseflade som vores Notifier-klasse
spec_notifier = create_autospec(Notifier)
try:
# Dette vil fejle med det samme på grund af stavefejlen
spec_notifier.send_mesage("hello")
except AttributeError as e:
print(e) # Output: Mock object has no attribute 'send_mesage'
try:
# Dette vil fejle, fordi signaturen er forkert (ingen 'text'-søgeord)
spec_notifier.send_message("hello")
except TypeError as e:
print(e) # Output: missing a required argument: 'text'
# Dette er den korrekte måde at kalde den på
spec_notifier.send_message(text="hello") # Dette virker!
spec_notifier.send_message.assert_called_once_with(text="hello")
Bedste praksis: Brug altid `autospec=True`, når du patcher. Det gør dine tests mere robuste og mindre skrøbelige. `@patch('path.to.thing', autospec=True)`.
Real-World eksempel: Test af en data processing service
Lad os binde det hele sammen med et mere komplet eksempel. Vi har en `ReportGenerator`, der afhænger af en database og et filsystem.
# i fil: app/services.py
class DatabaseConnector:
def get_sales_data(self, start_date, end_date):
# I virkeligheden ville dette forespørge en database
raise NotImplementedError("This should not be called in tests")
class FileSaver:
def save_report(self, path, content):
# I virkeligheden ville dette skrive til en fil
raise NotImplementedError("This should not be called in tests")
# i fil: app/reports.py
from .services import DatabaseConnector, FileSaver
class ReportGenerator:
def __init__(self):
self.db_connector = DatabaseConnector()
self.file_saver = FileSaver()
def generate_sales_report(self, start_date, end_date, output_path):
"""Henter salgsdata og gemmer en formateret rapport."""
raw_data = self.db_connector.get_sales_data(start_date, end_date)
if not raw_data:
report_content = "No sales data for this period."
else:
total_sales = sum(item['amount'] for item in raw_data)
report_content = f"Total Sales from {start_date} to {end_date}: ${total_sales:.2f}"
self.file_saver.save_report(path=output_path, content=report_content)
return True
Lad os nu skrive en enhedstest for `ReportGenerator.generate_sales_report`, der mocker dens afhængigheder.
# i fil: tests/test_reports.py
import unittest
from datetime import date
from unittest.mock import patch, Mock
from app.reports import ReportGenerator
class TestReportGenerator(unittest.TestCase):
@patch('app.reports.FileSaver', autospec=True)
@patch('app.reports.DatabaseConnector', autospec=True)
def test_generate_sales_report_with_data(self, mock_db_connector_class, mock_file_saver_class):
"""Test af rapportgenerering, når databasen returnerer data."""
# Arranger: Opsæt vores mocks
mock_db_instance = mock_db_connector_class.return_value
mock_file_saver_instance = mock_file_saver_class.return_value
# Konfigurer database-mock-objektet til at returnere nogle fakedata (Stub)
fake_data = [
{'id': 1, 'amount': 100.50},
{'id': 2, 'amount': 75.00},
{'id': 3, 'amount': 25.25}
]
mock_db_instance.get_sales_data.return_value = fake_data
start = date(2023, 1, 1)
end = date(2023, 1, 31)
path = '/reports/sales_jan_2023.txt'
# Udfør: Opret en instans af vores klasse og kald metoden
generator = ReportGenerator()
result = generator.generate_sales_report(start, end, path)
# Bekræft: Verificer interaktionerne og resultaterne
# 1. Blev databasen kaldt korrekt?
mock_db_instance.get_sales_data.assert_called_once_with(start, end)
# 2. Blev filgemmeren kaldt med det korrekte, beregnede indhold?
expected_content = "Total Sales from 2023-01-01 to 2023-01-31: $200.75"
mock_file_saver_instance.save_report.assert_called_once_with(
path=path,
content=expected_content
)
# 3. Returnerede vores metode den korrekte værdi?
self.assertTrue(result)
Denne test isolerer perfekt logikken inden i `generate_sales_report` fra kompleksiteten af databasen og filsystemet, samtidig med at den verificerer, at den interagerer korrekt med dem.
Bedste praksis for effektiv mocking
- Hold mocks enkle: En test, der kræver en meget kompleks mock-konfiguration, er ofte et tegn (en "test smell") på, at den testede enhed er for kompleks og muligvis overtræder Single Responsibility Principle. Overvej at refaktorere produktionskoden.
- Mock kollaboratorer, ikke alt: Du bør kun mocke objekter, som din testede enhed kommunikerer med (dens kollaboratorer). Mock ikke det objekt, du selv tester.
- Foretræk `autospec=True`: Som nævnt gør dette dine tests mere robuste ved at sikre, at mockens grænseflade matcher det virkelige objekts grænseflade. Dette hjælper med at fange problemer forårsaget af refaktorering.
- Én mock per test (ideelt set): En god enhedstest fokuserer på en enkelt adfærd eller interaktion. Hvis du finder dig selv i at mocke mange forskellige objekter i én test, kan det være bedre at opdele den i flere, mere fokuserede tests.
- Vær specifik i dine påstande: Tjek ikke kun `mock.method.assert_called()`. Brug `assert_called_with(...)` for at sikre, at interaktionen skete med de korrekte data. Dette gør dine tests mere værdifulde.
- Dine tests er dokumentation: Brug klare og beskrivende navne til dine tests og mock-objekter (f.eks. `mock_api_client`, `test_login_fails_on_network_error`). Dette gør testens formål klart for andre udviklere.
Konklusion
Test-doubles er ikke blot et værktøj til testning; de er en fundamental del af designet af testbar, modulær og vedligeholdelig software. Ved at erstatte reelle afhængigheder med kontrollerede surrogater kan du oprette en testsuite, der er hurtig, pålidelig og i stand til at verificere alle hjørner af din applikations logik.
Pythons unittest.mock-bibliotek giver et værktøjssæt i verdensklasse til implementering af disse mønstre. Ved at mestre MagicMock, `patch` og sikkerheden ved `autospec` låser du op for evnen til at skrive virkeligt isolerede enhedstests. Dette giver dig mulighed for at bygge komplekse applikationer med tillid, velvidende at du har et sikkerhedsnet af præcise, målrettede tests til at fange regressioner og validere nye funktioner. Så kom i gang, begynd at patche, og byg mere robuste Python-applikationer i dag.