O introducere în batjocura în Python
Publicat: 2022-03-11Cum să rulați teste unitare în Python fără a vă testa răbdarea
De cele mai multe ori, software-ul pe care îl scriem interacționează direct cu ceea ce am eticheta drept servicii „murdare”. În termeni simpli: servicii care sunt cruciale pentru aplicația noastră, dar ale căror interacțiuni au efecte secundare intenționate, dar nedorite, adică nedorite în contextul unei rulări de testare autonome.
De exemplu: poate că scriem o aplicație socială și vrem să testăm noua noastră „funcție Postare pe Facebook”, dar nu vrem să postăm efectiv pe Facebook de fiecare dată când rulăm suita noastră de teste.
Biblioteca Python unittest
include un subpachet numit unittest.mock
- sau dacă îl declarați ca dependență, pur și simplu mock
- care oferă mijloace extrem de puternice și utile prin care să bate joc și să elimine aceste efecte secundare nedorite.
Notă: mock
-ul este nou inclus în biblioteca standard începând cu Python 3.3; distribuțiile anterioare vor trebui să utilizeze biblioteca Mock care poate fi descărcată prin PyPI.
Apeluri de sistem vs. Python Mocking
Pentru a vă oferi un alt exemplu și unul cu care vom rula pentru restul articolului, luați în considerare apelurile de sistem . Nu este greu de observat că aceștia sunt candidați principali pentru batjocură: fie că scrieți un script pentru a scoate o unitate CD, un server web care elimină fișierele cache învechite din /tmp
sau un server socket care se leagă la un port TCP, acestea apelează toate caracteristicile efecte secundare nedorite în contextul testelor dumneavoastră unitare.
În calitate de dezvoltator, îți pasă mai mult că biblioteca ta a apelat cu succes funcția de sistem pentru ejectarea unui CD (cu argumentele corecte etc.) decât să experimentezi tava CD-ului deschisă de fiecare dată când este rulat un test. (Sau mai rău, de mai multe ori, deoarece mai multe teste fac referire la codul de evacuare în timpul unui singur test unitar!)
De asemenea, menținerea eficientă și performantă a testelor unitare înseamnă a păstra cât mai mult „cod lent” în afara testelor automate, și anume accesul la sistemul de fișiere și la rețea.
Pentru primul nostru exemplu, vom refactoriza un caz de testare Python standard din forma originală într-una folosind mock
. Vom demonstra cum scrierea unui caz de testare cu simulari va face testele noastre mai inteligente, mai rapide și capabile să dezvăluie mai multe despre modul în care funcționează software-ul.
O funcție de ștergere simplă
Cu toții trebuie să ștergem fișierele din sistemul nostru de fișiere din când în când, așa că haideți să scriem o funcție în Python, care va face un pic mai ușor pentru scripturile noastre să facă acest lucru.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Evident, metoda noastră rm
în acest moment nu oferă mult mai mult decât metoda os.remove
de bază, dar baza noastră de cod se va îmbunătăți, permițându-ne să adăugăm mai multe funcționalități aici.
Să scriem un caz de testare tradițional, adică fără simulari:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
Cazul nostru de testare este destul de simplu, dar de fiecare dată când este rulat, un fișier temporar este creat și apoi șters. În plus, nu avem nicio modalitate de a testa dacă metoda noastră rm
transmite corect argumentul apelului os.remove
. Putem presupune că o face pe baza testului de mai sus, dar mai sunt multe de dorit.
Refactorizarea cu Python Mocks
Să refactorăm cazul nostru de testare folosind mock
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
Cu acești refactori, am schimbat fundamental modul în care funcționează testul. Acum, avem un insider , un obiect pe care îl putem folosi pentru a verifica funcționalitatea altuia.
Capcane potențiale de batjocură de la Python
Unul dintre primele lucruri care ar trebui să iasă în evidență este că folosim decoratorul metodei mock.patch
pentru a bate joc de un obiect situat la mymodule.os
și injectăm acea simulare în metoda noastră de testare. Nu ar avea mai mult sens să ne batem joc de os
însuși, mai degrabă decât referința la el la mymodule.os
?
Ei bine, Python este oarecum un șarpe furtun când vine vorba de importuri și de gestionare a modulelor. În timpul rulării, modulul mymodule
are propriul său sistem de os
, care este importat în propriul domeniu de aplicare local în modul. Astfel, dacă facem joc de os
, nu vom vedea efectele simularii în modulul mymodule
.
Mantra de repetat este aceasta:
Imaginați-vă un articol unde este folosit, nu de unde provine.
Dacă trebuie să bateți joc de modulul tempfile
pentru myproject.app.MyElaborateClass
, probabil că trebuie să aplicați simularea pentru myproject.app.tempfile
, deoarece fiecare modul își păstrează propriile importuri.
Cu capcana aceea din drum, să continuăm să ne batjocorim.
Adăugarea validării la „rm”
Metoda rm
definită mai devreme este destul de simplificată. Ne-am dori ca acesta să valideze că există o cale și este un fișier înainte de a încerca orbește să o ștergem. Să refactorăm rm
pentru a fi puțin mai inteligent:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Grozav. Acum, să ajustăm cazul nostru de testare pentru a menține acoperirea.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
Paradigma noastră de testare s-a schimbat complet. Acum putem verifica și valida funcționalitatea internă a metodelor fără efecte secundare.
Eliminarea fișierelor ca serviciu cu Mock Patch
Până acum, am lucrat doar cu furnizarea de mack-uri pentru funcții, dar nu și pentru metode pe obiecte sau cazuri în care batjocura este necesară pentru trimiterea parametrilor. Să acoperim mai întâi metodele obiectului.
Vom începe cu o refactorizare a metodei rm
într-o clasă de servicii. Nu există într-adevăr o nevoie justificată, în sine, de a încapsula o funcție atât de simplă într-un obiect, dar cel puțin ne va ajuta să demonstrăm concepte cheie în mock
. Să refactorăm:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
Veți observa că nu s-au schimbat multe în cazul nostru de testare:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
Grozav, așa că acum știm că RemovalService
funcționează conform planului. Să creăm un alt serviciu care îl declară ca dependență:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
Deoarece avem deja acoperire de testare pe RemovalService
, nu vom valida funcționalitatea internă a metodei rm
în testele noastre de UploadService
. Mai degrabă, vom testa pur și simplu (fără efecte secundare, desigur) că UploadService
apelează metoda RemovalService.rm
, despre care știm că „funcționează” din cazul nostru de testare anterior.

