Quando si scrivono unit test, non utilizzare mock
Pubblicato: 2018-05-02Nota: questo è il nostro ultimo post di ingegneria tecnica scritto dall'ingegnere principale, Seth Ammons. Un ringraziamento speciale a Sam Nguyen, Kane Kim, Elmer Thomas e Kevin Gillette per la revisione tra pari di questo post . E per altri post come questo, dai un'occhiata al nostro blog tecnico.
Mi piace molto scrivere test per il mio codice, in particolare unit test. Il senso di fiducia che mi dà è grande. Raccogliere qualcosa su cui non lavoro da molto tempo ed essere in grado di eseguire i test unitari e di integrazione mi dà la consapevolezza che posso refactoring spietatamente se necessario e, purché i miei test abbiano una copertura buona e significativa e continuino a passare , in seguito avrò ancora un software funzionale.
Gli unit test guidano la progettazione del codice e ci consentono di verificare rapidamente che le modalità di errore e i flussi logici funzionino come previsto. Detto questo, voglio scrivere di qualcosa forse un po' più controverso: quando scrivi unit test, non usare mock.
Mettiamo sul tavolo alcune definizioni
Qual è la differenza tra test unitari e di integrazione? Cosa intendo per derisioni e cosa dovresti usare invece? Questo post è incentrato sul lavoro in Go, quindi la mia opinione su queste parole è nel contesto di Go.
Quando dico unit test , mi riferisco a quei test che garantiscono una corretta gestione degli errori e guidano la progettazione del sistema testando piccole unità di codice. Per unità, potremmo riferirci a un intero pacchetto, un'interfaccia o un singolo metodo.
Il test di integrazione è il momento in cui interagisci effettivamente con i sistemi e/o le librerie dipendenti. Quando dico "mock", mi riferisco specificamente al termine "Mock Object", che è dove "sostituiamo il codice di dominio con implementazioni fittizie che emulano la funzionalità reale e impongono affermazioni sul comportamento del nostro codice [1]" (enfasi il mio).
Detto un po 'più breve: le prese in giro affermano il comportamento, come:
MyMock.Method("pippo").Called(1).WithArgs("bar").Returns("raz")
Sostengo i "falsi piuttosto che i falsi".
Un falso è una specie di doppio test che può contenere comportamenti aziendali [2]. I fake sono semplicemente strutture che si adattano a un'interfaccia e sono una forma di iniezione di dipendenza in cui controlliamo il comportamento. Il principale vantaggio dei falsi è che riducono l'accoppiamento nel codice, dove i mock aumentano l'accoppiamento e l'accoppiamento rende più difficile il refactoring [3].
In questo post, intendo dimostrare che i falsi offrono flessibilità e consentono test e refactoring facili. Riducono le dipendenze rispetto ai mock e sono facili da mantenere.
Immergiamoci con un esempio un po' più avanzato del "test di una funzione di somma" come potresti vedere in un tipico post di questa natura. Tuttavia, ho bisogno di darti un po' di contesto in modo che tu possa capire più facilmente il codice che segue in questo post.
In SendGrid, uno dei nostri sistemi ha tradizionalmente file sul file system locale, ma a causa della necessità di una maggiore disponibilità e di una migliore velocità effettiva, stiamo spostando questi file su S3.
Abbiamo un'applicazione che deve essere in grado di leggere questi file e abbiamo optato per un'applicazione che può essere eseguita in due modalità "locale" o "remota", a seconda della configurazione. Un avvertimento che viene ignorato in molti esempi di codice è che, in caso di errore remoto, torniamo a leggere il file localmente.
Detto questo, questa applicazione ha un getter di pacchetti. Dobbiamo assicurarci che il pacchetto getter possa ottenere file dal filesystem remoto o dal filesystem locale.
Approccio ingenuo: basta chiamare libreria e chiamate a livello di sistema
L'approccio ingenuo è che il nostro pacchetto di implementazione chiamerà getter.New(...) e gli passerà le informazioni necessarie per impostare il recupero del file remoto o locale e restituirà un Getter . Il valore restituito sarà quindi in grado di chiamare MyGetter.GetFile(...) con i parametri necessari per individuare il file remoto o locale.
Questo ci darà la nostra struttura di base. Quando creiamo il nuovo Getter , inizializziamo i parametri necessari per qualsiasi potenziale recupero di file remoti (una chiave di accesso e un segreto) e passiamo anche alcuni valori che hanno origine nella nostra configurazione dell'applicazione, come useRemoteFS che dirà al codice di provare il file system remoto.
Dobbiamo fornire alcune funzionalità di base. Dai un'occhiata al codice ingenuo qui [4]; di seguito è una versione ridotta. Nota, questo è un esempio non finito e riformuleremo le cose.
L'idea di base qui è che se siamo configurati per leggere dal file system remoto e otteniamo i dettagli del file system remoto (host, bucket e chiave), allora dovremmo tentare di leggere dal file system remoto. Dopo aver acquisito fiducia nella lettura remota del sistema, sposteremo tutta la lettura dei file sul file system remoto e rimuoveremo i riferimenti alla lettura dal file system locale.
Questo codice non è molto compatibile con gli unit test; nota che per verificare come funziona, in realtà dobbiamo colpire non solo il file system locale, ma anche il file system remoto. Ora, potremmo semplicemente fare un test di integrazione e impostare un po' di magia Docker per avere un'istanza s3 che ci consenta di verificare il percorso felice nel codice.
Avere solo test di integrazione è tutt'altro che ideale, poiché gli unit test ci aiutano a progettare software più robusti testando facilmente codice alternativo e percorsi di errore. Dovremmo salvare i test di integrazione per tipi di test più grandi "funziona davvero". Per ora, concentriamoci sugli unit test.
Come possiamo rendere questo codice più testabile per unità? Ci sono due scuole di pensiero. Uno consiste nell'utilizzare un generatore di mock (come https://github.com/vektra/mockery o https://github.com/golang/mock) che crea codice standard da utilizzare durante il test di mock.
Potresti seguire questa strada e generare le chiamate del filesystem e le chiamate del client Minio. O forse vuoi evitare una dipendenza, quindi generi le tue prese in giro a mano. Si scopre che deridere il client Minio non è semplice perché hai un client tipizzato in modo concreto che restituisce un oggetto tipizzato in modo concreto.
Dico che c'è un modo migliore che prendere in giro. Se ristrutturiamo il nostro codice per renderlo più testabile, non abbiamo bisogno di ulteriori importazioni per mock e relativi cruft e non sarà necessario conoscere DSL di test aggiuntivi per testare con sicurezza le interfacce. Possiamo impostare il nostro codice in modo che non sia eccessivamente accoppiato e il codice di test sarà solo un normale codice Go utilizzando le interfacce di Go. Facciamolo!
Approccio all'interfaccia: maggiore astrazione, test più semplici
Cos'è che dobbiamo testare? È qui che alcuni nuovi Gopher sbagliano le cose. Ho visto persone capire il valore di sfruttare le interfacce, ma sentono di aver bisogno di interfacce che corrispondano all'implementazione concreta del pacchetto che stanno utilizzando.
Potrebbero vedere che abbiamo un client Minio, quindi potrebbero iniziare creando interfacce che corrispondono a TUTTI i metodi e gli usi del client Minio (o qualsiasi altro client s3). Dimenticano il proverbio Go [5][6] di "Più grande è l'interfaccia, più debole è l'astrazione".
Non abbiamo bisogno di testare contro il client Minio. Dobbiamo verificare che possiamo ottenere file in remoto o localmente (e verificare alcuni percorsi di errore, come gli errori remoti). Ridimensioniamo l'approccio iniziale ed estraiamo il client Minio in un getter remoto. Mentre lo facciamo, facciamo lo stesso con il nostro codice per la lettura di file locali e creiamo un getter locale. Ecco le interfacce di base e avremo il tipo per implementarle:
Con queste astrazioni in atto, possiamo riorganizzare la nostra implementazione iniziale. Metteremo localFetcher e remoteFetcher nella struttura Getter e refactoring GetFile per usarli. Scopri la versione completa del codice rifattorizzato qui [7]. Di seguito è riportato uno snippet leggermente semplificato utilizzando la nuova versione dell'interfaccia:
Questo nuovo codice rifattorizzato è molto più testabile in unità perché prendiamo le interfacce come parametri sulla struttura Getter e possiamo cambiare i tipi concreti per i falsi. Invece di prendere in giro le chiamate del sistema operativo o aver bisogno di una presa in giro completa del client Minio o di interfacce di grandi dimensioni, abbiamo solo bisogno di due semplici falsi: fakeLocalFetcher e fakeRemoteFetcher .
Questi falsi hanno alcune proprietà che ci consentono di specificare cosa restituiscono. Saremo in grado di restituire i dati del file o qualsiasi errore che ci piace e possiamo verificare che il metodo GetFile chiamante gestisca i dati e gli errori come previsto.
Con questo in mente, il cuore delle prove diventa:
Con questa struttura di base, possiamo racchiudere il tutto in test guidati da tabelle [8]. Ciascun caso nella tabella dei test verificherà l'accesso ai file locale o remoto. Saremo in grado di iniettare un errore all'accesso ai file remoto o locale. Possiamo verificare gli errori propagati, che il contenuto del file è passato e che sono presenti le voci di registro previste.
Sono andato avanti e ho incluso tutti i potenziali casi di test e permutazioni nel test guidato da una tabella disponibile qui [9] (potresti notare che alcune firme di metodo sono leggermente diverse: ci consente di fare cose come iniettare un logger e affermare contro le istruzioni di log ).
Elegante, eh? Abbiamo il pieno controllo di come vogliamo che GetFile si comporti e possiamo affermare contro i risultati. Abbiamo progettato il nostro codice in modo che sia compatibile con gli unit test e ora possiamo verificare i percorsi di successo e di errore implementati nel metodo GetFile .
Il codice è liberamente accoppiato e il refactoring in futuro dovrebbe essere un gioco da ragazzi. Lo abbiamo fatto scrivendo un semplice codice Go che qualsiasi sviluppatore che abbia familiarità con Go dovrebbe essere in grado di comprendere ed estendere quando necessario.
Mock: che dire dei dettagli di implementazione nitidi e grintosi?
Cosa ci comprerebbero i burloni che non otteniamo nella soluzione proposta? Un'ottima domanda che mostra un vantaggio per un mock tradizionale potrebbe essere: "come fai a sapere che hai chiamato il client s3 con i parametri corretti? Con i mock, posso assicurarmi di aver passato il valore della chiave al parametro chiave e non al parametro bucket".
Questa è una preoccupazione valida e dovrebbe essere trattata in un test da qualche parte . L'approccio di test che sostengo qui non verifica che tu abbia chiamato il client Minio con il bucket e i parametri chiave nell'ordine corretto.
Una grande citazione che ho letto di recente diceva: "Il beffardo introduce ipotesi, che introduce il rischio [10]". Stai assumendo che la libreria client sia implementata correttamente, stai assumendo che tutti i limiti siano solidi, stai presumendo di sapere come si comporta effettivamente la libreria.
Prendere in giro la libreria prende in giro solo le ipotesi e rende i tuoi test più fragili e soggetti a modifiche quando aggiorni il codice (che è ciò che Martin Fowler ha concluso in Mocks Aren't Stubs [3]). Quando la gomma incontra la strada, dovremo verificare che stiamo effettivamente utilizzando il client Minio correttamente e questo significa test di integrazione (questi potrebbero vivere in una configurazione Docker o in un ambiente di test). Poiché avremo sia test unitari che test di integrazione, non è necessario uno unit test per coprire l'esatta implementazione poiché il test di integrazione lo coprirà.
Nel nostro esempio, gli unit test guidano la progettazione del nostro codice e ci consentono di verificare rapidamente che gli errori e i flussi logici funzionino come previsto, facendo esattamente ciò che devono fare.
Per alcuni, ritengono che questa non sia una copertura sufficiente per gli unit test. Sono preoccupati per i punti sopra. Alcuni insisteranno sulle interfacce in stile bambola russa in cui un'interfaccia restituisce un'altra interfaccia che restituisce un'altra interfaccia, forse come la seguente:
E quindi potrebbero estrarre ogni parte del client Minio in ogni wrapper e quindi utilizzare un generatore fittizio (aggiungendo dipendenze a build e test, aumentando le ipotesi e rendendo le cose più fragili). Alla fine, il mockista potrà dire qualcosa del tipo:
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – e questo se riesci a ricordare l'incantesimo corretto per questo DSL specifico.
Questo sarebbe un sacco di astrazione extra legata direttamente alla scelta di implementazione di utilizzare il client Minio. Ciò causerà test fragili per quando scopriamo che dobbiamo cambiare le nostre ipotesi sul cliente o abbiamo bisogno di un cliente completamente diverso.
Ciò aumenta il tempo di sviluppo del codice end-to-end ora e in futuro, aumenta la complessità del codice e riduce la leggibilità, potenzialmente aumenta le dipendenze dai generatori di mock e ci dà il dubbio valore aggiuntivo di sapere se abbiamo confuso il bucket e i parametri chiave di cui avremmo comunque scoperto nei test di integrazione.
Man mano che vengono introdotti sempre più oggetti, l'accoppiamento diventa sempre più stretto. Potremmo aver simulato un logger e in seguito iniziare a simulare una metrica. Prima che tu te ne accorga, stai aggiungendo una voce di registro o una nuova metrica e hai appena superato mille test che non si aspettavano che arrivasse una metrica aggiuntiva.
L'ultima volta che sono stato morso da questo in Go, il framework beffardo non mi ha nemmeno detto quale test o file non funzionava poiché è andato nel panico ed è morto di una morte orribile perché si è imbattuto in una nuova metrica (questo richiedeva la ricerca binaria dei test commentandoli per essere in grado di trovare dove era necessario modificare il comportamento simulato). Le prese in giro possono aggiungere valore? Sicuro. Ne vale il costo? Nella maggior parte dei casi, non sono convinto.
Interfacce: semplicità e unit test per la vittoria
Abbiamo dimostrato che possiamo guidare la progettazione e garantire che il codice corretto e i percorsi di errore vengano seguiti con un semplice utilizzo delle interfacce in Go. Scrivendo falsi semplici che aderiscono alle interfacce, possiamo vedere che non abbiamo bisogno di mock, framework di mocking o generatori di mock per creare codice progettato per il test. Abbiamo anche notato che i test unitari non sono tutto ed è necessario scrivere test di integrazione per garantire che i sistemi siano correttamente integrati tra loro.
Spero di ricevere un post su alcuni modi accurati per eseguire test di integrazione in futuro; rimani sintonizzato!
Riferimenti
1: Endo-testing: Unit Testing with Mock Objects (2000): vedere l'introduzione per la definizione di oggetto simulato
2: The Little Mocker: Vedi la parte sui falsi, in particolare, "un Fake ha un comportamento commerciale. Puoi spingere un falso a comportarsi in modi diversi fornendogli dati diversi".
3: I mock non sono stub: vedi la sezione "Quindi dovrei essere un classicista o un mockista?" Martin Fowler afferma: "Non vedo alcun vantaggio convincente per il mockista TDD e sono preoccupato per le conseguenze dell'accoppiamento dei test all'implementazione".
4: Approccio ingenuo: una versione semplificata del codice. Vedere [7].
5: https://go-proverbs.github.io/: L'elenco di Go Proverbs con collegamenti a discorsi.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: collegamento diretto per parlare di Rob Pike in merito alla dimensione dell'interfaccia e all'astrazione.
7: Versione completa del codice demo: puoi clonare il repository ed eseguire `go test`.
8: Test guidati da tabelle: una strategia di test per organizzare il codice di test per ridurre la duplicazione.
9: Test per la versione completa del codice demo. Puoi eseguirli con `go test`.
10: Domande da porsi quando si scrivono i test di Michal Charemza: La presa in giro introduce ipotesi e le ipotesi introducono rischio.