Cel mai adesea, 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 avut efecte secundare intenționate, dar nedorite - adică nedorite în contextul unei testări autonome.
De exemplu: poate scriem o aplicație socială și dorim să testăm noua noastră funcție „Postează pe Facebook”, dar nu dorim de fapt postează pe Facebook de fiecare dată când rulăm suita de testare.
Python unittest
biblioteca include un subpachet numit unittest.mock
—sau dacă îl declarați ca o dependență, pur și simplu mock
— care oferă mijloace extrem de puternice și utile prin care să batjocoriți și să eliminați aceste efecte secundare nedorite.
Notă: mock
este nou inclus în biblioteca standard începând cu Python 3.3; distribuțiile anterioare vor trebui să utilizeze biblioteca Mock descărcabilă prin PyPI .
Pentru a vă oferi un alt exemplu și unul cu care vom rula pentru restul articolului, luați în considerare apeluri de sistem . Nu este dificil de văzut că aceștia sunt candidații principali pentru batjocură: fie că scrieți un script pentru a scoate o unitate CD, un server web care elimină fișierele cache vechi din /tmp
sau un server socket care se leagă de un TCP port, aceste apeluri prezintă efecte secundare nedorite în contextul testelor unitare.
Ca dezvoltator, îți pasă mai mult că biblioteca ta a apelat cu succes funcția de sistem pentru a scoate un CD (cu argumentele corecte etc.), spre deosebire de a experimenta tava CD deschisă de fiecare dată când se execută un test. (Sau mai rău, de mai multe ori, deoarece mai multe teste fac referire la codul de ejectare în timpul unei singure teste unitare!)
La fel, menținerea eficientă și performantă a testelor unitare înseamnă păstrarea a cât mai mult „cod lent” în afara testelor automate, și anume acces la sistem de fișiere și la rețea.
Pentru primul nostru exemplu, vom refactura un caz de test Python standard de la forma originală la unul folosind mock
. Vom demonstra cum scrierea unui caz de testare cu simulări va face testele noastre mai inteligente, mai rapide și capabile să dezvăluie mai multe despre modul în care funcționează software-ul.
Cu toții trebuie să ștergem fișiere 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 lucru mai ușor pentru scripturile noastre să o facă.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Evident, | | + _ | nostru metodă în acest moment nu oferă mult mai mult decât rm
de bază , dar baza noastră de coduri se va îmbunătăți, permițându-ne să adăugăm mai multe funcționalități aici.
Să scriem un caz de test tradițional, adică fără batjocuri:
os.remove
Cazul nostru de testare este destul de simplu, dar de fiecare dată când este rulat, este creat un fișier temporar și apoi șters. În plus, nu avem nicio modalitate de a testa dacă #!/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.')
metoda trece corect argumentul la rm
apel. Noi putem presupune că face pe baza testului de mai sus, dar mai este mult de dorit.
Să refacem cazul nostru de testare folosind os.remove
:
mock
Cu acești refactori, am schimbat fundamental modul în care funcționează testul. Acum, avem un din interior , un obiect pe care îl putem folosi pentru a verifica funcționalitatea altuia.
Unul dintre primele lucruri care ar trebui să reziste este că folosim #!/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')
decorator de metode pentru a batjocori un obiect situat la mock.patch
și a injecta acea batjocură în metoda noastră de caz de testare. Nu ar avea mai mult sens să batjocori mymodule.os
în sine, mai degrabă decât referința la acesta la os
?
Ei bine, Python este oarecum un șarpe șmecher când vine vorba de importuri și gestionarea modulelor. În timpul rulării, mymodule.os
modulul are propriul său mymodule
care este importat în propriul său scop local în modul. Astfel, dacă vom batjocori os
, nu vom vedea efectele batjocurii în os
modul.
Mantra care trebuie repetată este următoarea:
Batjocorește un articol de unde este folosit, nu de unde a venit.
Dacă trebuie să batjocorești mymodule
modul pentru tempfile
, probabil că trebuie să aplicați falsul la myproject.app.MyElaborateClass
, deoarece fiecare modul își păstrează propriile importuri.
Cu această capcană în afara drumului, să continuăm să ne batem joc.
myproject.app.tempfile
metoda definită mai devreme este destul de simplificată. Am dori să confirmăm că există o cale și este un fișier înainte de a încerca doar să o eliminăm. Să refactorizăm rm
a fi un pic mai inteligent:
rm
Grozav. Acum, să ajustăm cazul nostru de testare pentru a menține acoperirea.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Paradigma noastră de testare s-a schimbat complet. Acum putem verifica și valida funcționalitatea internă a metodelor fără orice efecte secundare.
Până acum, am lucrat doar cu furnizarea de simulări pentru funcții, dar nu și pentru metode pe obiecte sau cazuri în care este necesară batjocura pentru trimiterea parametrilor. Să acoperim mai întâi metodele obiectelor.
Vom începe cu un refactor al #!/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')
metoda într-o clasă de servicii. Într-adevăr nu există o nevoie justificabilă, î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 conceptele cheie în rm
. Să refactorizăm:
mock
Veți observa că nu s-au schimbat multe în cazul nostru de testare:
#!/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)
Super, așa că știm acum că #!/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')
funcționează conform planului. Să creăm un alt serviciu care îl declară ca dependență:
RemovalService
Deoarece avem deja o acoperire a testului pe #!/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)
, nu vom valida funcționalitatea internă a RemovalService
metodă în testele noastre de rm
. Mai degrabă, vom testa pur și simplu (fără efecte secundare, desigur) că UploadService
apeluri | | + _ | metodă, despre care știm că „doar funcționează” din cazul nostru anterior de testare.
Există două modalități de a face acest lucru:
UploadService
metoda în sine.RemovalService.rm
.Deoarece ambele metode sunt adesea importante în testarea unității, le vom analiza pe ambele.
RemovalService.rm
biblioteca are un decorator de metode speciale pentru a batjocori metodele și proprietățile instanței obiectului, UploadService
decorator:
mock
Grozav! Am validat că @mock.patch.object
apelează cu succes instanța noastră #!/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')
metodă. Observați ceva interesant acolo? Mecanismul de corecție a înlocuit efectiv UploadService
metoda tuturor rm
cazuri în metoda noastră de testare. Asta înseamnă că putem inspecta situațiile în sine. Dacă doriți să vedeți mai multe, încercați să introduceți un punct de întrerupere în codul dvs. de batjocură pentru a avea o impresie bună despre modul în care funcționează mecanismul de corecție.
Când utilizați mai mulți decoratori pe metodele de testare, ordinea este importantă și este cam confuz. Practic, atunci când mapezi decoratorii la parametrii metodei, lucrează înapoi . Luați în considerare acest exemplu:
rm
Observați cum se potrivesc parametrii noștri cu ordinea inversă a decoratorilor? Asta se datorează parțial modul în care funcționează Python . Cu mai mulți decoratori de metode, iată ordinea de execuție în pseudocod:
RemovalService
De când patch-ul la @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
este patch-ul cel mai exterior, acesta va fi executat ultimul, făcându-l ultimul parametru din argumentele metodei de testare reale. Rețineți acest bine și utilizați un depanator atunci când rulați testele pentru a vă asigura că parametrii corecți sunt injectați în ordinea corectă.
În loc să batjocorim metoda instanței specifice, am putea furniza doar o instanță batjocorită la patch_sys(patch_os(patch_os_path(test_something)))
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ă refacem din nou testul nostru:
sys
În acest exemplu, nici măcar nu a trebuit să reparăm nicio funcționalitate, pur și simplu creăm o specificație automată pentru UploadService
, apoi injectați această instanță în #!/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')
pentru a valida funcționalitatea.
RemovalService
metoda creează o instanță echivalentă funcțional cu clasa furnizată. Ceea ce înseamnă asta, practic vorbind, este că, atunci când instanța returnată este interacționată, va ridica excepții dacă este utilizată în moduri ilegale. Mai precis, dacă se apelează o metodă cu un număr greșit de argumente, va fi ridicată o excepție. Acest lucru este extrem de important pe măsură ce se întâmplă refactori. Pe măsură ce o bibliotecă se schimbă, testele se întrerup și este de așteptat. Fără a utiliza o specificație automată, testele noastre vor trece în continuare, chiar dacă implementarea de bază este defectă.
UploadService
și mock.create_autospec
Clasemock.Mock
biblioteca include, de asemenea, două clase importante pe care se bazează cea mai mare parte a funcționalității interne: mock.MagicMock
și mock
. Când li se oferă posibilitatea de a utiliza un mock.Mock
exemplu, un mock.MagicMock
exemplu, sau o specificație automată, preferă întotdeauna utilizarea unei specificații automate, deoarece vă ajută să vă mențineți testele sănătoase pentru modificările viitoare. Acest lucru se datorează faptului că mock.Mock
și mock.MagicMock
acceptați toate apelurile de metodă și atribuțiile de proprietate, indiferent de API-ul de bază. Luați în considerare următorul caz de utilizare:
mock.Mock
Putem testa acest lucru cu un mock.MagicMock
exemplu ca acesta:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Această logică pare sănătoasă, dar să modificăm mock.Mock
metoda de a lua mai mulți parametri:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, 'value') target.apply.assert_called_with('value')
Executați din nou testul și veți descoperi că acesta trece în continuare. Acest lucru se datorează faptului că nu este construit pe baza API-ului dvs. real. Acesta este motivul pentru care ar trebui mereu utilizați Target.apply
metoda și class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
parametru cu create_autospec
și autospec
decoratori.
Pentru a termina, să scriem un exemplu mai real aplicabil de piton din lumea reală, unul pe care l-am menționat în introducere: postarea unui mesaj pe Facebook. Vom scrie o clasă de împachetare frumoasă și un caz de testare corespunzător.
@patch
Iată cazul nostru de testare, care verifică dacă postăm mesajul fără de fapt postarea mesajului:
@patch.object
Așa cum am văzut până acum, este într-adevăr simplu pentru a începe să scrieți teste mai inteligente cu 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)
în Python.
Python’s 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!')
biblioteca, dacă este puțin confuză pentru a lucra, este un schimbător de jocuri pentru testarea unitara . Am demonstrat cazuri de utilizare obișnuite pentru a începe să utilizați mock
în testarea unității și, sperăm, acest articol vă va ajuta Dezvoltatori Python depășește obstacolele inițiale și scrie un cod excelent, testat.
Batjocura simulează existența și comportamentul unui obiect real, permițând inginerilor de software să testeze codul în diferite scenarii ipotetice fără a fi nevoie să recurgă la nenumărate apeluri de sistem. Batjocura poate îmbunătăți astfel viteza și eficiența testelor unitare.
Derularea în Python înseamnă că biblioteca unittest.mock este utilizată pentru a înlocui părți ale sistemului cu obiecte simulate, permițând testarea unității mai ușoară și mai eficientă decât altfel ar fi posibilă.