graph TD
A["Testes E2E / Integração\n(poucos, lentos, caros)\nVerificam o sistema completo"] --> B["Testes de Widget\n(quantidade moderada, velocidade média)\nVerificam componentes de UI"]
B --> C["Testes Unitários\n(muitos, rápidos, baratos)\nVerificam lógica isolada"]
style A fill:#ffcccc,stroke:#cc0000
style B fill:#fff9cc,stroke:#ccaa00
style C fill:#ccffcc,stroke:#00aa00
Módulo 15 — Testes, Qualidade e Publicação
Você chegou ao último módulo da disciplina. É um momento que merece ser comemorado — não porque o trabalho terminou, mas porque você está prestes a aprender as habilidades que separam um desenvolvedor que constrói protótipos de um desenvolvedor que entrega produtos. Ao longo de catorze módulos, você construiu, camada por camada, um aplicativo móvel completo: uma interface responsiva em Flutter, navegação declarativa com go_router, gerenciamento de estado com Provider, persistência local com SQLite, consumo de uma API REST hospedada na AWS, autenticação segura com OAuth 2.0, notificações push via Firebase, acesso a recursos nativos como câmera e GPS, e uma interface internacionalizada e acessível. Tudo isso é notável.
Mas há uma pergunta que qualquer engenheiro de software sério precisa ser capaz de responder sobre o código que escreve: como você sabe que ele funciona? Não “funciona na minha máquina”, não “testei manualmente algumas vezes” — mas funciona de forma verificável, reproduzível e documentada, para que qualquer colega de equipe ou colaborador futuro possa confirmar que o comportamento esperado continua presente após qualquer mudança. Essa é a questão que os testes automatizados respondem.
E há ainda a pergunta que fecha o ciclo do desenvolvimento: depois que o aplicativo está pronto e testado, como você o entrega ao usuário? O processo de publicação nas lojas — Google Play Store e Apple App Store — envolve assinatura de código, configuração de assets, formulação de metadados e submissão a processos de revisão. São etapas técnicas e burocráticas que você precisa conhecer para poder realizá-las.
Neste módulo, você vai aprender a escrever testes unitários, de widget e de integração para o aplicativo de delivery; a configurar a análise estática de código com flutter_lints; a preparar o aplicativo para publicação com todos os assets necessários; a gerar um build de release assinado para Android; e a entender o processo de submissão tanto na Google Play quanto na Apple App Store. Ao final, você terá completado o percurso completo do desenvolvimento móvel profissional.
Seção 1 — Por que Testar Software
Existe uma crença comum entre desenvolvedores iniciantes de que testes são uma atividade separada do desenvolvimento — algo que se faz depois que o código está pronto, ou que a equipe de QA realiza manualmente. Essa visão produz software frágil: qualquer mudança no código pode quebrar funcionalidades existentes de maneiras que só são descobertas quando o usuário reclama em produção. Os testes automatizados são a maneira sistemática de eliminar essa fragilidade.
O custo de encontrar um bug em diferentes momentos
Um dos conceitos mais importantes em engenharia de software é que o custo de corrigir um bug cresce exponencialmente à medida que o tempo passa entre a introdução do bug e sua descoberta. Um bug encontrado pelo próprio desenvolvedor enquanto escreve o código leva alguns minutos para ser corrigido. O mesmo bug encontrado por um colega durante a revisão de código leva algumas horas. Encontrado em testes de qualidade antes do lançamento, pode levar dias. Encontrado pelo usuário em produção, pode custar dias de trabalho mais o dano à reputação do produto — sem contar eventuais consequências legais ou financeiras.
Os testes automatizados agem como uma rede de segurança: quanto mais cedo um bug é capturado, menor o custo de corrigi-lo. Ao escrever testes que verificam o comportamento do código, você cria um mecanismo que detecta regressões — situações em que uma mudança que parecia inofensiva quebrou algo que funcionava antes — no momento exato em que a mudança é feita.
Testes como documentação viva
Há um benefício adicional dos testes automatizados que muitas vezes não é mencionado em cursos introdutórios: os testes são a documentação mais precisa e confiável de como o código deve se comportar. Comentários e documentação escrita ficam desatualizados — ninguém garante que um comentário escrito há seis meses ainda descreve corretamente o que o código faz hoje. Mas um teste automatizado que passa é uma afirmação verdadeira e verificável sobre o comportamento do sistema no momento presente.
Um teste bem escrito como testQueCarrinhoVazioRetornaZeroItens() documenta explicitamente um comportamento esperado do sistema. Qualquer desenvolvedor que leia esse teste sabe imediatamente o que o método correspondente deve fazer, sem precisar ler a implementação. Isso é especialmente valioso em equipes onde diferentes pessoas trabalham no mesmo código ao longo do tempo.
A pirâmide de testes
O modelo da pirâmide de testes, proposto por Mike Cohn, organiza os diferentes tipos de teste em camadas de acordo com seu custo de execução, velocidade e granularidade. A base da pirâmide é larga e contém os testes mais numerosos e baratos; o topo é estreito e contém os testes mais escassos e caros.
Os testes unitários ocupam a base. Eles verificam unidades isoladas de código — um método, uma classe, uma função — sem depender de componentes externos como banco de dados, rede ou interface. São extremamente rápidos (uma suíte de centenas de testes unitários roda em segundos) e fáceis de manter.
Os testes de widget (chamados de testes de componente em outras plataformas) ocupam o meio da pirâmide. Eles verificam o comportamento de widgets Flutter em um ambiente simulado, sem precisar de um dispositivo ou emulador real. São mais lentos que os unitários, mas muito mais rápidos que os testes de integração.
Os testes de integração ocupam o topo. Eles verificam o comportamento do aplicativo completo em um dispositivo ou emulador real, navegando pelas telas e interagindo com a interface como um usuário real faria. São os testes mais valiosos para verificar fluxos completos, mas também os mais lentos e difíceis de manter.
No contexto do aplicativo de delivery, uma estratégia razoável é ter muitos testes unitários para a lógica de domínio (cálculo de preços, validação de dados, regras de negócio), alguns testes de widget para os componentes de interface mais importantes, e poucos testes de integração para os fluxos mais críticos, como o fluxo de login e o fluxo de finalização de pedido.
Seção 2 — Testes Unitários com o Pacote test
O pacote test é a biblioteca base de testes em Dart. Ele fornece as funções test(), group(), expect() e os matchers necessários para escrever testes unitários. No Flutter, ele vem complementado pelo pacote flutter_test, que adiciona suporte a testes de widget — mas para testes puramente de lógica Dart, sem Flutter, você usa o test diretamente.
Estrutura básica de um teste
A estrutura de um arquivo de teste em Dart segue uma convenção simples: os arquivos ficam dentro da pasta test/ (na raiz do projeto, paralela à pasta lib/), seguindo a mesma hierarquia de pastas do código testado. Um arquivo de teste tem o sufixo _test.dart. A função main() é o ponto de entrada, onde você registra os grupos e os casos de teste individuais:
// test/features/carrinho/domain/carrinho_service_test.dart
import 'package:test/test.dart';
import 'package:delivery/features/carrinho/domain/carrinho_service.dart';
import 'package:delivery/features/carrinho/domain/entities/item_carrinho.dart';
import 'package:delivery/features/produtos/domain/entities/produto.dart';
void main() {
// group() organiza testes relacionados sob um nome descritivo.
// Pense nele como uma "suíte" de testes para uma classe ou comportamento específico.
group('CarrinhoService', () {
// Variáveis declaradas aqui são reinicializadas antes de cada teste,
// garantindo isolamento entre os casos.
late CarrinhoService carrinhoService;
late Produto produtoPizza;
late Produto produtoBebida;
// setUp() é executado antes de cada test() dentro do grupo.
// Use-o para inicializar o estado compartilhado pelos testes.
setUp(() {
carrinhoService = CarrinhoService();
produtoPizza = Produto(
id: '1',
nome: 'Pizza Margherita',
preco: 39.90,
categoria: 'Pizzas',
);
produtoBebida = Produto(
id: '2',
nome: 'Refrigerante 600ml',
preco: 7.50,
categoria: 'Bebidas',
);
});
// tearDown() é executado depois de cada test().
// Use-o para liberar recursos como conexões ou arquivos abertos.
tearDown(() {
carrinhoService.limpar();
});
// Cada test() verifica um comportamento específico e isolado.
// O nome deve descrever o que está sendo testado e o resultado esperado.
test('carrinho novo deve começar vazio', () {
expect(carrinhoService.itens, isEmpty);
expect(carrinhoService.totalItens, equals(0));
expect(carrinhoService.precoTotal, equals(0.0));
});
test('adicionar produto deve incluí-lo nos itens', () {
carrinhoService.adicionarProduto(produtoPizza);
expect(carrinhoService.itens, hasLength(1));
expect(carrinhoService.itens.first.produto.id, equals('1'));
expect(carrinhoService.itens.first.quantidade, equals(1));
});
test('adicionar mesmo produto duas vezes deve incrementar quantidade', () {
carrinhoService.adicionarProduto(produtoPizza);
carrinhoService.adicionarProduto(produtoPizza);
// O carrinho deve ter 1 item (não 2 linhas separadas) com quantidade 2
expect(carrinhoService.itens, hasLength(1));
expect(carrinhoService.itens.first.quantidade, equals(2));
});
test('preço total deve ser a soma dos itens com suas quantidades', () {
carrinhoService.adicionarProduto(produtoPizza);
carrinhoService.adicionarProduto(produtoPizza); // 2x R$ 39,90 = R$ 79,80
carrinhoService.adicionarProduto(produtoBebida); // 1x R$ 7,50
// Total esperado: R$ 87,30
expect(carrinhoService.precoTotal, closeTo(87.30, 0.001));
});
test('remover produto deve eliminá-lo dos itens', () {
carrinhoService.adicionarProduto(produtoPizza);
carrinhoService.removerProduto(produtoPizza.id);
expect(carrinhoService.itens, isEmpty);
});
test('remover produto inexistente não deve lançar exceção', () {
// Verificamos que remover um produto que não está no carrinho
// é uma operação silenciosa e segura.
expect(
() => carrinhoService.removerProduto('id_inexistente'),
returnsNormally, // não lança exceção
);
});
test('limpar carrinho deve remover todos os itens', () {
carrinhoService.adicionarProduto(produtoPizza);
carrinhoService.adicionarProduto(produtoBebida);
carrinhoService.limpar();
expect(carrinhoService.itens, isEmpty);
expect(carrinhoService.precoTotal, equals(0.0));
});
});
}// test/features/carrinho/domain/carrinho_service_test.dart
import 'package:test/test.dart';
import 'package:delivery/features/carrinho/domain/carrinho_service.dart';
import 'package:delivery/features/produtos/domain/entities/produto.dart';
void main() {
group('CarrinhoService', () {
late CarrinhoService sut;
final pizza = Produto(id: '1', nome: 'Pizza Margherita', preco: 39.90, categoria: 'Pizzas');
final bebida = Produto(id: '2', nome: 'Refrigerante 600ml', preco: 7.50, categoria: 'Bebidas');
setUp(() => sut = CarrinhoService());
tearDown(() => sut.limpar());
test('carrinho novo começa vazio', () {
expect(sut.itens, isEmpty);
expect(sut.precoTotal, equals(0.0));
});
test('adicionar produto inclui item', () {
sut.adicionarProduto(pizza);
expect(sut.itens, hasLength(1));
expect(sut.itens.first.quantidade, equals(1));
});
test('adicionar mesmo produto duas vezes incrementa quantidade', () {
sut
..adicionarProduto(pizza)
..adicionarProduto(pizza);
expect(sut.itens, hasLength(1));
expect(sut.itens.first.quantidade, equals(2));
});
test('preço total é soma dos itens', () {
sut
..adicionarProduto(pizza)
..adicionarProduto(pizza)
..adicionarProduto(bebida);
expect(sut.precoTotal, closeTo(87.30, 0.001));
});
test('remover produto elimina item', () {
sut
..adicionarProduto(pizza)
..removerProduto(pizza.id);
expect(sut.itens, isEmpty);
});
test('remover produto inexistente não lança exceção', () =>
expect(() => sut.removerProduto('x'), returnsNormally));
test('limpar remove todos os itens', () {
sut
..adicionarProduto(pizza)
..adicionarProduto(bebida)
..limpar();
expect(sut.itens, isEmpty);
});
});
}Executando os testes
Para rodar todos os testes do projeto, use o comando:
Para rodar apenas um arquivo específico:
Para rodar com cobertura de código (coverage), que mostra quais linhas do código foram exercidas pelos testes:
O relatório de cobertura é gerado em coverage/lcov.info. Você pode convertê-lo em HTML com a ferramenta genhtml do pacote LCOV para visualizar interativamente quais partes do código estão cobertas.
Os matchers mais utilizados
O pacote test oferece uma coleção rica de matchers — objetos que expressam condições de verificação — que tornam os testes legíveis e expressivos. Conhecer os matchers disponíveis te ajuda a escrever expectativas que comunicam claramente o que está sendo verificado:
| Matcher | O que verifica |
|---|---|
equals(valor) |
Igualdade exata com == |
isNull / isNotNull |
Nulidade do valor |
isEmpty / isNotEmpty |
Lista, mapa ou string vazia |
hasLength(n) |
Comprimento de lista ou string |
contains(item) |
Lista ou string contém o item |
isA<Tipo>() |
Tipo do objeto (instanceof) |
closeTo(valor, delta) |
Número dentro de uma margem de tolerância |
greaterThan(n) / lessThan(n) |
Comparação numérica |
throwsA<Excecao>() |
A função lança uma exceção específica |
returnsNormally |
A função não lança nenhuma exceção |
completes |
Future completa sem erro |
completion(matcher) |
Future completa com valor que satisfaz o matcher |
Seção 3 — Mocks com mockito
Testes unitários verificam lógica isolada. Mas a maioria das classes do aplicativo tem dependências — outras classes que são chamadas durante a execução. Um PedidoService depende de um PedidoRepository, que por sua vez depende de uma conexão HTTP com o backend. Ao testar o PedidoService, você não quer fazer chamadas HTTP reais: elas são lentas, dependem de conectividade, e o estado do servidor pode variar. Em vez disso, você substitui o repositório por um mock — um objeto que imita a interface do repositório real, mas cujo comportamento você controla completamente no teste.
O pacote mockito é a ferramenta padrão para criar mocks em Dart. Ele utiliza geração de código para produzir classes mock que implementam a interface da classe original. Para usá-lo, adicione as dependências:
Criando mocks com @GenerateMocks
O mockito moderno usa geração de código. Você anota o arquivo de teste com @GenerateMocks especificando quais classes precisam de mocks, e o build_runner gera automaticamente as classes mock correspondentes:
// test/features/pedidos/domain/pedido_service_test.dart
// Esta anotação instrui o build_runner a gerar mocks para as classes listadas.
// Após rodar "flutter pub run build_runner build", o arquivo
// "pedido_service_test.mocks.dart" será gerado automaticamente.
@GenerateMocks([PedidoRepository, UsuarioRepository])
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:delivery/features/pedidos/domain/pedido_service.dart';
import 'package:delivery/features/pedidos/domain/repositories/pedido_repository.dart';
import 'package:delivery/features/usuarios/domain/repositories/usuario_repository.dart';
import 'pedido_service_test.mocks.dart'; // gerado pelo build_runner
void main() {
group('PedidoService', () {
late PedidoService pedidoService;
late MockPedidoRepository mockPedidoRepository;
late MockUsuarioRepository mockUsuarioRepository;
setUp(() {
// Instancia os mocks gerados pelo mockito
mockPedidoRepository = MockPedidoRepository();
mockUsuarioRepository = MockUsuarioRepository();
// Injeta os mocks no serviço que estamos testando
pedidoService = PedidoService(
pedidoRepository: mockPedidoRepository,
usuarioRepository: mockUsuarioRepository,
);
});
test('buscarPedidos deve retornar lista de pedidos do repositório', () async {
// ARRANGE: configura o comportamento do mock.
// "quando chamar buscarTodos(), retorne esta lista"
final pedidosEsperados = [
Pedido(id: '1', status: StatusPedido.emPreparo, total: 39.90),
Pedido(id: '2', status: StatusPedido.entregue, total: 22.00),
];
when(mockPedidoRepository.buscarTodos())
.thenAnswer((_) async => pedidosEsperados);
// ACT: executa o método que está sendo testado
final resultado = await pedidoService.buscarPedidos();
// ASSERT: verifica que o resultado é o esperado
expect(resultado, equals(pedidosEsperados));
expect(resultado, hasLength(2));
// Verifica que o repositório foi chamado exatamente uma vez
verify(mockPedidoRepository.buscarTodos()).called(1);
});
test('buscarPedidos deve propagar exceção quando repositório falha', () async {
// Configura o mock para lançar uma exceção
when(mockPedidoRepository.buscarTodos())
.thenThrow(Exception('Falha na conexão com o servidor'));
// Verifica que o serviço propaga a exceção adequadamente
expect(
() => pedidoService.buscarPedidos(),
throwsA(isA<Exception>()),
);
});
test('finalizarPedido deve chamar repositório com os dados corretos', () async {
// Arrange
final itensPedido = [
ItemPedido(produtoId: '1', quantidade: 2, precoUnitario: 39.90),
];
when(mockPedidoRepository.criar(any))
.thenAnswer((_) async => Pedido(id: '3', status: StatusPedido.aguardando, total: 79.80));
when(mockUsuarioRepository.obterEnderecoEntrega())
.thenAnswer((_) async => Endereco(rua: 'Rua A', numero: '10'));
// Act
await pedidoService.finalizarPedido(itens: itensPedido);
// Assert: verifica que o método criar foi chamado no repositório
verify(mockPedidoRepository.criar(any)).called(1);
});
});
}@GenerateMocks([PedidoRepository, UsuarioRepository])
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:delivery/features/pedidos/domain/pedido_service.dart';
import 'package:delivery/features/pedidos/domain/repositories/pedido_repository.dart';
import 'package:delivery/features/usuarios/domain/repositories/usuario_repository.dart';
import 'pedido_service_test.mocks.dart';
void main() {
group('PedidoService', () {
late PedidoService sut;
late MockPedidoRepository mockRepo;
late MockUsuarioRepository mockUserRepo;
setUp(() {
mockRepo = MockPedidoRepository();
mockUserRepo = MockUsuarioRepository();
sut = PedidoService(pedidoRepository: mockRepo, usuarioRepository: mockUserRepo);
});
test('buscarPedidos retorna lista do repositório', () async {
final pedidos = [
Pedido(id: '1', status: StatusPedido.emPreparo, total: 39.90),
Pedido(id: '2', status: StatusPedido.entregue, total: 22.00),
];
when(mockRepo.buscarTodos()).thenAnswer((_) async => pedidos);
final resultado = await sut.buscarPedidos();
expect(resultado, equals(pedidos));
verify(mockRepo.buscarTodos()).called(1);
});
test('buscarPedidos propaga exceção do repositório', () async {
when(mockRepo.buscarTodos()).thenThrow(Exception('Falha'));
expect(sut.buscarPedidos, throwsA(isA<Exception>()));
});
});
}Para gerar os mocks após criar a anotação @GenerateMocks, rode:
O padrão AAA: Arrange, Act, Assert
O padrão AAA (Arrange, Act, Assert) é a estrutura recomendada para organizar cada caso de teste. Na fase de Arrange, você prepara o ambiente: inicializa objetos, configura mocks e define os dados de entrada. Na fase de Act, você executa a ação que está sendo testada — geralmente uma única chamada de método. Na fase de Assert, você verifica que o resultado é o esperado. Manter essa separação clara torna os testes mais legíveis e facilita identificar o que cada fase do teste está fazendo.
Seção 4 — Testes de Widget com flutter_test
Enquanto os testes unitários verificam lógica de código Dart puro, os testes de widget verificam componentes de interface Flutter. Eles usam o WidgetTester para renderizar widgets em um ambiente simulado — sem precisar de um dispositivo ou emulador real — e fornecem APIs para interagir com a interface programaticamente: tocar em botões, digitar em campos de texto, rolar listas e verificar a presença de elementos na tela.
A estrutura de um teste de widget
Os testes de widget usam a função testWidgets() em vez de test(). O primeiro parâmetro é a descrição do teste, e o segundo é uma função assíncrona que recebe um WidgetTester como argumento. O WidgetTester é a interface principal para interagir com o widget sendo testado:
// test/features/carrinho/presentation/widgets/card_produto_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:delivery/features/produtos/domain/entities/produto.dart';
import 'package:delivery/features/produtos/presentation/widgets/card_produto.dart';
void main() {
group('CardProduto', () {
// O produto de teste que será passado para o widget
final produtoTeste = Produto(
id: '1',
nome: 'Pizza Margherita',
preco: 39.90,
descricao: 'Molho de tomate, mozzarella e manjericão fresco',
categoria: 'Pizzas',
);
// testWidgets() é o equivalente de test() para widgets Flutter.
// O parâmetro WidgetTester permite renderizar e interagir com widgets.
testWidgets('deve exibir o nome e o preço do produto', (tester) async {
// ARRANGE: renderiza o widget dentro de um MaterialApp
// (necessário para que temas, navigator e mediaquery estejam disponíveis)
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CardProduto(
produto: produtoTeste,
aoAdicionar: () {},
),
),
),
);
// ASSERT: verifica que os textos esperados estão na tela
// find.text() busca um widget Text com o texto exato especificado
expect(find.text('Pizza Margherita'), findsOneWidget);
// Para o preço formatado como moeda:
expect(find.textContaining('39'), findsWidgets);
});
testWidgets('deve chamar aoAdicionar quando botão é tocado', (tester) async {
// Usamos uma variável para rastrear se o callback foi chamado
bool callbackFoiChamado = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CardProduto(
produto: produtoTeste,
aoAdicionar: () {
callbackFoiChamado = true;
},
),
),
),
);
// ACT: simula um toque no botão "Adicionar"
// find.byType() busca um widget pelo seu tipo
// find.text() busca pelo texto exibido
await tester.tap(find.text('Adicionar'));
// pump() aguarda a conclusão de animações e rebuilds pendentes
await tester.pump();
// ASSERT: verifica que o callback foi chamado
expect(callbackFoiChamado, isTrue);
});
testWidgets('deve exibir indicador de carregamento durante operação', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CardProduto(
produto: produtoTeste,
aoAdicionar: () {},
isCarregando: true,
),
),
),
);
// Verifica que o CircularProgressIndicator está presente
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Verifica que o botão não está visível durante o carregamento
expect(find.text('Adicionar'), findsNothing);
});
});
}import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:delivery/features/produtos/domain/entities/produto.dart';
import 'package:delivery/features/produtos/presentation/widgets/card_produto.dart';
void main() {
group('CardProduto', () {
final produto = Produto(id: '1', nome: 'Pizza Margherita', preco: 39.90,
descricao: 'Molho de tomate, mozzarella e manjericão', categoria: 'Pizzas');
Widget buildUnderTest({VoidCallback? aoAdicionar, bool isCarregando = false}) =>
MaterialApp(
home: Scaffold(
body: CardProduto(produto: produto, aoAdicionar: aoAdicionar ?? () {}, isCarregando: isCarregando),
),
);
testWidgets('exibe nome e preço', (t) async {
await t.pumpWidget(buildUnderTest());
expect(find.text('Pizza Margherita'), findsOneWidget);
expect(find.textContaining('39'), findsWidgets);
});
testWidgets('chama aoAdicionar ao tocar no botão', (t) async {
var chamado = false;
await t.pumpWidget(buildUnderTest(aoAdicionar: () => chamado = true));
await t.tap(find.text('Adicionar'));
await t.pump();
expect(chamado, isTrue);
});
testWidgets('exibe CircularProgressIndicator durante carregamento', (t) async {
await t.pumpWidget(buildUnderTest(isCarregando: true));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Adicionar'), findsNothing);
});
});
}Métodos essenciais do WidgetTester
O WidgetTester fornece uma série de métodos que você usará recorrentemente nos testes de widget. Entender o propósito de cada um é fundamental para escrever testes corretos:
O método pumpWidget() renderiza o widget pela primeira vez. Ele precisa ser chamado antes de qualquer interação ou verificação. O MaterialApp ao redor do widget é necessário porque a maioria dos widgets Flutter depende de contextos como Theme, Navigator e MediaQuery que o MaterialApp fornece.
O método pump() avança o relógio da animação por um frame. Após realizar uma ação que dispara uma reconstrução ou animação, você precisa chamar pump() para que o widget seja redesenhado e as verificações reflitam o estado atualizado. Já o pumpAndSettle() continua avançando o relógio até que não haja mais animações ou timers pendentes — útil para aguardar o fim de transições de tela ou animações de loading.
O método tap() simula um toque em um widget encontrado pelo finder. O método enterText() digita texto em um TextField encontrado pelo finder. O método drag() simula um gesto de arrasto. O método scrollUntilVisible() rola uma lista até que um widget seja encontrado.
Os finders mais utilizados
Os finders são objetos que localizam widgets na árvore para que o WidgetTester possa interagir com eles ou verificar sua presença:
| Finder | O que encontra |
|---|---|
find.text('texto') |
Widget Text com o conteúdo exato |
find.textContaining('parte') |
Widget Text que contém a substring |
find.byType(TipoWidget) |
Qualquer widget do tipo especificado |
find.byKey(Key('chave')) |
Widget com a Key especificada |
find.byIcon(Icons.icone) |
Widget Icon com o ícone especificado |
find.widgetWithText(Tipo, 'texto') |
Widget do tipo que contém o texto |
find.ancestor(of: x, matching: y) |
Ancestral de x que satisfaz y |
find.descendant(of: x, matching: y) |
Descendente de x que satisfaz y |
E os matchers para verificar a presença:
| Matcher | O que verifica |
|---|---|
findsOneWidget |
Exatamente um widget encontrado |
findsWidgets |
Um ou mais widgets encontrados |
findsNothing |
Nenhum widget encontrado |
findsNWidgets(n) |
Exatamente n widgets encontrados |
findsAtLeast(n) |
Pelo menos n widgets encontrados |
Seção 5 — Testes de Integração com integration_test
Os testes de integração verificam o comportamento do aplicativo completo em um dispositivo ou emulador real, navegando por múltiplas telas e realizando ações como um usuário real faria. Eles são mais lentos que os outros tipos de teste e têm mais dependências externas, mas são os únicos que verificam se todos os componentes do sistema — Flutter, Provider, go_router, banco de dados local e chamadas HTTP — funcionam corretamente em conjunto.
Configurando o integration_test
O pacote integration_test faz parte do SDK Flutter e não precisa ser instalado separadamente, mas precisa ser adicionado como dependência de desenvolvimento:
Os arquivos de teste de integração ficam numa pasta separada chamada integration_test/ (não dentro de test/). Cada arquivo é um teste que roda no dispositivo:
// integration_test/fluxo_login_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:delivery/main.dart' as app;
void main() {
// Esta linha inicializa o binding do integration_test.
// Deve ser a primeira linha do main() dos testes de integração.
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Fluxo de Login', () {
testWidgets(
'usuário deve conseguir fazer login com credenciais válidas',
(tester) async {
// Inicializa o aplicativo completo
app.main();
// pumpAndSettle aguarda o aplicativo inicializar completamente
// (carregamento inicial, animações de splash screen, etc.)
await tester.pumpAndSettle();
// Verifica que a tela de login foi exibida
expect(find.text('Bem-vindo de volta'), findsOneWidget);
// Preenche o campo de e-mail
final campoEmail = find.byKey(const Key('campo_email'));
await tester.enterText(campoEmail, 'usuario@teste.com');
// Preenche o campo de senha
final campoSenha = find.byKey(const Key('campo_senha'));
await tester.enterText(campoSenha, 'Senha@123');
// Toca no botão de login
await tester.tap(find.text('Entrar'));
// Aguarda a navegação para a tela principal completar
await tester.pumpAndSettle(const Duration(seconds: 5));
// Verifica que a tela inicial foi exibida após o login
expect(find.text('Restaurantes próximos'), findsOneWidget);
},
);
testWidgets(
'deve exibir mensagem de erro com credenciais inválidas',
(tester) async {
app.main();
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('campo_email')),
'invalido@email.com',
);
await tester.enterText(
find.byKey(const Key('campo_senha')),
'senha_errada',
);
await tester.tap(find.text('Entrar'));
await tester.pumpAndSettle(const Duration(seconds: 5));
// Verifica que a mensagem de erro aparece
expect(find.textContaining('Credenciais inválidas'), findsOneWidget);
// Verifica que permanecemos na tela de login
expect(find.text('Bem-vindo de volta'), findsOneWidget);
},
);
});
}import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:delivery/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Fluxo de Login', () {
Future<void> _iniciar(WidgetTester t) async {
app.main();
await t.pumpAndSettle();
}
Future<void> _preencherLogin(WidgetTester t, {required String email, required String senha}) async {
await t.enterText(find.byKey(const Key('campo_email')), email);
await t.enterText(find.byKey(const Key('campo_senha')), senha);
await t.tap(find.text('Entrar'));
await t.pumpAndSettle(const Duration(seconds: 5));
}
testWidgets('login com credenciais válidas navega para home', (t) async {
await _iniciar(t);
await _preencherLogin(t, email: 'usuario@teste.com', senha: 'Senha@123');
expect(find.text('Restaurantes próximos'), findsOneWidget);
});
testWidgets('login com credenciais inválidas exibe erro', (t) async {
await _iniciar(t);
await _preencherLogin(t, email: 'x@x.com', senha: 'errada');
expect(find.textContaining('Credenciais inválidas'), findsOneWidget);
});
});
}Para executar os testes de integração no emulador ou dispositivo conectado:
Seção 6 — Análise Estática com flutter_lints
A análise estática é o processo de inspecionar o código-fonte sem executá-lo, buscando problemas como uso de variáveis não inicializadas, chamadas de métodos que podem causar null pointer exceptions, código morto (que nunca é executado), e padrões que violam as convenções da linguagem. O compilador Dart já realiza uma forma de análise estática durante a compilação, mas o analisador — ativado pelo pacote flutter_lints — vai muito além, verificando padrões de qualidade e estilo que o compilador não considera erros mas que indicam código problemático.
Configurando o flutter_lints
O pacote flutter_lints já está presente na maioria dos projetos Flutter criados com flutter create. Ele define um conjunto de regras de lint recomendadas pela equipe do Flutter. Para verificar a configuração, olhe o arquivo analysis_options.yaml na raiz do projeto:
Para executar a análise estática manualmente:
O comando lista todos os problemas encontrados, com a localização exata no código (arquivo, linha e coluna) e a descrição do problema. Em projetos com CI/CD (integração contínua), o flutter analyze é frequentemente executado a cada push para garantir que o código novo não introduz problemas de qualidade.
As regras de lint mais importantes
O flutter_lints inclui dezenas de regras. Algumas das mais importantes para o contexto do aplicativo de delivery são:
prefer_const_constructors: Encoraja o uso de const em construtores quando possível. Widgets const são uma otimização de desempenho no Flutter — o framework reutiliza a instância existente em vez de criar uma nova a cada reconstrução.
avoid_unnecessary_containers: Avisa quando você usa um Container quando um widget mais simples, como Padding, ColoredBox ou DecoratedBox, seria suficiente. Containers desnecessários adicionam uma camada extra à árvore de widgets sem benefício.
use_key_in_widget_constructors: Encoraja a adição do parâmetro super.key a todos os construtores de widget, o que é importante para que o Flutter possa identificar widgets corretamente durante reconstruções.
avoid_print: Adverte contra o uso de print() no código de produção. Em vez disso, use um sistema de logging adequado que pode ser desabilitado em builds de release.
prefer_single_quotes: Padroniza o uso de aspas simples em strings, o que é a convenção da comunidade Dart.
Seção 7 — Preparando o Aplicativo para Publicação
Publicar um aplicativo nas lojas é um processo que vai muito além de simplesmente compilar o código. As lojas têm requisitos específicos sobre os assets do aplicativo, a assinatura do código, as permissões declaradas e os metadados. Esta seção apresenta cada etapa do processo de preparação, com ênfase na publicação para Android, que é mais acessível para desenvolvedores iniciantes por não exigir um Mac para a compilação.
Ícones do aplicativo
O ícone do aplicativo é um dos primeiros elementos que o usuário vê. Ele precisa ser fornecido em várias resoluções para diferentes densidades de tela. No Android, os ícones ficam nas pastas mipmap-* dentro de android/app/src/main/res/. As resoluções necessárias são:
| Pasta | Resolução (px) | Dispositivo |
|---|---|---|
mipmap-mdpi |
48×48 | Densidade 1x (referência) |
mipmap-hdpi |
72×72 | Densidade 1,5x |
mipmap-xhdpi |
96×96 | Densidade 2x |
mipmap-xxhdpi |
144×144 | Densidade 3x |
mipmap-xxxhdpi |
192×192 | Densidade 4x |
mipmap-anydpi-v26 |
Ícone adaptável (XML) | Android 8.0+ |
A abordagem prática e recomendada é usar o pacote flutter_launcher_icons para gerar automaticamente todos os tamanhos a partir de uma única imagem de alta resolução (recomendada: 1024×1024 pixels):
Após configurar, execute:
Splash screen
A splash screen é a tela exibida enquanto o aplicativo está sendo iniciado. No Flutter, o pacote flutter_native_splash automatiza a criação da splash screen nativa para Android e iOS:
Configurando metadados do aplicativo no Android
Antes de gerar o build de release, você precisa revisar as configurações do aplicativo no arquivo android/app/build.gradle. Os campos mais importantes são:
android {
defaultConfig {
// Identificador único do aplicativo na Play Store.
// Uma vez publicado, não pode ser alterado.
applicationId "com.suaempresa.delivery"
// API mínima suportada. O valor 21 corresponde ao Android 5.0,
// que cobre mais de 99% dos dispositivos ativos.
minSdkVersion 21
// API alvo. Use sempre o valor mais recente disponível.
targetSdkVersion 35
// Versão de código: número inteiro que aumenta a cada publicação.
// A Play Store usa este número para determinar qual versão é mais recente.
versionCode 1
// Versão exibida ao usuário (ex: "1.0.0").
versionName "1.0.0"
}
}O applicationId é o identificador único que distingue seu aplicativo de todos os outros na Play Store. Ele segue o padrão de nome de domínio reverso (reverse domain name notation): com.nomeempresa.nomeapp. Uma vez que o aplicativo é publicado com um applicationId, ele nunca pode ser alterado — mudar o ID equivale a criar um aplicativo novo, perdendo todos os downloads, avaliações e histórico do app original.
Seção 8 — Assinatura do Aplicativo Android com Keystore
Todo aplicativo Android distribuído publicamente precisa ser assinado digitalmente. A assinatura serve como prova de identidade do desenvolvedor: o sistema operacional Android verifica a assinatura antes de instalar uma atualização, garantindo que somente o detentor da chave privada pode publicar atualizações para aquele aplicativo. Se você perder o arquivo de keystore ou esquecer a senha, não será possível publicar atualizações para o aplicativo existente na Play Store — seria necessário criar um aplicativo novo, perdendo todo o histórico.
Criando o keystore
O keystore é um arquivo que contém sua chave privada de assinatura. Você o cria com a ferramenta keytool, que acompanha o JDK. Execute o comando no terminal e responda às perguntas sobre sua identidade:
O parâmetro -validity 10000 define que a chave é válida por 10000 dias (aproximadamente 27 anos). A Google recomenda uma validade de pelo menos 25 anos para chaves de aplicativos Android. Você será solicitado a definir uma senha para o keystore e uma senha para o alias (a chave dentro do keystore). Guarde essas senhas em um gerenciador de senhas seguro.
O arquivo de keystore é insubstituível. Se você perdê-lo, não poderá publicar atualizações para seu aplicativo na Play Store. Faça backup em pelo menos dois locais seguros (gerenciador de senhas e armazenamento criptografado offline). Nunca adicione o keystore ao controle de versão (Git).
Configurando a assinatura no projeto Flutter
Após criar o keystore, você precisa configurar o projeto Flutter para utilizá-lo durante o build de release. A maneira mais segura é criar um arquivo android/key.properties que referencia o keystore (sem incluir o keystore no repositório):
Em seguida, adicione android/key.properties ao .gitignore. Depois, modifique o android/app/build.gradle para carregar essas configurações:
// No início do arquivo android/app/build.gradle,
// antes do bloco android {}:
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}Gerando o build de release
Com a assinatura configurada, você pode gerar o arquivo de build para publicação. O Flutter suporta dois formatos para Android:
O formato AAB (Android App Bundle) é o formato moderno recomendado pela Google. Diferentemente do APK tradicional, o AAB é processado pela Play Store, que gera APKs otimizados para cada configuração de dispositivo — apenas os recursos necessários (idiomas, densidades de tela, arquiteturas de processador) são incluídos no arquivo entregue a cada dispositivo. Isso resulta em arquivos de instalação menores para o usuário final.
O formato APK (Android Package) é o formato tradicional, útil para distribuição direta (sem a Play Store) ou para testes de release:
O parâmetro --split-per-abi gera APKs separados para cada arquitetura de processador (arm64-v8a, armeabi-v7a, x86_64), resultando em arquivos menores do que um APK universal único.
O arquivo AAB gerado fica em build/app/outputs/bundle/release/app-release.aab. É este arquivo que você enviará para a Google Play Console.
Seção 9 — Publicando na Google Play Store
A Google Play Store é a maior loja de aplicativos do mundo em número de downloads. Publicar um aplicativo nela requer a criação de uma conta de desenvolvedor na Google Play Console, o pagamento de uma taxa única de cadastro (atualmente de US$ 25), e a submissão do aplicativo a um processo de revisão que verifica a conformidade com as políticas da Google.
Criando a conta de desenvolvedor e o aplicativo
O processo começa em play.google.com/console, onde você cria uma conta de desenvolvedor, aceita os termos de serviço e efetua o pagamento da taxa. Após a aprovação da conta, você cria um novo aplicativo informando o nome, o idioma padrão e se é um aplicativo ou jogo.
O próximo passo é preencher a ficha da loja (store listing), que é o que o usuário vê ao encontrar seu aplicativo na Play Store. Os campos obrigatórios incluem:
O título do aplicativo: até 30 caracteres. Escolha um nome claro e descritivo que inclua a palavra-chave mais relevante para a busca do usuário.
A descrição resumida: até 80 caracteres. Aparece nos resultados de busca — deve capturar a essência do aplicativo de forma concisa.
A descrição completa: até 4000 caracteres. Explique o que o aplicativo faz, seus diferenciais e os principais recursos. Inclua as palavras-chave relevantes naturalmente no texto.
Os screenshots: pelo menos dois screenshots por tipo de dispositivo (telefone, tablet). Os screenshots de telefone devem ter proporção de 9:16 (ou similar). A resolução mínima é 320 pixels no lado menor.
O ícone: 512×512 pixels em PNG com canal alpha.
O gráfico de recursos (Feature Graphic): imagem de 1024×500 pixels que aparece no topo da página do aplicativo. É o banner visual mais importante da ficha.
O processo de revisão
Ao enviar o AAB e preencher todos os metadados, o aplicativo entra no processo de revisão da Google. A revisão verifica se o aplicativo:
- Não viola as políticas de conteúdo da Google (pornografia, violência, discurso de ódio, etc.)
- Não contém malware ou código malicioso
- Funciona corretamente nos dispositivos compatíveis declarados
- As permissões solicitadas são justificadas pelo uso do aplicativo
- O aplicativo não usa APIs restritas sem as declarações necessárias
Para aplicativos novos, o processo de revisão pode levar de algumas horas a alguns dias. Após a aprovação, o aplicativo fica disponível para download em todos os países especificados no lançamento.
Estratégias de lançamento
A Google Play Console oferece diferentes estratégias de lançamento que são muito úteis para minimizar o risco de publicar um bug crítico para todos os usuários simultaneamente:
O lançamento interno permite que até 100 testadores designados instalem o aplicativo. Não há processo de revisão para lançamentos internos.
O lançamento fechado (anteriormente chamado de Alpha) permite que um grupo de testadores selecionados instale o aplicativo. O processo de revisão é aplicado mas pode ser mais rápido.
O lançamento aberto (anteriormente chamado de Beta) está disponível para qualquer usuário que acesse a página do aplicativo e opte por participar do programa de testes.
O lançamento em produção em estágios (Staged rollout) é a ferramenta mais poderosa: você define uma porcentagem de usuários que receberão a nova versão — por exemplo, 10% — e monitora métricas de crashes e avaliações. Se tudo estiver bem, você aumenta gradualmente para 100%.
Seção 10 — O Processo de Publicação na Apple App Store
A Apple App Store tem um processo de publicação diferente da Google Play em vários aspectos importantes. A diferença mais imediata para desenvolvedores é que compilar um aplicativo iOS requer um Mac com o Xcode instalado. Além disso, a conta de desenvolvedor Apple custa US$ 99 por ano (versus o pagamento único de US$ 25 da Google). O processo de revisão da Apple é geralmente mais rigoroso e pode levar mais tempo, mas a taxa de aprovação de aplicativos legítimos é alta.
A conta de desenvolvedor Apple e os certificados
O ponto de partida é criar uma conta no Apple Developer Program em developer.apple.com. Após o pagamento da anuidade, você tem acesso ao portal de desenvolvedores, onde gerencia três tipos de artefatos que são fundamentais para a publicação:
Os certificados de distribuição funcionam de forma análoga ao keystore do Android: eles identificam você como desenvolvedor Apple e são usados para assinar o código do aplicativo. Há dois tipos: Development (para testes em dispositivos físicos durante o desenvolvimento) e Distribution (para publicação na App Store).
Os identificadores de aplicativo (App IDs) são os equivalentes do applicationId do Android: identificam uniquely seu aplicativo no ecossistema Apple. Seguem o mesmo formato de domínio reverso.
Os perfis de provisionamento (Provisioning Profiles) são arquivos que vinculam um App ID a um certificado, definindo em quais dispositivos e sob quais condições o aplicativo pode ser executado. Para publicação na App Store, você usa o perfil de distribuição de App Store.
O processo de compilação e envio via Xcode e TestFlight
Para compilar o aplicativo Flutter para iOS em release, execute no terminal (em um Mac):
Em seguida, abra o arquivo ios/Runner.xcworkspace no Xcode e realize as configurações de assinatura, selecionando seu certificado de distribuição e o perfil de provisionamento correto. A compilação final e o envio para a App Store Connect (o painel web da Apple, equivalente ao Google Play Console) são feitos pelo próprio Xcode, via menu Product > Archive e depois Distribute App.
O TestFlight é a plataforma de distribuição de versões de teste da Apple, integrada à App Store Connect. Antes de submeter o aplicativo para revisão da App Store, você pode distribuí-lo via TestFlight para testadores internos (até 100 pessoas sem revisão da Apple) e testadores externos (até 10.000 pessoas, com revisão simplificada). Isso é altamente recomendado para identificar problemas antes de enviar para a revisão completa da App Store.
O processo de revisão da Apple
O processo de revisão da Apple é notoriamente mais criterioso que o da Google. Os revisores da Apple instalam e testam o aplicativo manualmente. Entre os motivos mais comuns de rejeição estão:
A falta de funcionalidade mínima: aplicativos que fazem muito pouco ou que parecem incompletos são rejeitados. O aplicativo deve oferecer valor claro para o usuário.
Referências a outras plataformas: menções ao Android, Google Play ou competidores dentro do aplicativo são geralmente motivo de rejeição.
Uso de APIs privadas: a Apple proíbe o uso de APIs não documentadas ou privadas do sistema operacional.
Problemas com as políticas de compra: se o aplicativo comercializa bens digitais (como moedas virtuais ou assinaturas), deve usar o sistema de pagamentos da Apple (In-App Purchase), do qual a Apple retém uma comissão.
Seção 11 — Integração do Processo de Qualidade no Ciclo de Desenvolvimento
As práticas que você aprendeu neste módulo — testes unitários, de widget e de integração, análise estática — têm máximo valor quando são integradas ao fluxo de trabalho diário do desenvolvimento, não quando executadas ocasionalmente antes de um lançamento. Esta seção descreve como construir um ciclo de desenvolvimento onde a qualidade é verificada continuamente.
O fluxo ideal de desenvolvimento com qualidade
graph LR
A[Escrever\ncódigo] --> B[Rodar\nflutter analyze]
B --> C{Problemas\nde lint?}
C -->|Sim| A
C -->|Não| D[Rodar\nflutter test]
D --> E{Testes\npassam?}
E -->|Não| A
E -->|Sim| F[Commit\nno Git]
F --> G[Push\npara GitHub]
G --> H[CI executa\nanalyze + test]
H --> I{CI\npassa?}
I -->|Sim| J[Code Review]
I -->|Não| A
J --> K[Merge\nna branch principal]
Configurando um pipeline de CI com GitHub Actions
O GitHub Actions é uma ferramenta de integração contínua que permite executar automaticamente os testes e a análise estática a cada push para o repositório. Para o projeto de delivery, um arquivo de workflow básico ficaria em .github/workflows/ci.yml:
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout do código
uses: actions/checkout@v4
- name: Configurar Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.32.0'
channel: 'stable'
- name: Instalar dependências
run: flutter pub get
- name: Análise estática
run: flutter analyze --fatal-infos
- name: Rodar testes unitários e de widget
run: flutter test --coverage
- name: Verificar cobertura mínima
run: |
COVERAGE=$(lcov --summary coverage/lcov.info 2>&1 | grep "lines" | awk '{print $2}' | tr -d '%')
echo "Cobertura: $COVERAGE%"
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "Cobertura abaixo de 70%!"
exit 1
fiCom esse pipeline configurado, cada push e cada pull request dispara automaticamente a análise estática e os testes. Se alguma verificação falhar, o merge é bloqueado, garantindo que a branch principal nunca contenha código que não passa nos testes.
Seção 12 — Cobertura de Testes: Quantidade e Qualidade
A cobertura de testes (test coverage) é uma métrica que indica qual porcentagem das linhas, ramificações ou funcionalidades do código são exercitadas pelos testes automatizados. Embora seja uma métrica valiosa, ela deve ser interpretada com cuidado: 100% de cobertura não garante que o software está livre de bugs — garante apenas que cada linha foi executada pelo menos uma vez durante os testes, não que todos os comportamentos foram verificados corretamente.
Cobertura de linhas, ramificações e funções
A cobertura pode ser medida de diferentes formas. A mais comum é a cobertura de linhas (line coverage), que conta quantas das linhas de código executáveis foram exercitadas. Mais rigorosa é a cobertura de ramificações (branch coverage), que verifica se todas as condições de if/else, switch e operadores ternários foram testadas tanto para o caso verdadeiro quanto para o falso. A cobertura de ramificações é mais significativa porque garante que o comportamento do código em diferentes condições foi verificado.
Formalmente, a cobertura de linhas C_L é definida como:
C_L = \frac{|\text{Linhas exercitadas}|}{|\text{Linhas executáveis}|} \times 100\%
E a cobertura de ramificações C_B como:
C_B = \frac{|\text{Ramificações exercitadas}|}{|\text{Ramificações totais}|} \times 100\%
No contexto do aplicativo de delivery, uma meta razoável é atingir pelo menos 70–80% de cobertura de linhas na camada de domínio (que contém as regras de negócio mais importantes) e uma cobertura menor nas camadas de apresentação e infraestrutura, que são mais difíceis de testar e têm comportamento mais dependente do ambiente.
O que não medir apenas por cobertura
A cobertura de testes é um indicador de saúde, não um objetivo em si. Perseguir 100% de cobertura a qualquer custo pode levar a testes que existem apenas para aumentar o número — testes que executam o código mas não verificam nenhum comportamento significativo. Um teste que chama um método sem nenhum expect() aumenta a cobertura sem oferecer nenhum valor de qualidade.
A pergunta certa não é “quantas linhas estão cobertas?” mas sim “os comportamentos mais importantes do sistema estão verificados?”. Para o aplicativo de delivery, os comportamentos mais importantes incluem: o cálculo correto do total do carrinho com diferentes quantidades e preços, a validação correta dos dados do formulário de endereço, o tratamento adequado de falhas de rede na camada de serviços, e a navegação correta entre as telas nos fluxos de login e checkout.
Seção 13 — Estratégias de Versionamento e Rollback
A publicação de um aplicativo não é um evento único — é o começo de um ciclo contínuo de atualizações. Gerenciar versões de forma organizada é fundamental para que você possa rastrear exatamente qual código corresponde a qual versão publicada e, se necessário, reverter para uma versão anterior em caso de problema crítico.
Versionamento semântico
O versionamento semântico (SemVer) é uma convenção amplamente adotada que define o significado de cada número na versão. O formato é MAJOR.MINOR.PATCH:
O componente MAJOR é incrementado quando há mudanças incompatíveis com versões anteriores — por exemplo, quando o aplicativo passa a exigir login obrigatório onde antes era opcional, ou quando o formato dos dados armazenados localmente muda de forma que versões antigas não conseguem ler.
O componente MINOR é incrementado quando novas funcionalidades são adicionadas de forma compatível com versões anteriores — por exemplo, quando uma nova tela de histórico de pedidos é adicionada.
O componente PATCH é incrementado quando apenas correções de bugs são feitas — sem novas funcionalidades e sem mudanças incompatíveis.
Para o aplicativo de delivery, a versão 1.2.3 indicaria a primeira versão estável principal (1), com duas iterações de novas funcionalidades (2), e três correções de bugs na iteração atual (3).
No Flutter, a versão do aplicativo é configurada no pubspec.yaml:
O versionCode do Android e o CFBundleVersion do iOS são derivados automaticamente do número após o + quando você usa os comandos de build do Flutter.
Estratégia de branches para publicação
Uma estratégia de branches bem definida é o que permite publicar versões com segurança e reverter rapidamente em caso de problema. A estratégia mais comum para aplicativos Flutter é:
A branch main (ou master) contém sempre o código correspondente à última versão publicada em produção. Nenhum código vai diretamente para main — ele chega somente através de pull requests aprovados.
A branch develop é onde o desenvolvimento ativo acontece. Features e correções são desenvolvidas em branches separadas (feature/nome, fix/descricao) e integradas em develop via pull requests.
Quando o develop está estável e pronto para publicação, uma branch de release é criada (release/1.2.3), a versão no pubspec.yaml é atualizada, e o CI gera o build de release. Após a aprovação na loja e a confirmação de que está funcionando bem, o release/1.2.3 é mesclado em main e em develop.
Seção 14 — Conectando Testes, Qualidade e Publicação no Contexto do Projeto Integrador
Ao longo de todos os módulos anteriores, você e sua equipe construíram um aplicativo completo. Neste módulo final, você fecha o ciclo adicionando a camada de qualidade verificável que torna o aplicativo um produto confiável — e aprendendo o processo para entregá-lo ao mundo.
O que testar prioritariamente
Com o tempo limitado do Projeto Integrador, é importante priorizar o que testar. A regra prática é testar primeiro o que é mais difícil de verificar manualmente e o que teria maior impacto se quebrasse. Para o aplicativo de delivery, essa prioridade se traduz em:
A lógica de cálculo do carrinho: preço total, aplicação de descontos, cálculo do frete. Esses cálculos envolvem aritmética que pode ter bugs sutis (como problemas de ponto flutuante) e têm impacto financeiro direto.
A validação de formulários: regras de validação de CPF, e-mail, CEP e campos obrigatórios. Validações incorretas podem permitir que dados inválidos entrem no sistema ou bloquear usuários legítimos.
As regras de negócio do domínio: o que acontece quando um item está esgotado, o comportamento quando o pedido mínimo não é atingido, o tratamento de endereços fora da área de entrega.
O mapa a seguir resume como os diferentes tipos de teste se relacionam com as camadas da arquitetura:
graph TD
A[Testes Unitários] --> B[Camada de Domínio\nEntidades, Casos de Uso, Regras]
C[Testes de Widget] --> D[Camada de Apresentação\nWidgets, Providers]
E[Testes de Integração] --> F[Fluxos completos\nLogin, Checkout, Pedidos]
B --> G[Rápidos e baratos\nMuitos deles]
D --> H[Velocidade média\nQuantidade moderada]
F --> I[Lentos e caros\nPoucos e focados]
style A fill:#ccffcc,stroke:#00aa00
style C fill:#fff9cc,stroke:#ccaa00
style E fill:#ffcccc,stroke:#cc0000
Checklist de publicação
Antes de submeter o aplicativo para revisão na Play Store ou App Store, verifique cada item desta lista:
Código e qualidade
flutter analyzenão reporta nenhum problema?- Todos os testes unitários e de widget passam com
flutter test? - O
applicationId(Android) está correto e definitivo? - A
versionCodefoi incrementada em relação à versão anterior? - O código usa
constnos widgets onde possível? - Não há chamadas a
print()no código de produção?
Assets e metadados
- O ícone do aplicativo está em todas as resoluções necessárias?
- A splash screen foi configurada e testada?
- O título do aplicativo na ficha da loja é claro e tem no máximo 30 caracteres?
- Os screenshots foram capturados em dispositivos reais ou emuladores de alta qualidade?
- A descrição completa menciona os principais recursos sem erros de ortografia?
Segurança e conformidade
- O keystore está guardado em local seguro e não está no repositório Git?
- O arquivo
key.propertiesestá no.gitignore? - As permissões declaradas no
AndroidManifest.xmlsão apenas as que o aplicativo realmente usa? - O aplicativo não expõe chaves de API no código-fonte?
- A política de privacidade está disponível e o link foi adicionado à ficha da loja?
Testes funcionais manuais
- O fluxo de login funciona corretamente em um build de release?
- O aplicativo funciona sem conexão à internet (comportamento offline)?
- Todas as telas foram testadas em pelo menos dois tamanhos de tela diferentes?
- O aplicativo foi testado com o TalkBack ativado?
- O aplicativo funciona corretamente com o tamanho de fonte máximo do sistema?
Seção 15 — Reflexão Final: Da Ideia ao Produto
Este é o último módulo de uma disciplina que foi construída com uma filosofia específica: aprender fazendo. Cada módulo trouxe novos conhecimentos que foram imediatamente aplicados ao Projeto Integrador — não como exercícios acadêmicos desconectados, mas como incrementos reais a um produto real que você e sua equipe construíram juntos ao longo do semestre.
Olhando para o percurso completo, você partiu de zero — configurando o ambiente Flutter pela primeira vez — e chegou a um aplicativo que se comunica com um backend na nuvem, autentica usuários de forma segura, envia notificações push em tempo real, acessa câmera e GPS do dispositivo, fala múltiplos idiomas, é acessível a usuários com deficiências visuais, tem testes automatizados que verificam seu comportamento, e está pronto para ser publicado nas maiores plataformas de distribuição de software do mundo.
Esse percurso reproduz, de forma comprimida, o que acontece em equipes de desenvolvimento de produtos reais. As tecnologias específicas mudarão — versões novas do Flutter serão lançadas, pacotes serão descontinuados e substituídos, as APIs das lojas evoluirão. Mas os princípios que você aprendeu são estáveis: a separação de responsabilidades em camadas de domínio, infraestrutura e apresentação; o uso de contratos abstratos (interfaces) para permitir a troca de implementações sem afetar o restante do sistema; a verificação automatizada do comportamento por meio de testes; e a entrega incremental de valor com cada módulo do projeto.
A engenharia de software é uma disciplina que combina rigor técnico com criatividade humana. O rigor garante que o sistema funciona corretamente e pode ser mantido ao longo do tempo. A criatividade é o que transforma um conjunto de requisitos em uma experiência que as pessoas encontram valiosa e satisfatória de usar. Você exercitou os dois ao longo desta disciplina.
O próximo passo é seu.
Resumo do Módulo 15
Este módulo fechou o ciclo do desenvolvimento profissional de aplicativos móveis com dois conjuntos de habilidades indispensáveis. Do lado da qualidade, você aprendeu a filosofia dos testes automatizados e a pirâmide de testes; escreveu testes unitários com o padrão AAA usando o pacote test; criou mocks com mockito para isolar dependências; testou widgets do Flutter com flutter_test e o WidgetTester; configurou testes de integração com integration_test para verificar fluxos completos no dispositivo; e integrou a análise estática com flutter_lints no ciclo de desenvolvimento.
Do lado da publicação, você entendeu a importância do ícone, da splash screen e dos metadados da ficha da loja; criou um keystore para assinar o aplicativo Android; gerou um build de release no formato AAB para a Play Store; conheceu o processo de revisão e as estratégias de lançamento gradual da Google Play Console; e compreendeu o processo equivalente para a Apple App Store, incluindo certificados, perfis de provisionamento e o TestFlight para distribuição de versões de teste.
Com esses conhecimentos, você possui as ferramentas técnicas para construir, verificar e distribuir aplicativos móveis com a qualidade esperada no mercado profissional. O percurso de catorze módulos anteriores forneceu os blocos de construção; este módulo mostrou como colocar o último tijolo e abrir as portas.