Un'introduzione alla presa in giro in Python
Pubblicato: 2022-03-11Come eseguire unit test in Python senza testare la tua pazienza
Il più delle volte, il software che scriviamo interagisce direttamente con quelli che definiremmo servizi "sporchi". In parole povere: servizi che sono cruciali per la nostra applicazione, ma le cui interazioni hanno effetti collaterali desiderati ma indesiderati, ovvero indesiderati nel contesto di un'esecuzione di test autonoma.
Ad esempio: forse stiamo scrivendo un'app social e vogliamo testare la nostra nuova funzione "Pubblica su Facebook", ma non vogliamo effettivamente pubblicare post su Facebook ogni volta che eseguiamo la nostra suite di test.
La libreria unittest
Python include un sottopacchetto chiamato unittest.mock
—o se lo dichiari come dipendenza, semplicemente mock
—che fornisce mezzi estremamente potenti e utili per deridere e escludere questi effetti collaterali indesiderati.
Nota: mock
è stato recentemente incluso nella libreria standard a partire da Python 3.3; le distribuzioni precedenti dovranno utilizzare la libreria Mock scaricabile tramite PyPI.
Chiamate di sistema contro Python Mocking
Per darti un altro esempio, che verrà utilizzato per il resto dell'articolo, considera le chiamate di sistema . Non è difficile vedere che questi sono i migliori candidati per la presa in giro: che tu stia scrivendo uno script per espellere un'unità CD, un server Web che rimuove i file di cache antiquati da /tmp
o un server socket che si collega a una porta TCP, questi chiama tutti gli effetti collaterali indesiderati delle funzionalità nel contesto dei test unitari.
Come sviluppatore, ti interessa di più che la tua libreria abbia chiamato con successo la funzione di sistema per espellere un CD (con gli argomenti corretti, ecc.) invece di vedere effettivamente il vassoio del CD aperto ogni volta che viene eseguito un test. (O peggio, più volte, poiché più test fanno riferimento al codice di espulsione durante una singola esecuzione di unit test!)
Allo stesso modo, mantenere gli unit-test efficienti e performanti significa mantenere la maggior parte del "codice lento" fuori dalle esecuzioni di test automatizzate, vale a dire il filesystem e l'accesso alla rete.
Per il nostro primo esempio, eseguiremo il refactoring di un test case Python standard dal modulo originale a uno utilizzando mock
. Dimostreremo come scrivere un test case con simulazioni renderà i nostri test più intelligenti, veloci e in grado di rivelare di più sul funzionamento del software.
Una semplice funzione di eliminazione
Tutti abbiamo bisogno di eliminare i file dal nostro filesystem di tanto in tanto, quindi scriviamo una funzione in Python che renderà un po' più facile per i nostri script farlo.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Ovviamente, il nostro metodo rm
in questo momento non fornisce molto di più del metodo os.remove
sottostante, ma la nostra base di codice migliorerà, permettendoci di aggiungere più funzionalità qui.
Scriviamo un test case tradizionale, cioè senza scherzi:
#!/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.")
Il nostro test case è piuttosto semplice, ma ogni volta che viene eseguito, viene creato e quindi eliminato un file temporaneo. Inoltre, non abbiamo modo di verificare se il nostro metodo rm
passa correttamente l'argomento alla chiamata os.remove
. Possiamo presumere che lo faccia in base al test di cui sopra, ma molto è lasciato a desiderare.
Refactoring con Python Mocks
Eseguiamo il refactoring del nostro test case usando 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")
Con questi refactoring, abbiamo cambiato radicalmente il modo in cui opera il test. Ora abbiamo un insider , un oggetto che possiamo usare per verificare la funzionalità di un altro.
Potenziali insidie di derisione di Python
Una delle prime cose che dovrebbe risaltare è che stiamo usando il decoratore del metodo mock.patch
per deridere un oggetto situato su mymodule.os
e iniettando quel mock nel nostro metodo del test case. Non avrebbe più senso prendere in giro semplicemente os
stesso, piuttosto che il riferimento ad esso su mymodule.os
?
Bene, Python è in qualche modo un serpente subdolo quando si tratta di importare e gestire moduli. In fase di esecuzione, il modulo mymodule
ha il proprio sistema os
che viene importato nel proprio ambito locale nel modulo. Quindi, se prendiamo in giro os
, non vedremo gli effetti del mock nel modulo mymodule
.
Il mantra da ripetere è questo:
Deridere un oggetto da dove viene utilizzato, non da dove viene.
Se devi prendere in giro il modulo tempfile
per myproject.app.MyElaborateClass
, probabilmente devi applicare il mock a myproject.app.tempfile
, poiché ogni modulo mantiene le proprie importazioni.
Con quella trappola fuori mano, continuiamo a prendere in giro.
Aggiunta della convalida a 'rm'
Il metodo rm
definito in precedenza è piuttosto semplificato. Ci piacerebbe che convalidasse che un percorso esiste ed è un file prima di tentare alla cieca di rimuoverlo. Facciamo il refactoring di rm
per essere un po' più intelligenti:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Grande. Ora, aggiustiamo il nostro test case per mantenere la copertura.
#!/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")
Il nostro paradigma di test è completamente cambiato. Ora possiamo verificare e convalidare la funzionalità interna dei metodi senza effetti collaterali.
Rimozione di file come servizio con Mock Patch
Finora, abbiamo lavorato solo con la fornitura di mock per funzioni, ma non per metodi su oggetti o casi in cui il mocking è necessario per l'invio di parametri. Analizziamo prima i metodi degli oggetti.
Inizieremo con un refactoring del metodo rm
in una classe di servizio. Non c'è davvero una necessità giustificabile, di per sé, di incapsulare una funzione così semplice in un oggetto, ma almeno ci aiuterà a dimostrare concetti chiave in mock
. Facciamo il refactoring:
#!/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)
Noterai che non è cambiato molto nel nostro test case:
#!/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")
Ottimo, quindi ora sappiamo che il servizio di RemovalService
funziona come previsto. Creiamo un altro servizio che lo dichiari come dipendenza:
#!/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)
Dal momento che abbiamo già una copertura di test su RemovalService
, non convalideremo la funzionalità interna del metodo rm
nei nostri test di UploadService
. Piuttosto, testeremo semplicemente (senza effetti collaterali, ovviamente) che UploadService
chiama il metodo RemovalService.rm
, che sappiamo "funziona" dal nostro precedente test case.

