Módulo 15 — Exercícios: Testes, Qualidade e Publicação

Você chegou ao último conjunto de exercícios da disciplina. Ao longo de catorze módulos, você construiu cada camada de um aplicativo de delivery real — da interface até o backend na nuvem. Neste módulo final, você aprende a fechar o ciclo do desenvolvimento profissional: verificar que o código funciona de forma reproduzível e automatizada, e preparar o aplicativo para ser entregue ao usuário. Os três exercícios a seguir percorrem essa jornada de forma progressiva. O primeiro coloca em prática os testes unitários, exigindo que você escreva testes para a camada de domínio do aplicativo — a parte mais importante e mais estável do sistema. O segundo avança para os testes de widget, onde você verifica o comportamento da interface de forma programática, integrando o Provider em um ambiente de testes controlado. O terceiro fecha o ciclo com o pipeline completo de qualidade: análise estática configurada, cobertura mínima enforçada e um workflow de integração contínua com GitHub Actions que executa tudo automaticamente a cada push, culminando na preparação do APK de release assinado. Leia cada enunciado com atenção antes de escrever qualquer linha de código — as decisões que você tomar aqui refletem o que se espera de um desenvolvedor que entrega produtos, não apenas protótipos.


Exercício 1

PedidoNotifier e TokenRepositorio: testes unitários da camada de domínio

O código que não pode ser testado é o código que não pode ser confiado

O coração de um aplicativo construído com arquitetura hexagonal e DDD é a camada de domínio. É lá que vivem as regras de negócio, as entidades, os casos de uso e os contratos de repositório — o código que descreve o que o sistema faz, independentemente de como ele se conecta ao mundo externo. Essa separação não é apenas uma decisão estética: ela é o que torna o domínio testável de forma rápida, barata e completamente isolada. Como as entidades e os notifiers de domínio são escritos em Dart puro, sem dependências de Flutter, Firebase ou HTTP, eles podem ser verificados pelo pacote test sem nenhuma inicialização de framework — o que significa que uma suíte com cem testes de domínio roda em poucos segundos.

Neste exercício, você vai escrever testes unitários para dois componentes do aplicativo de delivery: o PedidoNotifier, que gerencia o estado de um pedido em andamento e contém a lógica de negócio mais sensível do aplicativo, e o TokenRepositorio, que é responsável por toda a persistência dos tokens de autenticação. Esses dois componentes foram desenvolvidos em módulos anteriores; agora é hora de verificar sistematicamente que eles se comportam como esperado em todos os cenários relevantes — incluindo os cenários de erro, que são geralmente os mais esquecidos nos testes manuais.

O arquivo de testes do PedidoNotifier deve ser criado em test/features/pedidos/domain/pedido_notifier_test.dart e deve utilizar o pacote test com a função group para organizar os casos de teste em subgrupos nomeados por comportamento. Você vai precisar do pacote mockito com geração de código via build_runner para criar os mocks das dependências: o PedidoRepositorio (a interface abstrata do repositório de pedidos) e o CarrinhoService (o serviço que fornece os itens e o total do carrinho). Anote o arquivo com @GenerateMocks([PedidoRepositorio, CarrinhoService]) e, após escrevê-lo, gere os mocks com dart run build_runner build --delete-conflicting-outputs.

O PedidoNotifier deve ser testado nos seguintes grupos de comportamento, cada um com seus casos de teste específicos. O primeiro grupo, intitulado 'estado inicial', deve verificar que um notifier recém-criado possui os campos pedidoAtual como nulo, isCarregando como falso, e mensagemErro como nula. Esses três casos de teste são triviais de escrever, mas documentam explicitamente o contrato de inicialização da classe — qualquer desenvolvedor que leia esses testes sabe o que esperar ao instanciar o notifier.

