Unit test in Flutter: dagli elementi essenziali del flusso di lavoro a scenari complessi

Pubblicato: 2022-09-21

L'interesse per Flutter è ai massimi storici ed è atteso da tempo. L'SDK open source di Google è compatibile con Android, iOS, macOS, Web, Windows e Linux. Una singola base di codice Flutter li supporta tutti. E lo unit test è fondamentale per fornire un'app Flutter coerente e affidabile, che garantisce errori, difetti e difetti migliorando preventivamente la qualità del codice prima che venga assemblato.

In questo tutorial, condividiamo le ottimizzazioni del flusso di lavoro per i test unitari Flutter, dimostriamo un test unitario Flutter di base, quindi passiamo a casi e librerie di test Flutter più complessi.

Il flusso del test unitario nel flutter

Implementiamo i test unitari in Flutter più o meno allo stesso modo in cui facciamo in altri stack tecnologici:

  1. Valuta il codice.
  2. Imposta la simulazione dei dati.
  3. Definire il/i gruppo/i di test.
  4. Definire le firme delle funzioni di test per ciascun gruppo di test.
  5. Scrivi i test.

Per dimostrare lo unit test, ho preparato un progetto Flutter di esempio e ti incoraggio a utilizzare e testare il codice a tuo piacimento. Il progetto utilizza un'API esterna per recuperare e visualizzare un elenco di università che possiamo filtrare per paese.

Alcune note su come funziona Flutter: Il framework facilita il test caricando automaticamente la libreria flutter_test quando viene creato un progetto. La libreria consente a Flutter di leggere, eseguire e analizzare gli unit test. Flutter crea anche automaticamente la cartella del test in cui archiviare i test. È fondamentale evitare di rinominare e/o spostare la cartella di test , poiché ciò interrompe la sua funzionalità e, quindi, la nostra capacità di eseguire i test. È anche essenziale includere _test.dart nei nomi dei nostri file di test, poiché questo suffisso è il modo in cui Flutter riconosce i file di test.

Struttura della directory di prova

Per promuovere i test di unità nel nostro progetto, abbiamo implementato MVVM con un'architettura pulita e iniezione di dipendenza (DI), come evidenziato nei nomi scelti per le sottocartelle del codice sorgente. La combinazione dei principi MVVM e DI garantisce una separazione delle preoccupazioni:

  1. Ogni classe di progetto supporta un singolo obiettivo.
  2. Ogni funzione all'interno di una classe soddisfa solo il proprio ambito.

Creeremo uno spazio di archiviazione organizzato per i file di test che scriveremo, un sistema in cui i gruppi di test avranno "case" facilmente identificabili. Alla luce del requisito di Flutter di individuare i test all'interno della cartella test , rispecchiamo la struttura delle cartelle del nostro codice sorgente sottoposta a test . Quindi, quando scriviamo un test, lo memorizzeremo nella sottocartella appropriata: proprio come i calzini puliti vanno nel cassetto dei calzini del comò e le camicie piegate nel cassetto delle camicie, i test unitari delle classi Model vanno in una cartella denominata model , Per esempio.

Struttura delle cartelle di file con due cartelle di primo livello: lib e test. Nidificato sotto lib abbiamo la cartella delle funzionalità, ulteriormente nidificato è university_feed e ulteriormente nidificato è data. La cartella dei dati contiene il repository e le cartelle di origine. Annidata sotto la cartella di origine c'è la cartella di rete. Annidati sotto la rete ci sono le cartelle dell'endpoint e del modello, oltre al file university_remote_data_source.dart. Nella cartella del modello è presente il file api_university_model.dart. Allo stesso livello della già citata cartella university_feed si trovano le cartelle di dominio e di presentazione. Nidificata sotto il dominio c'è la cartella del caso d'uso. Annidati sotto la presentazione ci sono i modelli e le cartelle dello schermo. La struttura della cartella di test menzionata in precedenza imita quella di lib. Annidata sotto la cartella test c'è la cartella unit_test che contiene la cartella university_feed. La sua struttura di cartelle è la stessa della cartella university_feed sopra, con i suoi file dart con "_test" aggiunto ai loro nomi.
La struttura della cartella di prova del progetto rispecchia la struttura del codice sorgente

