Pruebas unitarias en Flutter: desde elementos esenciales del flujo de trabajo hasta escenarios complejos

Publicado: 2022-09-21

El interés en Flutter está en su punto más alto, y hace mucho que debería haberlo hecho. El SDK de código abierto de Google es compatible con Android, iOS, macOS, web, Windows y Linux. Una sola base de código de Flutter los admite a todos. Y las pruebas unitarias son fundamentales para ofrecer una aplicación Flutter consistente y confiable, asegurando contra errores, fallas y defectos al mejorar de manera preventiva la calidad del código antes de ensamblarlo.

En este tutorial, compartimos optimizaciones de flujo de trabajo para pruebas unitarias de Flutter, demostramos una prueba unitaria básica de Flutter y luego pasamos a bibliotecas y casos de prueba de Flutter más complejos.

El flujo de pruebas unitarias en Flutter

Implementamos pruebas unitarias en Flutter de la misma manera que lo hacemos en otras pilas de tecnología:

  1. Evalúa el código.
  2. Configurar la simulación de datos.
  3. Defina los grupos de prueba.
  4. Defina firmas de función de prueba para cada grupo de prueba.
  5. Escribe las pruebas.

Para demostrar las pruebas unitarias, preparé un proyecto Flutter de muestra y lo animo a usar y probar el código cuando lo desee. El proyecto utiliza una API externa para buscar y mostrar una lista de universidades que podemos filtrar por país.

Algunas notas sobre cómo funciona Flutter: el marco facilita las pruebas al cargar automáticamente la biblioteca flutter_test cuando se crea un proyecto. La biblioteca permite que Flutter lea, ejecute y analice pruebas unitarias. Flutter también crea automáticamente la carpeta de test en la que almacenar las pruebas. Es fundamental evitar cambiar el nombre y/o mover la carpeta de test , ya que esto interrumpe su funcionalidad y, por lo tanto, nuestra capacidad para ejecutar pruebas. También es esencial incluir _test.dart en nuestros nombres de archivos de prueba, ya que este sufijo es cómo Flutter reconoce los archivos de prueba.

Estructura del directorio de prueba

Para promover las pruebas unitarias en nuestro proyecto, implementamos MVVM con arquitectura limpia e inyección de dependencia (DI), como se evidencia en los nombres elegidos para las subcarpetas del código fuente. La combinación de los principios MVVM y DI asegura una separación de preocupaciones:

  1. Cada clase de proyecto apoya un solo objetivo.
  2. Cada función dentro de una clase cumple solo su propio ámbito.

Crearemos un espacio de almacenamiento organizado para los archivos de prueba que escribiremos, un sistema donde los grupos de pruebas tendrán "hogares" fácilmente identificables. A la luz del requisito de Flutter de ubicar las pruebas dentro de la carpeta de test , reflejemos la estructura de carpetas de nuestro código fuente bajo test . Luego, cuando escribimos una prueba, la almacenaremos en la subcarpeta apropiada: así como los calcetines limpios van en el cajón de los calcetines de su tocador y las camisas dobladas van en el cajón de las camisas, las pruebas unitarias de las clases Model van en una carpeta llamada model , por ejemplo.

Estructura de carpetas de archivos con dos carpetas de primer nivel: lib y test. Anidado debajo de lib tenemos la carpeta de funciones, más anidado es university_feed y más anidado es data. La carpeta de datos contiene el repositorio y las carpetas de origen. Anidada debajo de la carpeta de origen se encuentra la carpeta de red. Anidadas debajo de la red se encuentran las carpetas de puntos finales y modelos, además del archivo university_remote_data_source.dart. En la carpeta del modelo se encuentra el archivo api_university_model.dart. Al mismo nivel que la carpeta university_feed mencionada anteriormente se encuentran las carpetas de dominio y presentación. Anidado debajo del dominio está la carpeta de casos de uso. Anidados debajo de la presentación están los modelos y las carpetas de pantalla. La estructura de la carpeta de prueba mencionada anteriormente imita la de lib. Anidada debajo de la carpeta de prueba está la carpeta unit_test que contiene la carpeta university_feed. Su estructura de carpetas es la misma que la carpeta university_feed anterior, con sus archivos dart que tienen "_test" adjunto a sus nombres.
La estructura de carpetas de prueba del proyecto refleja la estructura del código fuente

La adopción de este sistema de archivos genera transparencia en el proyecto y le brinda al equipo una manera fácil de ver qué partes de nuestro código tienen pruebas asociadas.

Ahora estamos listos para poner en práctica las pruebas unitarias.

Una prueba de unidad de aleteo simple

Comenzaremos con las clases model (en la capa de data del código fuente) y limitaremos nuestro ejemplo para incluir solo una clase model , ApiUniversityModel . Esta clase cuenta con dos funciones:

  • Inicialice nuestro modelo burlándose del objeto JSON con un Map .
  • Cree el modelo de datos de la University .

