Teste de unidade no Flutter: do básico do fluxo de trabalho a cenários complexos

Publicados: 2022-09-21

O interesse em Flutter está em alta - e está muito atrasado. O SDK de código aberto do Google é compatível com Android, iOS, macOS, Web, Windows e Linux. Uma única base de código Flutter suporta todos eles. E o teste de unidade é fundamental para fornecer um aplicativo Flutter consistente e confiável, garantindo contra erros, falhas e defeitos, melhorando preventivamente a qualidade do código antes que ele seja montado.

Neste tutorial, compartilhamos otimizações de fluxo de trabalho para testes de unidade Flutter, demonstramos um teste básico de unidade Flutter e, em seguida, passamos para bibliotecas e casos de teste Flutter mais complexos.

O fluxo de testes unitários no Flutter

Implementamos testes de unidade no Flutter da mesma maneira que fazemos em outras pilhas de tecnologia:

  1. Avalie o código.
  2. Configure a simulação de dados.
  3. Defina o(s) grupo(s) de teste.
  4. Defina a(s) assinatura(s) da função de teste para cada grupo de teste.
  5. Escreva os testes.

Para demonstrar o teste de unidade, preparei um projeto de amostra do Flutter e encorajo você a usar e testar o código quando quiser. O projeto usa uma API externa para buscar e exibir uma lista de universidades que podemos filtrar por país.

Algumas notas sobre como o Flutter funciona: O framework facilita o teste carregando automaticamente a biblioteca flutter_test quando um projeto é criado. A biblioteca permite que o Flutter leia, execute e analise testes de unidade. O Flutter também cria automaticamente a pasta de test na qual armazenar testes. É fundamental evitar renomear e/ou mover a pasta de test , pois isso quebra sua funcionalidade e, portanto, nossa capacidade de executar testes. Também é essencial incluir _test.dart em nossos nomes de arquivos de teste, pois esse sufixo é como o Flutter reconhece os arquivos de teste.

Estrutura de diretórios de teste

Para promover testes unitários em nosso projeto, implementamos o MVVM com arquitetura limpa e injeção de dependência (DI), conforme evidenciado nos nomes escolhidos para as subpastas do código-fonte. A combinação dos princípios MVVM e DI garante uma separação de preocupações:

  1. Cada classe de projeto suporta um único objetivo.
  2. Cada função dentro de uma classe cumpre apenas seu próprio escopo.

Criaremos um espaço de armazenamento organizado para os arquivos de teste que escreveremos, um sistema onde grupos de testes terão “casas” facilmente identificáveis. À luz do requisito do Flutter para localizar testes dentro da pasta de test , vamos espelhar a estrutura de pastas do nosso código-fonte em test . Então, quando escrevermos um teste, vamos armazená-lo na subpasta apropriada: Assim como as meias limpas vão para a gaveta de meias de sua cômoda e as camisas dobradas vão para a gaveta de camisas, os testes de unidade das classes Model vão para uma pasta chamada model , por exemplo.

Estrutura de pastas de arquivos com duas pastas de primeiro nível: lib e test. Aninhado abaixo de lib temos a pasta features, mais aninhado está o university_feed e ainda mais aninhado está o data. A pasta de dados contém as pastas de repositório e de origem. Aninhada abaixo da pasta de origem está a pasta de rede. Aninhados abaixo da rede estão as pastas de endpoint e modelo, além do arquivo university_remote_data_source.dart. Na pasta do modelo está o arquivo api_university_model.dart. No mesmo nível da pasta university_feed mencionada anteriormente, estão as pastas de domínio e apresentação. Aninhada abaixo do domínio está a pasta de casos de uso. Aninhados abaixo da apresentação estão os modelos e as pastas de tela. A estrutura da pasta de teste mencionada anteriormente imita a de lib. Aninhada abaixo da pasta test está a pasta unit_test que contém a pasta university_feed. Sua estrutura de pastas é a mesma da pasta university_feed acima, com seus arquivos dart tendo "_test" anexado aos seus nomes.
A estrutura de pastas de teste do projeto espelhando a estrutura do código-fonte

Adotar esse sistema de arquivos cria transparência no projeto e oferece à equipe uma maneira fácil de visualizar quais partes do nosso código têm testes associados.

Agora estamos prontos para colocar o teste de unidade em ação.

Um teste de unidade de vibração simples

Começaremos com as classes de model (na camada de data do código-fonte) e limitaremos nosso exemplo para incluir apenas uma classe de model , ApiUniversityModel . Esta classe possui duas funções:

  • Inicialize nosso modelo simulando o objeto JSON com um Map .
  • Construir o modelo de dados da University .