L'adozione di questo file system crea trasparenza nel progetto e offre al team un modo semplice per visualizzare quali parti del nostro codice hanno test associati.

Ora siamo pronti per mettere in atto gli unit test.

Un semplice test dell'unità di flutter

Inizieremo con le classi del model (nel livello data del codice sorgente) e limiteremo il nostro esempio a includere una sola classe del model , ApiUniversityModel . Questa classe vanta due funzioni:

  • Inizializza il nostro modello prendendo in giro l'oggetto JSON con un Map .
  • Costruisci il modello dati di University .

Per testare ciascuna delle funzioni del modello, personalizzeremo i passaggi universali descritti in precedenza:

  1. Valuta il codice.
  2. Imposta il mocking dei dati: definiremo la risposta del server alla nostra chiamata API.
  3. Definire i gruppi di test: avremo due gruppi di test, uno per ciascuna funzione.
  4. Definire le firme delle funzioni di test per ciascun gruppo di test.
  5. Scrivi i test.

Dopo aver valutato il nostro codice, siamo pronti per raggiungere il nostro secondo obiettivo: impostare il mocking dei dati specifico per le due funzioni all'interno della classe ApiUniversityModel .

Per deridere la prima funzione (inizializzazione del nostro modello deridendo il JSON con una Map ), fromJson , creeremo due oggetti Map per simulare i dati di input per la funzione. Creeremo anche due oggetti ApiUniversityModel equivalenti per rappresentare il risultato atteso della funzione con l'input fornito.

Per deridere la seconda funzione (costruendo il modello dati di University ), toDomain , creeremo due oggetti University , che sono il risultato atteso dopo aver eseguito questa funzione negli oggetti ApiUniversityModel precedentemente istanziati:

 void main() { Map<String, dynamic> apiUniversityOneAsJson = { "alpha_two_code": "US", "domains": ["marywood.edu"], "country": "United States", "state-province": null, "web_pages": ["http://www.marywood.edu"], "name": "Marywood University" }; ApiUniversityModel expectedApiUniversityOne = ApiUniversityModel( alphaCode: "US", country: "United States", state: null, name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); University expectedUniversityOne = University( alphaCode: "US", country: "United States", state: "", name: "Marywood University", websites: ["http://www.marywood.edu"], domains: ["marywood.edu"], ); Map<String, dynamic> apiUniversityTwoAsJson = { "alpha_two_code": "US", "domains": ["lindenwood.edu"], "country": "United States", "state-province":"MJ", "web_pages": null, "name": "Lindenwood University" }; ApiUniversityModel expectedApiUniversityTwo = ApiUniversityModel( alphaCode: "US", country: "United States", state:"MJ", name: "Lindenwood University", websites: null, domains: ["lindenwood.edu"], ); University expectedUniversityTwo = University( alphaCode: "US", country: "United States", state: "MJ", name: "Lindenwood University", websites: [], domains: ["lindenwood.edu"], ); }

Successivamente, per il nostro terzo e quarto obiettivo, aggiungeremo un linguaggio descrittivo per definire i nostri gruppi di test e le firme delle funzioni di test:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from JSON", () { test('Test using json one', () {}); test('Test using json two', () {}); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () {}); test('Test toDomain using json two', () {}); }); }

Abbiamo definito le firme di due test per controllare la funzione fromJson e due per controllare la funzione toDomain .

