article

Desenvolvimento Ágil e Sustentável

9 min read

Como continuar a crescer mantendo a qualidade do nosso app?

Com o aumento de clientes e usuários ativos no app Robbin, o questionamento acima veio à tona, principalmente no desenvolvimento do app em Flutter. Parte da resposta está em um código bem escrito, estruturado e planejado, um código que, naturalmente, tem aspectos de qualidade. No entanto, é ingenuidade acreditar que apenas a habilidade dos engenheiros é o suficiente para evitar todo e qualquer bug no sistema, especialmente em uma base de código em constante evolução, como a da Robbin. É nesse contexto que os testes se tornam uma resposta complementar essencial. Em engenharia de software, os testes são nossos aliados indispensáveis e, com um bom plano de testes, conseguimos garantir que, se o teste for bem sucedido, o código também deveria se comportar conforme esperado.

Vamos assumir o exemplo abaixo: Criamos um código bem simplificado que valida se uma string qualquer tem um tamanho maior ou igual a cinco caracteres.

// some_random_validation.dart
bool is_valid(String value) {
  if (value.length >= 5) {
    return true;
  } else {
    return false;
  }
}

// test.dart
void main() {
  test('test description', () {
    expect(is_valid('12345'), true);
    expect(is_valid('1234'), false);
  });
}

Como podemos perceber, um teste simples e rápido pode “blindar” o código contra quebras causadas por mudanças realizadas por qualquer desenvolvedor. Esse exemplo se encaixa como um teste unitário, que garante que a unidade se comporta conforme o esperado. Esse tipo de implementação é muito útil e rápida, tanto para escrever quanto para executar, e, como já dito, é suficiente para mitigar muitos problemas, sendo capaz de assegurar minimamente um comportamento esperado para uma camada específica.

Trazendo para o mundo real

Apesar do exemplo ser bem simplificado, podemos aplicar essa mesma estratégia em cenários reais da engenharia de software, como em apps com arquiteturas similares às definidas pela própria equipe do Flutter:

Descrição da imagem

Nessa arquitetura exemplo, temos, em duas camadas (UI e Data Layer), as unidades View, ViewModel, Repository e Service. Podemos implementar testes unitários de cada uma dessas camadas, garantindo a funcionalidade de cada uma, de forma isolada.

Mas e quanto às interações entre essas camadas?

Descrição da imagem

Ao analisarmos o modelo acima, percebemos que cada uma dessas camadas interagem entre si, portanto, como garantimos que todas essas interações funcionam corretamente, se os testes unitários validam apenas unidades isoladas?

É aqui que entram os testes integrados!

Nos testes unitários, em teoria, definimos mocks das camadas subsequentes às que estamos validando, nos preocupando apenas com uma unidade alvo, por exemplo, no teste da ViewModel, criamos um mock do Repository, simulamos suas respostas e, em seguida, avaliamos se a ViewModel se comporta conforme esperado para o cenário especificado.

Descrição da imagem

Agora, imagine que, em vez de “mockar” a camada do Repository, utilizamos sua implementação real, testamos, tanto a ViewModel, quanto o Repository. Assim, passamos a testar, não somente a ViewModel, como também o Repository, tendo o adicional de validação das interações entre essas camadas. Expandindo essa lógica, podemos aplicar a mesma idéia a qualquer camada, inclusive em um teste na View, utilizando as implementações reais da ViewModel, do Repository e do Service. “Mockaríamos” apenas elementos externos, como uma chamada HTTP, obtendo algo similar ao modelo abaixo:

Descrição da imagem

Pronto, temos um teste integrado!

Dessa forma, ao simularmos uma ação na View durante o teste, essa ação será propagada por todas as camadas e unidades do código, validando não apenas o funcionamento individual de cada parte, mas também a comunicação e interação entre elas.

Vejamos um exemplo de teste integrado retirado do app de exemplo da equipe do flutter:

testWidgets('Open a booking', (tester) async {
      // Aqui carregamos o MainApp widget, entidade alvo do teste em questão
      await tester.pumpWidget(
        MultiProvider(
          providers: providersRemote,
          child: const MainApp(),
        ),
      );

      await tester.pumpAndSettle();

      // Caímos na tela de Login pois o usuário está deslogado
      expect(find.byType(LoginScreen), findsOneWidget);

      // Realiza o login
      await tester.tap(find.text('Login'));
      await tester.pumpAndSettle();

      // Valida se após o login, o usuário foi redirecionado para a Home
      expect(find.byType(HomeScreen), findsOneWidget);
      await tester.pumpAndSettle();

      // Faz a verificação do nome do usuário
      expect(find.text('Sofie\'s Trips'), findsOneWidget);

      // Performa um clique no botão de reserva
      await tester.tap(find.text('Alaska, North America'));
      await tester.pumpAndSettle();

      // Deve ter sido redirecionado para a tela de reserva
      expect(find.byType(BookingScreen), findsOneWidget);
      expect(find.text('Alaska'), findsOneWidget);

      // Testa navegação para home
      await tester.tap(find.byType(HomeButton).first);
      await tester.pumpAndSettle();

      // Valida redirecionamento para Home
      expect(find.byType(HomeScreen), findsOneWidget);

      // Faz o logout
      await tester.tap(find.byType(LogoutButton).first);
      await tester.pumpAndSettle();
      expect(find.byType(LoginScreen), findsOneWidget);
    });