O segundo grupo, intitulado 'finalizarPedido', deve conter os casos de teste mais importantes. O caso de sucesso deve usar o método when do mockito para configurar o MockPedidoRepositorio para retornar um Pedido com os campos preenchidos quando criar for chamado com qualquer argumento, e o MockCarrinhoService para retornar uma lista de itens e um total específicos. Em seguida, chame await notifier.finalizarPedido() e verifique que: isCarregando voltou para false após a operação; pedidoAtual não é nulo; pedidoAtual.status é igual a StatusPedido.aguardando; e mensagemErro permanece nula. Utilize verify(mockPedidoRepositorio.criar(any)).called(1) para garantir que o repositório foi chamado exatamente uma vez — essa verificação é importante porque garante que a lógica de negócio não está chamando o repositório múltiplas vezes desnecessariamente, o que poderia gerar pedidos duplicados.

O caso de falha de rede deve configurar o mock para lançar uma Exception('Erro de conexão') quando criar for chamado. Após chamar finalizarPedido(), verifique que isCarregando é falso, pedidoAtual permanece nulo, e mensagemErro é não nula e contém a substring 'Erro de conexão'. Esse caso de teste documenta que o notifier não deixa o estado de carregamento ativo em caso de falha — um bug frequente que resulta em um spinner infinito na tela do usuário.

O terceiro grupo, intitulado 'cancelarPedido', deve verificar o caso de sucesso do cancelamento: configure o mock para retornar normalmente quando cancelar for chamado com o ID do pedido atual, chame await notifier.cancelarPedido(), e verifique que pedidoAtual se torna nulo após o cancelamento bem-sucedido. Adicione também o caso em que pedidoAtual já é nulo quando cancelarPedido é chamado: verifique que o método completa sem lançar exceção usando o matcher returnsNormally, e que o repositório nunca é chamado usando verifyNever(mockPedidoRepositorio.cancelar(any)).

O arquivo de testes do TokenRepositorio deve ser criado em test/features/autenticacao/infrastructure/token_repositorio_test.dart. Como o TokenRepositorio depende do FlutterSecureStorage, que é uma dependência de plataforma não disponível em testes unitários puros, você deve criar uma implementação fake em vez de usar o mockito. Declare uma classe interna ao arquivo de teste chamada FlutterSecureStorageFake que implementa o contrato do FlutterSecureStorage usando um simples Map<String, String> interno como armazenamento. Nos métodos relevantes, write deve adicionar a chave e o valor ao mapa, read deve retornar o valor correspondente ou nulo, e deleteAll deve limpar o mapa completamente.

Com essa implementação fake, escreva os seguintes casos de teste para o TokenRepositorio. O caso 'salvarSessao e lerAccessToken devem persistir e recuperar o token' deve salvar uma sessão com dados conhecidos e verificar que lerAccessToken retorna exatamente o token salvo. O caso 'accessTokenEstaValido deve retornar true para token com 10 minutos de validade' deve salvar uma sessão com expiração definida como DateTime.now().add(const Duration(minutes: 10)) e verificar que o método retorna true. O caso 'accessTokenEstaValido deve retornar false para token expirado' deve salvar uma sessão com expiração no passado e verificar que o método retorna false. O caso 'accessTokenEstaValido deve retornar false quando não há expiração salva' deve verificar o comportamento quando o repositório está vazio. O caso 'temSessaoAtiva deve retornar false após limparTodosOsTokens' deve salvar uma sessão, chamar limparTodosOsTokens, e verificar que temSessaoAtiva retorna false. O caso 'salvarSessao deve persistir todos os quatro campos' deve salvar uma sessão e verificar que lerAccessToken, lerRefreshToken e temSessaoAtiva refletem os dados salvos de forma coerente.

Execute os testes com flutter test test/features/pedidos/domain/pedido_notifier_test.dart e flutter test test/features/autenticacao/infrastructure/token_repositorio_test.dart. Certifique-se de que todos os testes passam antes de entregar. Implemente o código do exercício em um único arquivo g_ex1.dart que contenha os dois arquivos de teste concatenados, precedidos de comentários de seção que indiquem claramente a qual arquivo pertence cada bloco.

Reflita sobre as seguintes questões antes de implementar. Por que usar uma implementação fake do FlutterSecureStorage em vez de um mock gerado pelo mockito? Em que situações um fake é preferível a um mock? Por que o padrão AAA — Arrange, Act, Assert — torna os testes mais legíveis mesmo quando a quantidade de código é a mesma? O que aconteceria se você usasse verifyNever em vez de verify(mock).called(1) no caso de sucesso de finalizarPedido — esse teste ainda seria capaz de detectar um bug de chamada dupla ao repositório?