Para testar cada uma das funções do modelo, personalizaremos as etapas universais descritas anteriormente:

  1. Avalie o código.
  2. Configurar simulação de dados: definiremos a resposta do servidor à nossa chamada de API.
  3. Defina os grupos de teste: Teremos dois grupos de teste, um para cada função.
  4. Defina assinaturas de função de teste para cada grupo de teste.
  5. Escreva os testes.

Depois de avaliar nosso código, estamos prontos para cumprir nosso segundo objetivo: configurar a simulação de dados específica para as duas funções dentro da classe ApiUniversityModel .

Para simular a primeira função (inicializando nosso modelo simulando o JSON com um Map ), fromJson , criaremos dois objetos Map para simular os dados de entrada para a função. Também criaremos dois objetos ApiUniversityModel equivalentes para representar o resultado esperado da função com a entrada fornecida.

Para simular a segunda função (construindo o modelo de dados University ), toDomain , criaremos dois objetos University , que são o resultado esperado após ter executado esta função nos objetos ApiUniversityModel instanciados anteriormente:

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

Em seguida, para nosso terceiro e quarto objetivos, adicionaremos linguagem descritiva para definir nossos grupos de teste e assinaturas de função de teste:

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

Definimos as assinaturas de dois testes para verificar a função fromJson e dois para verificar a função toDomain .

Para cumprir nosso quinto objetivo e escrever os testes, vamos usar o método expect da biblioteca flutter_test para comparar os resultados das funções com nossas 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); }); }); }

Tendo alcançado nossos cinco objetivos, agora podemos executar os testes, seja do IDE ou da linha de comando.

Captura de tela indicando que cinco dos cinco testes foram aprovados. O cabeçalho lê: Executar: testes em api_university_model_test.dart. O painel esquerdo da tela lê: Resultados do teste --- carregando api_university_model_test.dart---api_university_model_test.dart---Test ApiUniversityModel initialization from json---Test using json one---Test using json two---Tests ApiUniversityModel toDomain ---Test toDomain usando json one---Test toDomain usando json two. O painel direito da tela diz: Testes aprovados: cinco de cinco testes --- flutter test test/unit_test/universities_feed/data/source/network/model/api_university_model_test.dart

Em um terminal, podemos executar todos os testes contidos na pasta test digitando o comando flutter test e ver se nossos testes são aprovados.

Alternativamente, poderíamos executar um único teste ou grupo de teste digitando o comando flutter test --plain-name "ReplaceWithName" , substituindo o nome do nosso teste ou grupo de teste por ReplaceWithName .

Teste de unidade de um endpoint no Flutter

Tendo concluído um teste simples sem dependências, vamos explorar um exemplo mais interessante: Vamos testar a classe endpoint , cujo escopo engloba:

  • Executando uma chamada de API para o servidor.
  • Transformando a resposta JSON da API em um formato diferente.

Depois de avaliar nosso código, usaremos o método setUp da biblioteca flutter_test para inicializar as classes dentro do nosso grupo de teste:

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

Para fazer solicitações de rede para APIs, prefiro usar a biblioteca de retrofit, que gera a maior parte do código necessário. Para testar adequadamente a classe UniversityEndpoint , forçaremos a biblioteca dio — que o Retrofit usa para executar chamadas de API — a retornar o resultado desejado simulando o comportamento da classe Dio por meio de um adaptador de resposta personalizado.

Simulação de interceptador de rede personalizado

A simulação é possível devido ao fato de termos construído a classe UniversityEndpoint por meio de DI. (Se a classe UniversityEndpoint inicializasse uma classe Dio sozinha, não haveria como zombar do comportamento da classe.)

Para zombar do comportamento da classe Dio , precisamos conhecer os métodos Dio usados ​​na biblioteca Retrofit — mas não temos acesso direto a Dio . Portanto, vamos zombar do Dio usando um interceptor de resposta de rede 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); }

Agora que criamos o interceptor para simular nossas respostas de rede, podemos definir nossos grupos de teste e assinaturas de função de teste.

No nosso caso, temos apenas uma função para testar ( getUniversitiesByCountry ), então vamos criar apenas um grupo de teste. Vamos testar a resposta da nossa função para três situações:

  1. A função da classe Dio é realmente chamada por getUniversitiesByCountry ?
  2. Se nossa solicitação de API retornar um erro, o que acontece?
  3. Se nossa solicitação de API retornar o resultado esperado, o que acontece?