E o mock na ponta final? Como garantimos que os serviços que consumimos se comportem como o aplicativo espera?

Via contrato!

Contratos de comunicação entre serviços, em contexto de tecnologia da informação, referem-se a acordos que definem como diferentes componentes de um sistema, ou diferentes sistemas entre si, interagem e trocam informação.

Damos grande importância aos contratos em cada caso de uso do aplicativo, pois eles são o núcleo, tanto da funcionalidade em si, como também da agilidade no desenvolvimento. Sem eles, não apenas comprometemos o produto e a eficácia dos testes integrados, mas também impactamos significativamente a velocidade de nossas entregas.

Por esse motivo, em todas as novas entregas, definimos com precisão as informações essenciais referentes aos contratos, incluindo endpoints, métodos HTTP, corpo da requisição (body), parâmetros de consulta (query parameters), respostas (responses), entre outros. Esse tipo de artefato não apenas orienta nossos testes integrados, como também é fundamental para sua execução.

Sempre precisamos simular o domínio externo?

Nem sempre.

Aqui entramos no território dos testes mais complexos, e mais completos, dessa coleção: os testes End-to-End (E2E), ou testes de ponta a ponta, o topo da pirâmide de testes, que veremos logo mais.

Nesse nível, replicamos ao máximo a experiência real do usuário, o ambiente de teste geralmente roda em um dispositivo real e consome todos os serviços de forma autêntica, sem mocks, garantindo uma validação mais fiel do funcionamento do sistema como um todo, trazendo todas as garantias e seguranças dos testes integrados, com o adicional do teste de comunicação e compatibilidade entre o aplicativo, e o back-end que em nosso contexto é o BFF (Back-end For Front-end).

Pirâmide de testes

Os três testes citados anteriormente, compõem o que chamamos de pirâmide de testes.

Descrição da imagem

Como podemos observar, quanto mais alto o nível do teste na pirâmide, maior a integração obtida, no entanto, isso também implica em testes mais lentos. Em teoria, manter a estrutura da pirâmide é essencial para manter um conjunto de testes eficientes, rápidos e de fácil manutenção. Isso significa priorizar uma grande quantidade de testes unitários pequenos e ágeis, alguns testes de integração mais granulares e poucos testes de alto nível que validem o aplicativo de ponta a ponta.

Na Robbin, decidimos não adotar os testes E2E (End-to-End), pois, ao avaliar a relação entre seus benefícios e desafios, percebemos que os esforços exigidos superam as vantagens. Embora existam excelentes ferramentas que viabilizam esses testes, como Maestro, Appium e Robot, identificamos desafios significativos, como a complexidade de manutenção dos cenários ao longo do tempo e, principalmente, a dificuldade em garantir um ambiente de teste estável. Sendo assim, o custo de manutenção e escrita de cenários E2E não justificava o retorno obtido, ao menos não em primeira instância. Por fim, tivemos as mesmas dificuldades, seguidas da mesma tomada de decisão da Nubank, que também escreveu sobre os testes E2E em seus produtos no artigo Why We Killed Our End-to-End Test Suite.

Na prática, como ficou a solução final?

Levando em consideração toda a teoria, a prática, e a experiência que tivemos com essa coleção de testes, a pirâmide do aplicativo Robbin, hoje, se encontra assim:

Descrição da imagem

Como dissemos anteriormente, os testes E2E não fizeram sentido para o nosso contexto, logo, o teste de maior complexidade em nossa pirâmide é o integrado.

Em nossa solução, implementamos uma estrutura base, uma abstração que facilita a escrita dos cenários com o mock de nossos serviços. Com o uso dessa abstração, vimos uma facilidade enorme em garantir a eficácia e funcionalidade de nossos produtos em um curto período de tempo, promovendo uma grande confiança em nosso trabalho.

Sendo assim, com essa abstração implementada, nossa solução final contempla a escrita de um grande número de testes unitários, que cobrem todo tipo de cenário, incluindo, principalmente, os cenários de exceção, sendo complementados por testes integrados, que se espalham, exclusivamente, ao longo dos fluxos felizes de nossas jornadas mais críticas, como Login, Onboarding, DDA, Parcelamento de Boletos, Cartões, entre outros. Dessa forma, ao longo do nosso desenvolvimento, nossos engenheiros passam a ter muito mais segurança em sua codificação, se atendo aos planos de testes, que são uma proteção contra bugs em novas versões.

É válido ressaltar também, que obedecemos a premissa do volume de testes em cada camada da pirâmide de testes, o que nos permitiu obter um ciclo de vida ágil e saudável em nossas integrações contínuas no app, já que, por termos um número menor de testes integrados, que são mais lentos por natureza, asseguramos as funcionalidades sem onerar o tempo de pipeline em uma PR (Pull Request), por exemplo, nos mantendo ágeis e ainda mais seguros acerca de nossas entregas contínuas.

Em conclusão, fomos capazes de chegar a uma resposta satisfatória para a pergunta que originou este artigo, passamos a criar produtos mais seguros, confiáveis, manuteníveis, preparados para qualquer tipo de evolução necessária, um software que oferece uma base cada vez mais sólida e com qualidade para nossos produtos e confiança aos nossos engenheiros, viabilizando, através dos testes, um crescimento exponencial com responsabilidade e com qualidade à Robbin.