Per raggiungere il nostro quinto obiettivo e scrivere i test, utilizziamo il metodo Expect della libreria expect per confrontare i risultati delle funzioni con le nostre aspettative:

 void main() { // Previous declarations group("Test ApiUniversityModel initialization from json", () { test('Test using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson), expectedApiUniversityOne); }); test('Test using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson), expectedApiUniversityTwo); }); }); group("Test ApiUniversityModel toDomain", () { test('Test toDomain using json one', () { expect(ApiUniversityModel.fromJson(apiUniversityOneAsJson).toDomain(), expectedUniversityOne); }); test('Test toDomain using json two', () { expect(ApiUniversityModel.fromJson(apiUniversityTwoAsJson).toDomain(), expectedUniversityTwo); }); }); }

Dopo aver raggiunto i nostri cinque obiettivi, ora possiamo eseguire i test, dall'IDE o dalla riga di comando.

Screenshot che indica che cinque test su cinque sono stati superati. L'intestazione recita: Esegui: test in api_university_model_test.dart. Il pannello sinistro dello schermo riporta: Risultati del test---caricamento api_university_model_test.dart---api_university_model_test.dart---Test ApiUniversityModel initialization from json---Test using json one---Test using json two---Test ApiUniversityModel toDomain ---Testa su dominio usando json uno ---Test su dominio usando json due. Il pannello di destra dello schermo riporta: Test superati: cinque su cinque test --- flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

Da un terminale, possiamo eseguire tutti i test contenuti nella cartella test immettendo il comando flutter test e vedere che i nostri test passano.

In alternativa, potremmo eseguire un singolo test o gruppo di test immettendo il comando flutter test --plain-name "ReplaceWithName" , sostituendo il nome del nostro test o gruppo di test con ReplaceWithName .

Test unitario di un endpoint in Flutter

Dopo aver completato un semplice test senza dipendenze, esploriamo un esempio più interessante: testeremo la classe endpoint , il cui ambito comprende:

  • Esecuzione di una chiamata API al server.
  • Trasformazione della risposta JSON dell'API in un formato diverso.

Dopo aver valutato il nostro codice, utilizzeremo il metodo setUp della libreria setUp per inizializzare le classi all'interno del nostro gruppo di test:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }

Per effettuare richieste di rete alle API, preferisco utilizzare la libreria di retrofit, che genera la maggior parte del codice necessario. Per testare correttamente la classe UniversityEndpoint , forzeremo la libreria dio, utilizzata da Retrofit per eseguire le chiamate API, a restituire il risultato desiderato deridendo il comportamento della classe Dio tramite un adattatore di risposta personalizzato.

Mock di intercettazione di rete personalizzato

La presa in giro è possibile grazie al fatto che abbiamo costruito la classe UniversityEndpoint tramite DI. (Se la classe UniversityEndpoint dovesse inizializzare una classe Dio da sola, non ci sarebbe modo di deridere il comportamento della classe.)

Per deridere il comportamento della classe Dio , dobbiamo conoscere i metodi Dio utilizzati all'interno della libreria Retrofit , ma non abbiamo accesso diretto a Dio . Pertanto, prenderemo in giro Dio usando un intercettore di risposta di rete personalizzato:

 class DioMockResponsesAdapter extends HttpClientAdapter { final MockAdapterInterceptor interceptor; DioMockResponsesAdapter(this.interceptor); @override void close({bool force = false}) {} @override Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future? cancelFuture) { if (options.method == interceptor.type.name.toUpperCase() && options.baseUrl == interceptor.uri && options.queryParameters.hasSameElementsAs(interceptor.query) && options.path == interceptor.path) { return Future.value(ResponseBody.fromString( jsonEncode(interceptor.serializableResponse), interceptor.responseCode, headers: { "content-type": ["application/json"] }, )); } return Future.value(ResponseBody.fromString( jsonEncode( {"error": "Request doesn't match the mock interceptor details!"}), -1, statusMessage: "Request doesn't match the mock interceptor details!")); } } enum RequestType { GET, POST, PUT, PATCH, DELETE } class MockAdapterInterceptor { final RequestType type; final String uri; final String path; final Map<String, dynamic> query; final Object serializableResponse; final int responseCode; MockAdapterInterceptor(this.type, this.uri, this.path, this.query, this.serializableResponse, this.responseCode); }

