Testarea unitară în Flutter: de la elementele esențiale ale fluxului de lucru la scenarii complexe

Publicat: 2022-09-21

Interesul 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:

  1. Evaluați codul.
  2. Configurați ridicarea datelor.
  3. Definiți grupul (grupurile) de testare.
  4. Definiți semnătura (semnăturile) funcției de testare pentru fiecare grup de testare.
  5. 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:

  1. Fiecare clasă de proiect susține un singur obiectiv.
  2. 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.

Structura de foldere de fișiere cu două foldere de prim nivel: lib și test. Imbricat sub lib avem folderul caracteristici, mai mult imbricat este universities_feed și mai mult imbricat sunt date. Dosarul de date conține folderele de depozit și sursă. Imbricat sub folderul sursă este folderul de rețea. Sub rețea sunt imbricate folderele de puncte terminale și model, plus fișierul university_remote_data_source.dart. În folderul model se află fișierul api_university_model.dart. La același nivel cu folderul universities_feed menționat anterior se află folderele de domeniu și de prezentare. Imbricat sub domeniu este folderul usecase. Imbricate sub prezentare sunt modelele și folderele ecranului. Structura folderului de testare menționată anterior o imită pe cea a lib. Sub folderul de testare se află folderul unit_test care conține folderul universities_feed. Structura folderului său este aceeași cu folderul universities_feed de mai sus, cu fișierele sale dart având „_test” adăugat la nume.
Structura folderului de testare a proiectului care oglindește structura codului sursă

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:

  1. Evaluați codul.
  2. Configurați ridicarea datelor: vom defini răspunsul serverului la apelul nostru API.
  3. Definiți grupurile de testare: Vom avea două grupuri de teste, câte unul pentru fiecare funcție.
  4. Definiți semnăturile funcției de testare pentru fiecare grup de testare.
  5. 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ă.

Captură de ecran care indică faptul că cinci teste din cinci au trecut. Antetul scrie: Run: tests in api_university_model_test.dart. Panoul din stânga a ecranului arată: Rezultatele testului---încărcare api_university_model_test.dart---api_university_model_test.dart---Testează inițializarea ApiUniversityModel de la json---Testează folosind json one---Testează folosind json doi---Testează ApiUniversityModel peDomain ---Testează toDomain folosind json unu---Testează toDomain folosind json doi. Panoul din dreapta al ecranului afișează: Teste trecute: cinci din cinci teste---flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

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:

  1. Funcția clasei Dio este de fapt numită de getUniversitiesByCountry ?
  2. Dacă solicitarea noastră API returnează o eroare, ce se întâmplă?
  3. 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.