Para probar cada una de las funciones del modelo, personalizaremos los pasos universales descritos anteriormente:

  1. Evalúa el código.
  2. Configurar la simulación de datos: definiremos la respuesta del servidor a nuestra llamada API.
  3. Defina los grupos de prueba: Tendremos dos grupos de prueba, uno para cada función.
  4. Defina firmas de función de prueba para cada grupo de prueba.
  5. Escribe las pruebas.

Después de evaluar nuestro código, estamos listos para lograr nuestro segundo objetivo: configurar la simulación de datos específica para las dos funciones dentro de la clase ApiUniversityModel .

Para simular la primera función (inicializando nuestro modelo simulando el JSON con un Map ), fromJson , crearemos dos objetos Map para simular los datos de entrada para la función. También crearemos dos objetos ApiUniversityModel equivalentes para representar el resultado esperado de la función con la entrada proporcionada.

Para simular la segunda función (construir el modelo de datos de la University ), toDomain , crearemos dos objetos de la University , que son el resultado esperado después de haber ejecutado esta función en los objetos ApiUniversityModel instanciados previamente:

 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"], ); }

A continuación, para nuestros objetivos tercero y cuarto, agregaremos un lenguaje descriptivo para definir nuestros grupos de prueba y firmas de funciones de prueba:

 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', () {}); }); }

Hemos definido las firmas de dos pruebas para verificar la función fromJson y dos para verificar la función toDomain .

Para cumplir con nuestro quinto objetivo y escribir las pruebas, usemos el método expect de la biblioteca flutter_test para comparar los resultados de las funciones con nuestras expectativas:

 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); }); }); }

Habiendo logrado nuestros cinco objetivos, ahora podemos ejecutar las pruebas, ya sea desde el IDE o desde la línea de comandos.

Captura de pantalla que indica que se aprobaron cinco de cinco pruebas. El encabezado dice: Ejecutar: pruebas en api_university_model_test.dart. El panel izquierdo de la pantalla dice: Resultados de la prueba---cargando api_university_model_test.dart---api_university_model_test.dart---Probar la inicialización de ApiUniversityModel desde json---Prueba usando json one---Prueba usando json dos---Prueba ApiUniversityModel toDomain ---Pruebe toDomain usando json one---Pruebe toDomain usando json two. El panel derecho de la pantalla dice: Pruebas aprobadas: cinco de cinco pruebas --- prueba de aleteo test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

En una terminal, podemos ejecutar todas las pruebas contenidas dentro de la carpeta de test ingresando el comando de flutter test y ver que nuestras pruebas pasen.

Alternativamente, podríamos ejecutar una sola prueba o grupo de prueba ingresando el flutter test --plain-name "ReplaceWithName" , sustituyendo el nombre de nuestra prueba o grupo de prueba por ReplaceWithName .

Unidad de prueba de un punto final en Flutter

Habiendo completado una prueba simple sin dependencias, exploremos un ejemplo más interesante: probaremos la clase de endpoint , cuyo alcance abarca:

  • Ejecutando una llamada API al servidor.
  • Transformar la respuesta API JSON en un formato diferente.

Después de haber evaluado nuestro código, usaremos el método setUp de la biblioteca setUp para inicializar las clases dentro de nuestro grupo de prueba:

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

Para realizar solicitudes de red a las API, prefiero usar la biblioteca de actualización, que genera la mayor parte del código necesario. Para probar correctamente la clase UniversityEndpoint , obligaremos a la biblioteca dio, que Retrofit usa para ejecutar llamadas API, a devolver el resultado deseado burlándose del comportamiento de la clase Dio a través de un adaptador de respuesta personalizado.

Simulacro de interceptor de red personalizado

La simulación es posible debido a que hemos creado la clase UniversityEndpoint a través de DI. (Si la clase UniversityEndpoint fuera a inicializar una clase Dio por sí misma, no habría forma de burlarnos del comportamiento de la clase).

Para simular el comportamiento de la clase Dio , necesitamos conocer los métodos Dio utilizados en la biblioteca Retrofit , pero no tenemos acceso directo a Dio . Por lo tanto, nos burlaremos de Dio usando un interceptor de respuesta de red personalizado:

 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); }

Ahora que hemos creado el interceptor para simular nuestras respuestas de red, podemos definir nuestros grupos de prueba y firmas de funciones de prueba.

En nuestro caso, solo tenemos una función para probar ( getUniversitiesByCountry ), por lo que crearemos solo un grupo de prueba. Probaremos la respuesta de nuestra función a tres situaciones:

  1. ¿La función de la clase Dio es realmente llamada por getUniversitiesByCountry ?
  2. Si nuestra solicitud de API devuelve un error, ¿qué sucede?
  3. Si nuestra solicitud de API devuelve el resultado esperado, ¿qué sucede?