Ao configurar o mock com when(mock.criar(any)).thenAnswer(...), observe que any é um matcher do mockito que aceita qualquer argumento. Se você substituir any por um argumento literal específico, o mock só retornará o valor configurado quando a chamada coincidir exatamente com esse argumento — o que pode tornar os testes frágeis a mudanças no objeto de entrada. Use any quando quiser testar o comportamento independentemente dos dados passados, e use argumentos literais quando o teste precisar verificar exatamente quais dados foram passados para o colaborador.

O que deve ser entregue: um arquivo chamado g_ex1.dart, onde g é o nome do seu grupo.


Exercício 2

TelaCardapio e TelaConfirmacaoPedido: testes de widget com Provider

O widget que não tem teste pode mudar sem avisar

Testes unitários cobrem a lógica de domínio com precisão e velocidade. Mas eles não podem verificar se a interface do usuário reage corretamente às mudanças de estado — se um botão realmente aparece quando o carrinho tem itens, se um spinner é exibido durante o carregamento, se uma mensagem de erro aparece quando a operação falha, se o formulário de endereço só habilita o botão de confirmação quando todos os campos obrigatórios estão preenchidos. Essas verificações são responsabilidade dos testes de widget, que usam o WidgetTester do pacote flutter_test para renderizar widgets em um ambiente simulado e interagir com eles programaticamente.

A complexidade dos testes de widget aumenta quando os widgets testados dependem de Provider para acessar o estado da aplicação. A solução padrão é envolver o widget sendo testado em um ChangeNotifierProvider que injeta um notifier de teste — que pode ser o notifier real em estado controlado, ou uma implementação fake que permite simular estados específicos sem executar lógica de negócio. Esse padrão é o que você vai implementar neste exercício, testando dois widgets centrais do aplicativo de delivery.

O primeiro conjunto de testes deve verificar o widget TelaCardapio. Crie o arquivo test/features/cardapio/presentation/tela_cardapio_test.dart. O TelaCardapio depende de um CardapioNotifier que expõe a lista de produtos disponíveis, um indicador de carregamento e uma mensagem de erro. Para os testes, você vai precisar de um CardapioNotifierFake — uma classe que estende ChangeNotifier, implementa o contrato do CardapioNotifier e permite que você defina manualmente o estado antes de cada teste.

O CardapioNotifierFake deve ter campos públicos mutáveis: List<Produto> produtos, bool isCarregando e String? mensagemErro. Seu método carregarCardapio() deve apenas notificar os ouvintes sem fazer nenhuma chamada assíncrona real. Isso é intencional: você não quer que os testes de widget dependam de uma conexão HTTP real ou de um banco de dados local, pois isso os tornaria lentos e não determinísticos.

Para cada caso de teste, crie um helper Widget buildTelaCardapio(CardapioNotifierFake notifier) que retorna o widget envolvido em ChangeNotifierProvider<CardapioNotifier>.value(value: notifier, child: MaterialApp(home: TelaCardapio())). Esse helper evita repetição de código e torna os testes mais legíveis.

Escreva os seguintes casos de teste para o TelaCardapio. O caso 'deve exibir CircularProgressIndicator quando isCarregando é true' deve definir notifier.isCarregando = true no arrange, renderizar o widget com tester.pumpWidget, e verificar com expect(find.byType(CircularProgressIndicator), findsOneWidget). O caso 'deve exibir mensagem de erro quando mensagemErro não é nula' deve definir uma mensagem de erro e verificar que ela aparece na tela com find.textContaining. O caso 'deve exibir lista de produtos quando carregamento completa' deve configurar notifier.produtos com dois produtos conhecidos e verificar que os nomes de ambos os produtos aparecem na tela. O caso 'deve exibir estado vazio quando lista de produtos está vazia' deve configurar notifier.produtos como uma lista vazia e verificar que nenhum ListTile de produto aparece.