Aqui está nosso grupo de teste e assinaturas de função de teste:

 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 prontos para escrever nossos testes. Para cada caso de teste, criaremos uma instância de DioMockResponsesAdapter com a configuração correspondente:

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

Agora que nosso teste de endpoint foi concluído, vamos testar nossa classe de fonte de dados, UniversityRemoteDataSource . Anteriormente, observamos que a classe UniversityEndpoint faz parte do construtor UniversityRemoteDataSource({UniversityEndpoint? universityEndpoint}) , que indica que UniversityRemoteDataSource usa a classe UniversityEndpoint para cumprir seu escopo, portanto, essa é a classe que simularemos.

Zombando com Mockito

Em nosso exemplo anterior, zombamos manualmente do adaptador de solicitação do nosso cliente Dio usando um NetworkInterceptor personalizado. Aqui estamos zombando de uma classe inteira. Fazer isso manualmente — zombar de uma classe e suas funções — consumiria muito tempo. Felizmente, bibliotecas simuladas são projetadas para lidar com tais situações e podem gerar classes simuladas com o mínimo de esforço. Vamos usar a biblioteca mockito, a biblioteca padrão da indústria para simulação no Flutter.

Para simular o Mockito , primeiro adicionamos a anotação “ @GenerateMocks([class_1,class_2,…]) ” antes do código do teste—logo acima da função void main() {} . Na anotação, incluiremos uma lista de nomes de classes como parâmetro (no lugar de class_1,class_2… ).

Em seguida, executamos o comando flutter pub run build_runner build do Flutter que gera o código para nossas classes simuladas no mesmo diretório que o teste. O nome do arquivo simulado resultante será uma combinação do nome do arquivo de teste mais .mocks.dart , substituindo o sufixo .dart do teste. O conteúdo do arquivo incluirá classes simuladas cujos nomes começam com o prefixo Mock . Por exemplo, UniversityEndpoint se torna MockUniversityEndpoint .

Agora, importamos university_remote_data_source_test.dart.mocks.dart (nosso arquivo simulado) para university_remote_data_source_test.dart (o arquivo de teste).

Em seguida, na função setUp , zombaremos de UniversityEndpoint usando MockUniversityEndpoint e inicializando a 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); }); }

Nós zombamos com sucesso de UniversityEndpoint e, em seguida, inicializamos nossa classe UniversityRemoteDataSource . Agora estamos prontos para definir nossos grupos de teste e assinaturas de função de teste:

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

Com isso, nossa simulação, grupos de teste e assinaturas de função de teste são configurados. Estamos prontos para escrever os testes reais.

Nosso primeiro teste verifica se a função UniversityEndpoint é chamada quando a fonte de dados inicia a busca de informações do país. Começamos definindo como cada classe reagirá quando suas funções forem chamadas. Como zombamos da classe UniversityEndpoint , essa é a classe com a qual trabalharemos, usando a estrutura de código when( function_that_will_be_called ).then( what_will_be_returned ) .

As funções que estamos testando são assíncronas (funções que retornam um objeto Future ), então usaremos a estrutura de código when(function name).thenanswer( (_) {modified function result} ) para modificar nossos resultados.

Para verificar se a função getUniversitiesByCountry chama a função getUniversitiesByCountry dentro da classe UniversityEndpoint , usaremos when(...).thenAnswer( (_) {...} ) para simular a função getUniversitiesByCountry dentro da classe UniversityEndpoint :

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

Agora que zombamos de nossa resposta, chamamos a função de fonte de dados e verificamos - usando a função de verify - se a função UniversityEndpoint foi chamada:

 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 os mesmos princípios para escrever testes adicionais que verificam se nossa função transforma corretamente nossos resultados de endpoint nos fluxos de dados 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) ]), ); }); }); }

Executamos vários testes de unidade do Flutter e demonstramos diferentes abordagens para simulação. Convido você a continuar usando meu projeto de amostra do Flutter para executar testes adicionais.

Testes unitários do Flutter: sua chave para um UX superior

Se você já incorpora testes de unidade em seus projetos Flutter, este artigo pode ter introduzido algumas novas opções que você pode injetar em seu fluxo de trabalho. Neste tutorial, demonstramos como seria simples incorporar testes de unidade em seu próximo projeto Flutter e como enfrentar os desafios de cenários de teste mais sutis. Talvez você nunca mais queira pular os testes de unidade no Flutter.

A equipe editorial do Toptal Engineering Blog agradece a Matija Becirevic e Paul Hoskins por revisar os exemplos de código e outros conteúdos técnicos apresentados neste artigo.