Testarea unitară în Flutter: de la elementele esențiale ale fluxului de lucru la scenarii complexe
Publicat: 2022-09-21Interesul pentru Flutter este la cote maxime – și este de mult așteptat. SDK-ul open-source de la Google este compatibil cu Android, iOS, macOS, web, Windows și Linux. O singură bază de cod Flutter le acceptă pe toate. Iar testarea unitară este esențială în furnizarea unei aplicații Flutter consistente și fiabile, asigurându-se împotriva erorilor, defectelor și a defectelor prin îmbunătățirea preventivă a calității codului înainte de a fi asamblat.
În acest tutorial, împărtășim optimizări ale fluxului de lucru pentru testarea unitară Flutter, demonstrăm un test de bază Flutter unitar, apoi trecem la cazuri și biblioteci de testare Flutter mai complexe.
Fluxul testării unitare în Flutter
Implementăm testarea unitară în Flutter aproape în același mod în care o facem în alte stive de tehnologie:
- Evaluați codul.
- Configurați ridicarea datelor.
- Definiți grupul (grupurile) de testare.
- Definiți semnătura (semnăturile) funcției de testare pentru fiecare grup de testare.
- Scrie testele.
Pentru a demonstra testarea unitară, am pregătit un exemplu de proiect Flutter și vă încurajez să utilizați și să testați codul după cum doriți. Proiectul folosește un API extern pentru a prelua și afișa o listă de universități pe care le putem filtra în funcție de țară.
Câteva note despre cum funcționează Flutter: cadrul facilitează testarea prin încărcarea automată a bibliotecii flutter_test
atunci când este creat un proiect. Biblioteca îi permite lui Flutter să citească, să ruleze și să analizeze teste unitare. De asemenea, Flutter creează automat folderul de test
în care să stocheze testele. Este esențial să evitați redenumirea și/sau mutarea folderului de test
, deoarece acest lucru distruge funcționalitatea acestuia și, prin urmare, capacitatea noastră de a rula teste. De asemenea, este esențial să includem _test.dart
în numele fișierelor noastre de testare, deoarece acest sufix este modul în care Flutter recunoaște fișierele de testare.
Testează structura directorului
Pentru a promova testarea unitară în proiectul nostru, am implementat MVVM cu arhitectură curată și injecție de dependență (DI), așa cum se arată în numele alese pentru subfolderele codului sursă. Combinația dintre principiile MVVM și DI asigură o separare a preocupărilor:
- Fiecare clasă de proiect susține un singur obiectiv.
- Fiecare funcție dintr-o clasă își îndeplinește doar propriul domeniu de aplicare.
Vom crea un spațiu de stocare organizat pentru fișierele de test pe care le vom scrie, un sistem în care grupurile de teste vor avea „case” ușor de identificat. Având în vedere cerința lui Flutter de a localiza testele în folderul de test
, să oglindim structura de foldere a codului nostru sursă aflată în test
. Apoi, când scriem un test, îl vom stoca în subdosarul corespunzător: Așa cum șosetele curate intră în sertarul pentru șosete al comodei dvs. și cămășile pliate intră în sertarul cămășilor, testele unitare ale claselor de Model
merg într-un folder numit model
. , de exemplu.
Adoptarea acestui sistem de fișiere generează transparență în proiect și oferă echipei o modalitate ușoară de a vedea ce porțiuni din codul nostru au teste asociate.
Acum suntem gata să punem în aplicare testarea unitară.
Un simplu test unitar de flutter
Vom începe cu clasele de model
(în stratul de data
al codului sursă) și vom limita exemplul nostru pentru a include doar o clasă de model
, ApiUniversityModel
. Această clasă are două funcții:
- Inițializați modelul nostru ridicând obiectul JSON cu o
Map
. - Construiți modelul de date al
University
.
Pentru a testa fiecare dintre funcțiile modelului, vom personaliza pașii universali descriși anterior:
- Evaluați codul.
- Configurați ridicarea datelor: vom defini răspunsul serverului la apelul nostru API.
- Definiți grupurile de testare: Vom avea două grupuri de teste, câte unul pentru fiecare funcție.
- Definiți semnăturile funcției de testare pentru fiecare grup de testare.
- Scrie testele.
După ce ne evaluăm codul, suntem gata să ne îndeplinim al doilea obiectiv: să punem în mișcare date batjocoritoare specifice celor două funcții din clasa ApiUniversityModel
.
Pentru a bate joc de prima funcție (inițialând modelul nostru prin batjocura JSON cu o Map
), fromJson
, vom crea două obiecte Map
pentru a simula datele de intrare pentru funcție. De asemenea, vom crea două obiecte ApiUniversityModel
echivalente pentru a reprezenta rezultatul așteptat al funcției cu intrarea furnizată.
Pentru a bate joc de a doua funcție (construirea modelului de date al University
), toDomain
, vom crea două obiecte University
, care sunt rezultatul așteptat după ce am rulat această funcție în obiectele ApiUniversityModel
instanțiate anterior:
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"], ); }
În continuare, pentru al treilea și al patrulea obiectiv, vom adăuga un limbaj descriptiv pentru a defini grupurile noastre de testare și semnăturile funcției de testare:
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', () {}); }); }
Am definit semnăturile a două teste pentru a verifica funcția fromJson
și două pentru a verifica funcția toDomain
.
Pentru a ne îndeplini al cincilea obiectiv și a scrie testele, să folosim metoda expect
a bibliotecii flutter_test pentru a compara rezultatele funcțiilor cu așteptările noastre:
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); }); }); }
După ce ne-am îndeplinit cele cinci obiective, acum putem rula testele, fie din IDE, fie din linia de comandă.
La un terminal, putem rula toate testele conținute în folderul de test
introducând comanda flutter test
și să vedem că testele noastre trec.
Alternativ, am putea rula un singur test sau grup de testare introducând comanda flutter test --plain-name "ReplaceWithName"
, înlocuind numele testului sau grupului nostru de testare cu ReplaceWithName
.
Testarea unitară a unui punct final în Flutter
După ce am finalizat un test simplu fără dependențe, haideți să explorăm un exemplu mai interesant: Vom testa clasa de endpoint
, al cărei scop cuprinde:
- Executarea unui apel API către server.
- Transformarea răspunsului API JSON într-un format diferit.
După ce ne-am evaluat codul, vom folosi metoda setUp a bibliotecii setUp
pentru a inițializa clasele din grupul nostru de testare:
group("Test University Endpoint API calls", () { setUp(() { baseUrl = "https://test.url"; dioClient = Dio(BaseOptions()); endpoint = UniversityEndpoint(dioClient, baseUrl: baseUrl); }); }
Pentru a face solicitări de rețea către API-uri, prefer să folosesc biblioteca de retrofit, care generează cea mai mare parte a codului necesar. Pentru a testa în mod corespunzător clasa UniversityEndpoint
, vom forța biblioteca dio - pe care Retrofit
o folosește pentru a executa apeluri API - să returneze rezultatul dorit prin batjocură de comportamentul clasei Dio
printr-un adaptor de răspuns personalizat.
Model de interceptor de rețea personalizat
Batjocorirea este posibilă datorită faptului că am construit clasa UniversityEndpoint
prin DI. (Dacă clasa UniversityEndpoint
ar inițializa o clasă Dio
singură, nu ar exista nicio modalitate de a ne batjocori comportamentul clasei.)
Pentru a bate joc de comportamentul clasei Dio
, trebuie să cunoaștem metodele Dio
utilizate în biblioteca Retrofit
, dar nu avem acces direct la Dio
. Prin urmare, vom bate joc de Dio
folosind un interceptor de răspuns personalizat în rețea:
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); }
Acum că am creat interceptorul pentru a ne batjocori răspunsurile din rețea, putem defini grupurile noastre de testare și semnăturile funcțiilor de testare.
În cazul nostru, avem de testat o singură funcție ( getUniversitiesByCountry
), așa că vom crea un singur grup de teste. Vom testa răspunsul funcției noastre la trei situații:
- Funcția clasei
Dio
este de fapt numită degetUniversitiesByCountry
? - Dacă solicitarea noastră API returnează o eroare, ce se întâmplă?
- Dacă solicitarea noastră API returnează rezultatul așteptat, ce se întâmplă?
Iată semnăturile noastre pentru grupul de testare și funcția de testare:
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 {}); });
Suntem gata să ne scriem testele. Pentru fiecare caz de testare, vom crea o instanță de DioMockResponsesAdapter
cu configurația corespunzătoare:
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()); }); });
Acum că testarea punctului final este finalizată, să testăm clasa noastră sursă de date, UniversityRemoteDataSource
. Mai devreme, am observat că clasa UniversityEndpoint
face parte din constructorul UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint})
, ceea ce indică faptul că UniversityRemoteDataSource
folosește clasa UniversityEndpoint
pentru a-și îndeplini scopul, așa că aceasta este clasa pe care o vom bate joc.
Batjocorind cu Mockito
În exemplul nostru anterior, am batjocorit manual adaptorul de solicitare al clientului nostru Dio
folosind un NetworkInterceptor
personalizat. Aici ne batem joc de o clasă întreagă. Făcând acest lucru manual — batjocorirea unei clase și a funcțiilor sale — ar consuma mult timp. Din fericire, bibliotecile simulate sunt concepute pentru a gestiona astfel de situații și pot genera clase simulate cu un efort minim. Să folosim biblioteca mockito, biblioteca standard din industrie pentru batjocură în Flutter.
Pentru a bate joc de Mockito
, adăugăm mai întâi adnotarea „ @GenerateMocks([class_1,class_2,…])
” înainte de codul testului, chiar deasupra funcției void main() {}
. În adnotare, vom include o listă de nume de clase ca parametru (în loc de class_1,class_2…
).
Apoi, rulăm comanda Flutter flutter pub run build_runner build
care generează codul pentru clasele noastre simulate în același director ca și testul. Numele fișierului simulat rezultat va fi o combinație a numelui fișierului de test plus .mocks.dart
, înlocuind sufixul .dart
al testului. Conținutul fișierului va include clase simulate ale căror nume încep cu prefixul Mock
. De exemplu, UniversityEndpoint
devine MockUniversityEndpoint
.
Acum, importăm university_remote_data_source_test.dart.mocks.dart
(fișierul nostru simulat) în university_remote_data_source_test.dart
(fișierul de testare).
Apoi, în funcția setUp
, vom bate joc de UniversityEndpoint
utilizând MockUniversityEndpoint
și inițialând clasa 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); }); }
Am batjocorit cu succes UniversityEndpoint
și apoi am inițializat clasa noastră UniversityRemoteDataSource
. Acum suntem gata să definim grupurile noastre de testare și semnăturile funcțiilor de testare:
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', () {}); });
Cu aceasta, sunt create semnăturile noastre batjocoritoare, grupuri de testare și funcții de testare. Suntem gata să scriem testele reale.
Primul nostru test verifică dacă funcția UniversityEndpoint
este apelată atunci când sursa de date inițiază preluarea informațiilor despre țară. Începem prin a defini cum va reacționa fiecare clasă atunci când funcțiile sale sunt apelate. Din moment ce am batjocorit clasa UniversityEndpoint
, aceasta este clasa cu care vom lucra, folosind structura de cod when( function_that_will_be_called ).then( what_will_be_returned )
.
Funcțiile pe care le testăm sunt asincrone (funcții care returnează un obiect Future
), așa că vom folosi structura de cod when(function name).thenanswer( (_) {modified function result} )
pentru a ne modifica rezultatele.
Pentru a verifica dacă funcția getUniversitiesByCountry
apelează funcția getUniversitiesByCountry
din clasa UniversityEndpoint
, vom folosi when(...).thenAnswer( (_) {...} )
pentru a bate joc de funcția getUniversitiesByCountry
din clasa UniversityEndpoint
:
when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[]));
Acum că ne-am batjocorit răspunsul, apelăm funcția sursă de date și verificăm, folosind funcția de verify
, dacă funcția UniversityEndpoint
a fost numită:
test('Test dataSource calls getUniversitiesByCountry from endpoint', () { when(endpoint.getUniversitiesByCountry("test")) .thenAnswer((realInvocation) => Future.value(<ApiUniversityModel>[])); dataSource.getUniversitiesByCountry("test"); verify(endpoint.getUniversitiesByCountry("test")); });
Putem folosi aceleași principii pentru a scrie teste suplimentare care verifică dacă funcția noastră transformă corect rezultatele noastre finale în fluxurile relevante de date:
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) ]), ); }); }); }
Am executat o serie de teste unitare Flutter și am demonstrat diferite abordări ale batjocoririi. Vă invit să continuați să utilizați exemplul meu de proiect Flutter pentru a efectua teste suplimentare.
Teste de unitate Flutter: cheia pentru UX superioară
Dacă încorporați deja testarea unitară în proiectele dvs. Flutter, este posibil ca acest articol să fi introdus câteva opțiuni noi pe care le-ați putea injecta în fluxul de lucru. În acest tutorial, am demonstrat cât de simplu ar fi să încorporezi testarea unitară în următorul tău proiect Flutter și cum să faci față provocărilor unor scenarii de testare mai nuanțate. Este posibil să nu doriți să săriți niciodată peste testele unitare în Flutter din nou.
Echipa editorială a Toptal Engineering Blog își exprimă recunoștința față de Matija Becirevic și Paul Hoskins pentru revizuirea mostrelor de cod și a altor conținuturi tehnice prezentate în acest articol.