Ora che abbiamo creato l'interceptor per deridere le nostre risposte di rete, possiamo definire i nostri gruppi di test e le firme delle funzioni di test.

Nel nostro caso, abbiamo solo una funzione da testare ( getUniversitiesByCountry ), quindi creeremo un solo gruppo di test. Verificheremo la risposta della nostra funzione a tre situazioni:

  1. La funzione della classe Dio è effettivamente chiamata da getUniversitiesByCountry ?
  2. Se la nostra richiesta API restituisce un errore, cosa succede?
  3. Se la nostra richiesta API restituisce il risultato atteso, cosa succede?

Ecco il nostro gruppo di test e le firme delle funzioni di test:

 group("Test University Endpoint API calls", () { test('Test endpoint calls dio', () async {}); test('Test endpoint returns error', () async {}); test('Test endpoint calls and returns 2 valid universities', () async {}); });

Siamo pronti per scrivere i nostri test. Per ogni test case, creeremo un'istanza di DioMockResponsesAdapter con la configurazione corrispondente:

 group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); test('Test endpoint calls dio', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, [], ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, <ApiUniversityModel>[]); }); test('Test endpoint returns error', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 404, {"error": "Not found!"}, ); List<ApiUniversityModel>? response; DioError? error; try { response = await endpoint.getUniversitiesByCountry("us"); } on DioError catch (dioError, _) { error = dioError; } expect(response, null); expect(error?.error, "Http status error [404]"); }); test('Test endpoint calls and returns 2 valid universities', () async { dioClient.httpClientAdapter = _createMockAdapterForSearchRequest( 200, generateTwoValidUniversities(), ); var result = await endpoint.getUniversitiesByCountry("us"); expect(result, expectedTwoValidUniversities()); }); });

Ora che il test dell'endpoint è completo, testiamo la nostra classe di origine dati, UniversityRemoteDataSource . In precedenza, abbiamo osservato che la classe UniversityEndpoint fa parte del costruttore UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , che indica che UniversityRemoteDataSource usa la classe UniversityEndpoint per soddisfare il suo ambito, quindi questa è la classe che prenderemo in giro.

Beffardo con Mockito

Nel nostro esempio precedente, abbiamo preso in giro manualmente l'adattatore di richiesta del nostro client Dio utilizzando un NetworkInterceptor personalizzato. Qui stiamo prendendo in giro un'intera classe. Farlo manualmente, prendendo in giro una classe e le sue funzioni, richiederebbe molto tempo. Fortunatamente, le librerie fittizie sono progettate per gestire tali situazioni e possono generare classi fittizie con il minimo sforzo. Usiamo la libreria mockito, la libreria standard del settore per prendere in giro in Flutter.

Per prendere in giro Mockito , aggiungiamo prima l'annotazione " @GenerateMocks([class_1,class_2,…]) " prima del codice del test, appena sopra la funzione void main() {} . Nell'annotazione, includeremo un elenco di nomi di classi come parametro (al posto di class_1,class_2… ).

Successivamente, eseguiamo il comando flutter pub run build_runner build di Flutter che genera il codice per le nostre classi fittizie nella stessa directory del test. Il nome del file fittizio risultante sarà una combinazione del nome del file di test più .mocks.dart , sostituendo il suffisso .dart del test. Il contenuto del file includerà classi fittizie i cui nomi iniziano con il prefisso Mock . Ad esempio, UniversityEndpoint diventa MockUniversityEndpoint .

Ora importiamo university_remote_data_source_test.dart.mocks.dart (il nostro file fittizio) in university_remote_data_source_test.dart (il file di test).