Ci sono due modi per farlo:
- Prendi in giro il metodo
RemovalService.rm
stesso. - Fornisci un'istanza simulata nel costruttore di
UploadService
.
Poiché entrambi i metodi sono spesso importanti negli unit test, li esamineremo entrambi.
Opzione 1: metodi di istanza beffarda
La libreria mock
ha uno speciale decoratore di metodi per deridere i metodi e le proprietà dell'istanza di oggetti, il decoratore @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")
Grande! Abbiamo verificato che UploadService
chiama correttamente il metodo rm
della nostra istanza. Noti qualcosa di interessante lì dentro? Il meccanismo di patch ha effettivamente sostituito il metodo rm
di tutte le istanze RemovalService
nel nostro metodo di test. Ciò significa che possiamo effettivamente ispezionare le istanze stesse. Se vuoi vedere di più, prova a inserire un punto di interruzione nel tuo codice mocking per avere un'idea di come funziona il meccanismo di patching.
Falsa patch finta: ordine del decoratore
Quando si utilizzano più decoratori sui metodi di prova, l' ordine è importante e crea confusione. Fondamentalmente, quando si associano i decoratori ai parametri del metodo, si lavora all'indietro. Considera questo esempio:
@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
Hai notato come i nostri parametri vengono abbinati all'ordine inverso dei decoratori? Ciò è in parte dovuto al modo in cui funziona Python. Con più decoratori di metodi, ecco l'ordine di esecuzione in pseudocodice:
patch_sys(patch_os(patch_os_path(test_something)))
Poiché la patch a sys
è la patch più esterna, verrà eseguita per ultima, rendendola l'ultimo parametro negli argomenti del metodo di test effettivo. Prendi nota di questo bene e usa un debugger quando esegui i tuoi test per assicurarti che i parametri giusti vengano iniettati nell'ordine giusto.
Opzione 2: creazione di istanze fittizie
Invece di prendere in giro il metodo di istanza specifico, potremmo invece semplicemente fornire un'istanza simulata a UploadService
con il suo costruttore. Preferisco l'opzione 1 sopra, poiché è molto più precisa, ma ci sono molti casi in cui l'opzione 2 potrebbe essere efficiente o necessaria. Ridimensioniamo nuovamente il nostro test:
#!/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")
In questo esempio, non abbiamo nemmeno dovuto correggere alcuna funzionalità, creiamo semplicemente una specifica automatica per la classe RemovalService
e quindi inseriamo questa istanza nel nostro UploadService
per convalidare la funzionalità.
Il metodo mock.create_autospec
crea un'istanza funzionalmente equivalente alla classe fornita. Ciò significa, in pratica, che quando si interagisce con l'istanza restituita, solleverà eccezioni se utilizzata in modi illegali. Più specificamente, se un metodo viene chiamato con il numero errato di argomenti, verrà sollevata un'eccezione. Questo è estremamente importante quando si verificano i refactoring. Quando una libreria cambia, i test si interrompono e questo è previsto. Senza utilizzare una specifica automatica, i nostri test continueranno a passare anche se l'implementazione sottostante è interrotta.
Trappola: le classi mock.Mock
e mock.MagicMock
La libreria mock
include anche due classi importanti su cui si basa la maggior parte delle funzionalità interne: mock.Mock
e mock.MagicMock
. Quando viene data la possibilità di utilizzare un'istanza mock.Mock
, un'istanza mock.MagicMock
o una specifica automatica, preferire sempre l'utilizzo di una specifica automatica, poiché aiuta a mantenere sani i test per modifiche future. Questo perché mock.Mock
e mock.MagicMock
accettano tutte le chiamate di metodo e le assegnazioni di proprietà indipendentemente dall'API sottostante. Considera il seguente caso d'uso:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Possiamo testarlo con un'istanza mock.Mock
come questa:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Questa logica sembra sana, ma modifichiamo il metodo Target.apply
per prendere più parametri:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Esegui nuovamente il test e scoprirai che passa ancora. Questo perché non è costruito sulla tua vera API. Questo è il motivo per cui dovresti sempre usare il metodo create_autospec
e il parametro autospec
con i decoratori @patch
e @patch.object
.
Esempio di simulazione di Python: prendere in giro una chiamata API di Facebook
Per finire, scriviamo un esempio simulato di Python nel mondo reale più applicabile, quello che abbiamo menzionato nell'introduzione: pubblicare un messaggio su Facebook. Scriveremo una bella classe wrapper e un test case corrispondente.
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)
Ecco il nostro test case, che verifica che pubblichiamo il messaggio senza effettivamente pubblicare il messaggio:
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!")
Come abbiamo visto finora, è davvero semplice iniziare a scrivere test più intelligenti con mock
in Python.
Conclusione
La mock
libreria di Python, anche se un po' confusa con cui lavorare, è un punto di svolta per gli unit test. Abbiamo dimostrato casi d'uso comuni per iniziare a usare mock
negli unit test e, si spera, questo articolo aiuterà gli sviluppatori Python a superare gli ostacoli iniziali e scrivere codice eccellente e testato.