Există două moduri de a proceda în acest sens:
- Imaginați metoda
RemovalService.rm
în sine. - Furnizați o instanță batjocorită în constructorul
UploadService
.
Deoarece ambele metode sunt adesea importante în testarea unitară, le vom revizui pe ambele.
Opțiunea 1: Metode de batjocură
Biblioteca de mock
are un decorator de metode special pentru metodele și proprietățile instanței obiectului batjocoritor, decoratorul @mock.patch.object
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
Grozav! Am validat că UploadService
apelează cu succes metoda rm
a instanței noastre. Observați ceva interesant acolo? Mecanismul de corecție a înlocuit de fapt metoda rm
a tuturor instanțelor RemovalService
din metoda noastră de testare. Asta înseamnă că putem inspecta chiar ele instanțele. Dacă doriți să vedeți mai multe, încercați să introduceți un punct de întrerupere în codul batjocoritor pentru a obține o idee bună despre cum funcționează mecanismul de corecție.
Mock Patch Pitfall: Comanda decoratorului
Atunci când utilizați mai mulți decoratori în metodele dvs. de testare, ordinea este importantă și este cam confuză. Practic, atunci când mapați decoratorii la parametrii metodei, lucrați înapoi. Luați în considerare acest exemplu:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
Observați cum parametrii noștri sunt potriviți la ordinea inversă a decoratorilor? Asta se datorează parțial modului în care funcționează Python. Cu decoratori de metode multiple, iată ordinea de execuție în pseudocod:
patch_sys(patch_os(patch_os_path(test_something)))
Deoarece patch-ul către sys
este cel mai exterior, acesta va fi executat ultimul, făcându-l ultimul parametru din argumentele metodei de testare actuale. Luați notă de acest lucru și folosiți un depanator când rulați testele pentru a vă asigura că parametrii potriviți sunt injectați în ordinea corectă.
Opțiunea 2: Crearea de instanțe simulate
În loc să batem joc de metoda de instanță specifică, am putea în schimb să furnizăm o instanță batjocorită către UploadService
cu constructorul său. Prefer opțiunea 1 de mai sus, deoarece este mult mai precisă, dar există multe cazuri în care opțiunea 2 ar putea fi eficientă sau necesară. Să refactorăm din nou testul nostru:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
În acest exemplu, nici măcar nu a trebuit să corectăm nicio funcționalitate, pur și simplu creăm o specificație automată pentru clasa RemovalService
și apoi injectăm această instanță în UploadService
pentru a valida funcționalitatea.
Metoda mock.create_autospec
creează o instanță echivalentă funcțional cu clasa furnizată. Ceea ce înseamnă, practic, vorbind, este că atunci când instanța returnată este interacționată, va ridica excepții dacă este folosită în moduri ilegale. Mai precis, dacă o metodă este apelată cu un număr greșit de argumente, va fi ridicată o excepție. Acest lucru este extrem de important pe măsură ce au loc refactorizări. Pe măsură ce o bibliotecă se schimbă, testele se întrerup și asta este de așteptat. Fără a folosi o specificație automată, testele noastre vor trece în continuare, chiar dacă implementarea de bază este întreruptă.
Capcană: Mock-ul. mock.MagicMock
mock.Mock
Biblioteca de mock
include, de asemenea, două clase importante pe care se bazează cea mai mare parte a funcționalității interne: mock.Mock
și mock.MagicMock
. Când aveți posibilitatea de a alege să utilizați o instanță mock.Mock
, o instanță mock.MagicMock
sau o specificație automată, preferați întotdeauna folosirea unei specificații automate, deoarece vă ajută să vă păstrați testele pentru modificările viitoare. Acest lucru se datorează faptului că mock.Mock
și mock.MagicMock
acceptă toate apelurile de metodă și atribuirile de proprietăți, indiferent de API-ul de bază. Luați în considerare următorul caz de utilizare:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Putem testa acest lucru cu o instanță mock.Mock
ca aceasta:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Această logică pare sănătoasă, dar să modificăm metoda Target.apply
pentru a lua mai mulți parametri:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Reluați testul și veți descoperi că încă trece. Asta pentru că nu este construit pe baza API-ului dvs. real. Acesta este motivul pentru care ar trebui să utilizați întotdeauna metoda create_autospec
și parametrul autospec cu @patch
autospec
@patch.object
.
Exemplu simulat Python: batjocură de un apel API Facebook
Pentru a termina, haideți să scriem un exemplu simulat de python mai aplicabil în lumea reală, unul pe care l-am menționat în introducere: postarea unui mesaj pe Facebook. Vom scrie o clasă de wrapper drăguță și un caz de testare corespunzător.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
Iată cazul nostru de testare, care verifică dacă postăm mesajul fără a posta efectiv mesajul:
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
După cum am văzut până acum, este foarte simplu să începeți să scrieți teste mai inteligente cu mock
în Python.
Concluzie
Biblioteca mock
a lui Python, chiar dacă este puțin confuză pentru a lucra, este un schimbător de joc pentru testarea unitară. Am demonstrat cazuri de utilizare obișnuite pentru începerea utilizării mock
în testarea unitară și sperăm că acest articol îi va ajuta pe dezvoltatorii Python să depășească obstacolele inițiale și să scrie cod excelent, testat.