Quindi, nella funzione setUp , prenderemo in giro UniversityEndpoint utilizzando MockUniversityEndpoint e inizializzando la classe UniversityRemoteDataSource :

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); }

Abbiamo deriso con successo UniversityEndpoint e quindi abbiamo inizializzato la nostra classe UniversityRemoteDataSource . Ora siamo pronti per definire i nostri gruppi di test e le firme delle funzioni di test:

 group("Test function calls", () { test('Test dataSource calls getUniversitiesByCountry from endpoint', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream', () {}); test('Test dataSource maps getUniversitiesByCountry response to Stream with error', () {}); });

Con questo, vengono impostati i nostri modelli di simulazione, gruppi di test e firme delle funzioni di test. Siamo pronti per scrivere i test veri e propri.

Il nostro primo test verifica se la funzione UniversityEndpoint viene chiamata quando l'origine dati avvia il recupero delle informazioni sul paese. Iniziamo definendo come ciascuna classe reagirà quando vengono chiamate le sue funzioni. Dato che abbiamo preso in giro la classe UniversityEndpoint , questa è la classe con cui lavoreremo, usando la struttura del codice when( function_that_will_be_called ).then( what_will_be_returned ) .

Le funzioni che stiamo testando sono asincrone (funzioni che restituiscono un oggetto Future ), quindi useremo la struttura del codice when(function name).thenanswer( (_) {modified function result} ) per modificare i nostri risultati.

Per verificare se la funzione getUniversitiesByCountry chiama la funzione getUniversitiesByCountry all'interno della classe UniversityEndpoint , useremo when(...).thenAnswer( (_) {...} ) per deridere la funzione getUniversitiesByCountry all'interno della classe UniversityEndpoint :

 when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));

Ora che abbiamo preso in giro la nostra risposta, chiamiamo la funzione di origine dati e controlliamo, utilizzando la funzione di verify , se è stata chiamata la funzione UniversityEndpoint :

 test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });

Possiamo utilizzare gli stessi principi per scrivere test aggiuntivi che verificano se la nostra funzione trasforma correttamente i risultati dei nostri endpoint nei flussi di dati rilevanti:

 import 'university_remote_data_source_test.mocks.dart'; @GenerateMocks([UniversityEndpoint]) void main() { late UniversityEndpoint endpoint; late UniversityRemoteDataSource dataSource; group("Test function calls", () { setUp(() { endpoint = MockUniversityEndpoint(); dataSource = UniversityRemoteDataSource(universityEndpoint: endpoint); }); test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); }); test('Test dataSource maps getUniversitiesByCountry response to Stream', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), const AppResult<List<University>>.data([]) ]), ); }); test( 'Test dataSource maps getUniversitiesByCountry response to Stream with error', () { ApiError mockApiError = ApiError( statusCode: 400, message: "error", errors: null, ); when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.error(mockApiError)); expect( dataSource.getUniversitiesByCountry("test"), emitsInOrder([ const AppResult<List<University>>.loading(), AppResult<List<University>>.apiError(mockApiError) ]), ); }); }); }

Abbiamo eseguito una serie di unit test Flutter e dimostrato diversi approcci alla presa in giro. Ti invito a continuare a utilizzare il mio progetto Flutter di esempio per eseguire ulteriori test.

Test unitari Flutter: la chiave per un'esperienza utente superiore

Se incorpori già lo unit test nei tuoi progetti Flutter, questo articolo potrebbe aver introdotto alcune nuove opzioni che potresti inserire nel tuo flusso di lavoro. In questo tutorial, abbiamo dimostrato quanto sarebbe semplice incorporare i test unitari nel tuo prossimo progetto Flutter e come affrontare le sfide di scenari di test più sfumati. Potresti non voler mai più saltare gli unit test in Flutter.

La redazione di Toptal Engineering Blog estende la propria gratitudine a Matija Becirevic e Paul Hoskins per aver esaminato gli esempi di codice e altri contenuti tecnici presentati in questo articolo.