Para o teste de interação, escreva um caso 'deve chamar adicionarAoCarrinho ao tocar no botão de adicionar do produto'. Configure o notifier com um produto, renderize o widget, localize o botão de adicionar para esse produto usando find.byKey(Key('btn_adicionar_${produto.id}')), toque nele com await tester.tap(...), chame await tester.pump(), e verifique que o estado do CarrinhoNotifier (injetado via outro ChangeNotifierProvider) reflete o produto adicionado. Isso exige que você injete também um CarrinhoNotifierFake na árvore de providers do teste.

O segundo conjunto de testes deve verificar o widget TelaConfirmacaoPedido. Crie o arquivo test/features/pedidos/presentation/tela_confirmacao_pedido_test.dart. Esse widget exibe o resumo do pedido — itens do carrinho, total, campo de endereço de entrega — e um botão “Confirmar Pedido” que só deve estar habilitado quando o campo de endereço estiver preenchido.

Escreva os seguintes casos de teste. O caso 'deve exibir os itens do carrinho com nome e preço' deve configurar o CarrinhoNotifierFake com dois itens de preços conhecidos e verificar que os nomes e os valores aparecem na tela. O caso 'deve exibir o total correto do carrinho' deve configurar o total no notifier e verificar que o valor formatado aparece na tela. O caso 'botão de confirmar deve estar desabilitado quando endereço está vazio' deve renderizar o widget sem preencher o campo de endereço e verificar que o ElevatedButton com o texto “Confirmar Pedido” está desabilitado. Para verificar o estado de um botão, use tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'Confirmar Pedido')).onPressed == null. O caso 'botão de confirmar deve ser habilitado após preencher endereço' deve usar await tester.enterText(find.byType(TextFormField), 'Rua das Flores, 42'), depois await tester.pump(), e verificar que o botão não está mais desabilitado. O caso 'deve chamar finalizarPedido ao confirmar pedido com endereço preenchido' deve preencher o endereço, tocar no botão confirmar, e verificar que o método correspondente do PedidoNotifier foi chamado.

Para o teste de transição de estado durante o processamento do pedido, escreva o caso 'deve exibir CircularProgressIndicator durante o processamento'. Configure o PedidoNotifierFake para retornar um future que não completa imediatamente — você pode usar um Completer<void> para controlar quando a operação termina. Preencha o endereço, toque no botão confirmar, chame await tester.pump() uma vez sem deixar o future completar, e verifique que o CircularProgressIndicator aparece. Em seguida, complete o Completer, chame await tester.pumpAndSettle(), e verifique que o indicador de carregamento sumiu.

Entregue o código do exercício em um arquivo g_ex2.dart que contenha os dois arquivos de teste completos, precedidos por comentários de seção.

Reflita sobre as seguintes questões. Por que usar uma implementação fake do notifier em vez de renderizar o widget com o notifier real e um repositório mockado? Qual é a diferença entre chamar pump() e pumpAndSettle() após uma interação — e em que situação cada um deve ser usado? Por que verificar onPressed == null é a forma correta de verificar se um botão está desabilitado em Flutter, em vez de buscar a propriedade enabled?

Ao envolver o widget testado com MaterialApp, você garante que os contextos de Theme, Navigator e MediaQuery estão disponíveis. Widgets que acessam Theme.of(context) ou MediaQuery.of(context) diretamente lançam um erro de context nulo se não estiverem dentro de um MaterialApp durante os testes. Verifique sempre que o helper de construção do widget inclui o MaterialApp como ancestral. Caso o widget use go_router para navegação, considere usar GoRouter configurado com rotas de stub no ambiente de testes em vez de Navigator.of(context) diretamente.

O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.


Exercício 3

Pipeline de qualidade completo: lints, cobertura, CI e build de release

Qualidade sem automação é uma intenção. Qualidade com automação é uma garantia.

Um aplicativo pode ter testes unitários e de widget impecáveis, mas se esses testes só forem executados manualmente e ocasionalmente, sua eficácia é limitada. A qualidade que conta em um ambiente profissional é a qualidade que é verificada automaticamente, a cada mudança de código, por um sistema que não esquece, não fica com preguiça e não faz exceções por pressão de prazo. Essa automação é o que a integração contínua — CI, de Continuous Integration — proporciona.