Aquí está nuestro grupo de prueba y firmas de función de prueba:

 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 {}); });

Estamos listos para escribir nuestras pruebas. Para cada caso de prueba, crearemos una instancia de DioMockResponsesAdapter con la configuración correspondiente:

 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()); }); });

Ahora que nuestra prueba de punto final está completa, probemos nuestra clase de origen de datos, UniversityRemoteDataSource . Anteriormente, observamos que la clase UniversityEndpoint es parte del constructor UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , lo que indica que UniversityRemoteDataSource usa la clase UniversityEndpoint para cumplir con su alcance, por lo que esta es la clase de la que nos burlaremos.

Burlándose Con Mockito

En nuestro ejemplo anterior, nos burlamos manualmente del adaptador de solicitud de nuestro cliente Dio usando un NetworkInterceptor personalizado. Aquí nos estamos burlando de toda una clase. Hacerlo manualmente (burlarse de una clase y sus funciones) llevaría mucho tiempo. Afortunadamente, las bibliotecas simuladas están diseñadas para manejar tales situaciones y pueden generar clases simuladas con un esfuerzo mínimo. Usemos la biblioteca de mockito, la biblioteca estándar de la industria para simulacros en Flutter.

Para simular a través de Mockito , primero agregamos la anotación “ @GenerateMocks([class_1,class_2,…]) ” antes del código de la prueba, justo encima de la función void main() {} . En la anotación, incluiremos una lista de nombres de clase como parámetro (en lugar de class_1,class_2… ).

A continuación, ejecutamos el comando flutter pub run build_runner build de Flutter que genera el código para nuestras clases simuladas en el mismo directorio que la prueba. El nombre del archivo simulado resultante será una combinación del nombre del archivo de prueba más .mocks.dart , reemplazando el sufijo .dart de la prueba. El contenido del archivo incluirá clases simuladas cuyos nombres comienzan con el prefijo Mock . Por ejemplo, UniversityEndpoint se convierte en MockUniversityEndpoint .

Ahora, importamos university_remote_data_source_test.dart.mocks.dart (nuestro archivo simulado) a university_remote_data_source_test.dart (el archivo de prueba).

Luego, en la función de setUp , simularemos UniversityEndpoint usando MockUniversityEndpoint e inicializando la clase 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); }); }

Nos burlamos con éxito de UniversityEndpoint y luego inicializamos nuestra clase UniversityRemoteDataSource . Ahora estamos listos para definir nuestros grupos de prueba y firmas de funciones de prueba:

 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 esto, nuestras firmas de simulación, grupos de prueba y función de prueba están configuradas. Estamos listos para escribir las pruebas reales.

Nuestra primera prueba comprueba si se llama a la función UniversityEndpoint cuando la fuente de datos inicia la obtención de información del país. Comenzamos definiendo cómo reaccionará cada clase cuando se llame a sus funciones. Como nos burlamos de la clase UniversityEndpoint , esa es la clase con la que trabajaremos, usando la estructura de código when( function_that_will_be_called ).then( what_will_be_returned ) .

Las funciones que estamos probando son asíncronas (funciones que devuelven un objeto Future ), por lo que usaremos la estructura de código when(function name).thenanswer( (_) {modified function result} ) para modificar nuestros resultados.

Para verificar si la función getUniversitiesByCountry llama a la función getUniversitiesByCountry dentro de la clase UniversityEndpoint , usaremos when(...).thenAnswer( (_) {...} ) para simular la función getUniversitiesByCountry dentro de la clase UniversityEndpoint :

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

Ahora que nos hemos burlado de nuestra respuesta, llamamos a la función de fuente de datos y verificamos, utilizando la función de verify , si se llamó a la función UniversityEndpoint :

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

Podemos usar los mismos principios para escribir pruebas adicionales que verifiquen si nuestra función transforma correctamente los resultados de nuestro punto final en los flujos de datos relevantes:

 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) ]), ); }); }); }

Hemos ejecutado una serie de pruebas unitarias de Flutter y demostrado diferentes enfoques para la simulación. Lo invito a continuar usando mi proyecto Flutter de muestra para ejecutar pruebas adicionales.

Pruebas unitarias de Flutter: su clave para una experiencia de usuario superior

Si ya incorporaste pruebas unitarias en tus proyectos de Flutter, este artículo puede haber introducido algunas opciones nuevas que podrías inyectar en tu flujo de trabajo. En este tutorial, demostramos lo sencillo que sería incorporar pruebas unitarias en su próximo proyecto de Flutter y cómo abordar los desafíos de escenarios de prueba más matizados. Es posible que nunca quieras volver a saltarte las pruebas unitarias en Flutter.

El equipo editorial del blog de ingeniería de Toptal agradece a Matija Becirevic y Paul Hoskins por revisar los ejemplos de código y otro contenido técnico presentado en este artículo.