Neste exercício, você vai configurar o pipeline completo de qualidade do aplicativo de delivery: começando pela análise estática com regras específicas para o projeto, passando pela medição e verificação de cobertura mínima de testes, construindo um workflow de GitHub Actions que executa tudo automaticamente a cada push, e concluindo com a configuração do pubspec.yaml e do android/app/build.gradle para gerar um APK de release assinado. Ao final, o repositório do projeto terá todas as verificações automatizadas que são esperadas de um projeto de software profissional.

A primeira parte do exercício consiste em configurar a análise estática com regras adicionais ao conjunto base do flutter_lints. No arquivo analysis_options.yaml da raiz do projeto, mantenha a linha include: package:flutter_lints/flutter.yaml e adicione, sob a chave linter: rules:, as seguintes regras com justificativa para cada uma. A regra prefer_const_constructors: true deve estar presente porque widgets const são reutilizados pelo Flutter em vez de recriados a cada rebuild, reduzindo a pressão sobre o garbage collector. A regra avoid_print: true deve estar presente porque chamadas a print() no código de produção podem vazar dados sensíveis em logs de dispositivo, e o projeto já usa um sistema de logging adequado. A regra use_super_parameters: true deve estar presente porque a sintaxe super.key é mais concisa e é a forma idiomática em Dart moderno. A regra prefer_single_quotes: true deve estar presente para manter consistência com a convenção da comunidade Dart. A regra avoid_unnecessary_containers: true deve estar presente porque Container sem configuração é mais pesado que Padding, ColoredBox ou SizedBox para tarefas simples.

Adicione também uma seção analyzer: errors: com as entradas invalid_annotation_target: ignore (para silenciar avisos do freezed e do json_serializable que às vezes conflitam com a análise padrão) e missing_required_param: error (para promover parâmetros obrigatórios ausentes de avisos para erros, evitando que o código que falta argumentos passe despercebido durante o desenvolvimento). Execute flutter analyze e corrija todos os problemas relatados antes de prosseguir para a próxima parte.

A segunda parte consiste em medir e verificar a cobertura de testes. Execute flutter test --coverage e examine o arquivo gerado em coverage/lcov.info. Se a ferramenta lcov estiver instalada no seu ambiente, gere o relatório HTML com genhtml coverage/lcov.info --output-directory coverage/html e abra coverage/html/index.html para visualizar interativamente quais linhas do código foram exercitadas pelos testes. Identifique pelo menos duas classes da camada de domínio que têm cobertura abaixo de 70% e escreva testes adicionais para cobri-las. Documente no arquivo de entrega quais classes foram identificadas, qual era a cobertura antes e depois dos testes adicionais, e quais comportamentos os novos testes verificam.

A terceira parte consiste em criar o workflow de integração contínua com GitHub Actions. Crie o arquivo .github/workflows/ci.yml com a seguinte configuração. O workflow deve ser disparado por push e pull request para as branches main e develop. Deve conter um único job chamado quality executando em ubuntu-latest. As etapas devem ser executadas na seguinte ordem: checkout do código com actions/checkout@v4; configuração do Flutter com subosito/flutter-action@v2 usando a versão 3.32.0 no canal stable; instalação de dependências com flutter pub get; geração de código com dart run build_runner build --delete-conflicting-outputs (necessário para gerar os mocks do mockito); análise estática com flutter analyze --fatal-infos (a flag --fatal-infos promove informações a erros, garantindo que o pipeline falhe se houver qualquer sugestão de melhoria, não apenas erros); execução dos testes com cobertura usando flutter test --coverage; verificação de cobertura mínima de 70% usando um script shell que lê o arquivo coverage/lcov.info, extrai o percentual de linhas cobertas e falha com código de saída não-zero se a cobertura estiver abaixo do limiar; e por fim, build de release com flutter build apk --release para verificar que o código compila corretamente em modo de produção.

Para a etapa de verificação de cobertura, use o seguinte script na etapa correspondente do YAML:

sudo apt-get install -y lcov
COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep "lines" | awk '{print $2}' | tr -d '%')
echo "Cobertura de linhas: ${COVERAGE}%"
if [ $(echo "$COVERAGE < 70" | bc -l) -eq 1 ]; then
  echo "Cobertura abaixo do limiar mínimo de 70%!"
  exit 1
fi

A quarta parte consiste em preparar o projeto para geração do APK de release assinado. No arquivo pubspec.yaml, verifique que a versão do aplicativo segue o formato semântico com código de build: version: 1.0.0+1, onde 1.0.0 é a versão exibida ao usuário e +1 é o código de build usado internamente pela Play Store. Incremente o código de build a cada publicação. Adicione as dependências de desenvolvimento necessárias para geração de ícones: flutter_launcher_icons: ^0.14.3. Configure a seção flutter_launcher_icons com android: true, ios: true, image_path: "assets/icon/icon.png", e o campo adaptive_icon_background com a cor primária do aplicativo de delivery.

No arquivo android/app/build.gradle, verifique e, se necessário, ajuste as seguintes configurações no bloco defaultConfig: applicationId deve ser "com.nomegrupo.delivery", substituindo nomegrupo pelo nome real do seu grupo; minSdkVersion deve ser 21 (Android 5.0, cobrindo mais de 99% dos dispositivos ativos); targetSdkVersion deve ser 35 (a versão mais recente estável); versionCode deve corresponder ao código de build do pubspec.yaml; versionName deve corresponder à versão semântica do pubspec.yaml.

Para a assinatura do release, crie o arquivo android/key.properties com as entradas storePassword, keyPassword, keyAlias e storeFile, apontando para o keystore do grupo. Adicione android/key.properties ao .gitignore para que as senhas não sejam incluídas no repositório. Configure o bloco signingConfigs.release no build.gradle para ler as propriedades desse arquivo, e defina signingConfig signingConfigs.release no bloco buildTypes.release. Gere o keystore com keytool -genkey -v -keystore chave_delivery.jks -keyalg RSA -keysize 2048 -validity 10000 -alias chave_delivery, guarde a senha em um gerenciador de senhas e faça backup do arquivo .jks em pelo menos dois locais seguros.

Por fim, gere o APK de release com flutter build apk --release --split-per-abi e verifique que o arquivo é gerado sem erros em build/app/outputs/flutter-apk/. Documente no arquivo de entrega o tamanho do APK para cada arquitetura gerada (armeabi-v7a, arm64-v8a, x86_64) e o que o parâmetro --split-per-abi significa em termos práticos para o usuário final.

Entregue os arquivos do exercício compactados em g_ex3.zip, contendo: analysis_options.yaml configurado, .github/workflows/ci.yml completo, as partes relevantes do pubspec.yaml e do android/app/build.gradle copiadas para um arquivo g_ex3_config.txt, e um arquivo g_ex3_relatorio.txt com as respostas às perguntas de reflexão e a documentação da cobertura antes e depois dos testes adicionais.

Reflita sobre as seguintes questões. Por que a flag --fatal-infos no flutter analyze do CI é mais rigorosa do que simplesmente usar flutter analyze? Qual é a diferença entre gerar um APK com --split-per-abi e sem essa flag — e por que a versão separada por ABI é melhor para distribuição na Play Store? Por que o arquivo key.properties não deve jamais ser incluído no repositório Git, mesmo que o repositório seja privado? O que acontece com o histórico de avaliações e downloads de um aplicativo na Play Store se o applicationId for alterado após a publicação?

O arquivo de keystore .jks é insubstituível. Se ele for perdido — seja por exclusão acidental, falha de disco ou perda de senha — não será possível publicar atualizações para o aplicativo existente na Play Store. Seria necessário criar um novo aplicativo com um applicationId diferente, perdendo todo o histórico de avaliações, downloads e usuários ativos. Faça backup do keystore em pelo menos dois locais físicos diferentes (um gerenciador de senhas como Bitwarden ou 1Password e um pendrive criptografado armazenado em local seguro). Nunca salve o keystore em serviços de nuvem pública não criptografados como Google Drive ou Dropbox sem criptografia adicional.

O que deve ser entregue: um arquivo compactado chamado g_ex3.zip, onde g é o nome do seu grupo, contendo os arquivos de configuração e o relatório descritos no enunciado.