Flutter: Testes — Unitários / Widget / Integração
Código sem testes é uma bomba-relógio — você não sabe quando vai explodir, apenas que vai.
📋 Pré-requisitos: Você já deve estar familiarizado com
- Aula 5: StatefulWidget e Interação
- Aula 12: Gerenciamento de Estado — Riverpod
1. O Que Você Vai Aprender
- Testes unitários: pacote test + mocking com mocktail; testando lógica de Notifier / Repository
- Testes de Widget: testWidgets + Finder + pumpWidget / pumpAndSettle
- Testes de integração: pacote integration_test + testes end-to-end em dispositivo real
- Golden tests: matchesGoldenFile para detecção de regressão de UI
- ShopApp: testes unitários do CartNotifier + testes de Widget da lista de produtos + testes de integração do checkout
2. Uma História Real de um Bug em Produção
(1) O Problema: Testes Manuais Não Cobrem Tudo
O ShopApp do Bob lançou uma funcionalidade de cupom. O QA testou manualmente o "caminho feliz" e passou, mas perdeu o caso extremo de "usar um cupom expirado." Após o lançamento, 500 usuários fizeram pedidos com cupons expirados, resultando em valores negativos de pedidos e erros no banco de dados. A correção levou 4 horas, custando 10 mil USD. Problemas similares aconteciam pelo menos duas vezes por mês.
(2) A Solução com Testes Automatizados
Testes automatizados rodam em cada commit de código, cobrindo todos os casos extremos e garantindo que código novo não quebre funcionalidade existente.
import 'package:flutter_test/flutter_test.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// CartNotifier / Coupon / Product from ShopApp project, full definition in Lesson 27
class Coupon {
final String code;
final DateTime expiresAt;
const Coupon({required this.code, required this.expiresAt});
}
test('coupon expired should show error', () {
final cart = CartNotifier();
cart.addItem(product);
final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
expect(result, isFalse);
expect(cart.error, contains('expired'));
});
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
(3) Benefício: Taxa de Bugs Cai 80%
Após Bob estabelecer um sistema de testes, 200+ testes rodam automaticamente em cada commit, e a taxa de bugs em produção caiu de 5/mês para 1/mês.
3. Pirâmide de Testes
graph TD
TP[Testing Pyramid] --> UT[Unit Tests: 70%]
TP --> WT[Widget Tests: 20%]
TP --> IT[Integration Tests: 10%]
UT --> |mocktail| CN3[CartNotifier]
WT --> |testWidgets| PV3[ProductListView]
IT --> |integration_test| CK2[Checkout Flow]
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
| Camada | Proporção | Velocidade | Dependências | Escopo de Teste |
|---|---|---|---|---|
| Testes Unitários | 70% | Milissegundos | Mock | Lógica pura (Notifier/Repository) |
| Testes de Widget | 20% | Segundos | Ambiente de teste | Renderização de UI + interação |
| Testes de Integração | 10% | Minutos | Dispositivo/emulador real | Fluxos completos do usuário |
4. Testes Unitários
(1) Configuração do Projeto
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
build_runner: ^2.4.0
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Testes unitários do CartNotifier
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// ProductRepository / CartNotifier from ShopApp project, full definition in Lesson 27
// Simplified Product (full definition in Lesson 26 domain/entities/product.dart)
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified CartNotifier (full definition in Lesson 27)
class CartNotifier {
List<_CartItem> state = [];
String? error;
void addItem(Product product) {
final idx = state.indexWhere((i) => i.product.id == product.id);
if (idx >= 0) {
state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
} else {
state = [...state, _CartItem(product: product, quantity: 1)];
}
}
void updateQuantity(int productId, int quantity) {
if (quantity <= 0) { removeItem(productId); return; }
state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
}
void removeItem(int productId) {
state = state.where((i) => i.product.id != productId).toList();
}
void clear() { state = []; }
double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}
class _CartItem {
final Product product;
final int quantity;
_CartItem({required this.product, required this.quantity});
}
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
group('CartNotifier', () {
late CartNotifier cart;
setUp(() {
cart = CartNotifier();
});
test('initial state is empty', () {
expect(cart.state, isEmpty);
});
test('addItem adds product to cart', () {
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.product, testProduct);
expect(cart.state.first.quantity, 1);
});
test('addItem increments quantity if product exists', () {
cart.addItem(testProduct);
cart.addItem(testProduct);
expect(cart.state.length, 1);
expect(cart.state.first.quantity, 2);
});
test('updateQuantity updates item quantity', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 5);
expect(cart.state.first.quantity, 5);
});
test('updateQuantity removes item when quantity is 0', () {
cart.addItem(testProduct);
cart.updateQuantity(testProduct.id, 0);
expect(cart.state, isEmpty);
});
test('removeItem removes product from cart', () {
cart.addItem(testProduct);
cart.removeItem(testProduct.id);
expect(cart.state, isEmpty);
});
test('clear empties the cart', () {
cart.addItem(testProduct);
cart.addItem(anotherProduct);
cart.clear();
expect(cart.state, isEmpty);
});
test('total calculates correct sum', () {
cart.addItem(testProduct); // price: 99.99
cart.addItem(anotherProduct); // price: 49.99
expect(cart.total, closeTo(149.98, 0.01));
});
});
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Testes unitários do Repository (com Mock)
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';
// ⚙️ Dependencies: mocktail: ^1.0.0, dio: ^5.0.0 (add to dev_dependencies / dependencies)
// ProductRepository / DioClient from ShopApp project, full definition in Lesson 27
class MockDioClient extends Mock implements DioClient {}
void main() {
group('ProductRepository', () {
late ProductRepository repo;
late MockDioClient mockClient;
setUp(() {
mockClient = MockDioClient();
repo = ProductRepository(mockClient);
});
test('getProducts returns list of products', () async {
// Arrange
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenAnswer((_) async => Response(
data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
statusCode: 200, requestOptions: RequestOptions(path: '/products'),
));
// Act
final products = await repo.getProducts();
// Assert
expect(products.length, 1);
expect(products.first.name, 'Laptop');
verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
});
test('getProducts throws on network error', () async {
when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
.thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));
expect(() => repo.getProducts(), throwsA(isA<DioException>()));
});
});
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
5. Testes de Widget
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Teste de Widget do ProductCard
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Product / ProductCard from ShopApp project, full definition in Lessons 26/27
// Simplified Product
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final String category;
const Product({required this.id, required this.name, required this.price,
required this.imageUrl, this.category = 'General'});
}
// Simplified ProductCard
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onAddToCart;
const ProductCard({super.key, required this.product, required this.onAddToCart});
@override
Widget build(BuildContext context) {
return Card(
child: Column(children: [
Text(product.name),
Text('\$${product.price.toStringAsFixed(2)}'),
if (product.price < 50) const Text('SALE'),
IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
]),
);
}
}
const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
void main() {
group('ProductCard', () {
testWidgets('displays product name and price', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () {},
)),
));
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('\$99.99'), findsOneWidget);
});
testWidgets('calls onAddToCart when button tapped', (tester) async {
var tapped = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: testProduct,
onAddToCart: () => tapped = true,
)),
));
await tester.tap(find.byIcon(Icons.add_shopping_cart));
expect(tapped, isTrue);
});
testWidgets('shows SALE badge when price < 50', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ProductCard(
product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
onAddToCart: () {},
)),
));
expect(find.text('SALE'), findsOneWidget);
});
});
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Teste de Widget da página do carrinho
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ⚙️ Dependency: flutter_riverpod: ^2.0.0 (add to dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier from ShopApp project
void main() {
group('CartPage', () {
testWidgets('shows empty state when cart is empty', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
expect(find.text('Cart is empty'), findsOneWidget);
});
testWidgets('shows items and total when cart has items', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
expect(find.text('Laptop'), findsOneWidget);
expect(find.text('Mouse'), findsOneWidget);
expect(find.textContaining('\$149.98'), findsOneWidget);
});
testWidgets('increments quantity on plus tap', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
child: const MaterialApp(home: CartPage()),
));
await tester.pumpAndSettle();
// Find first increment button and tap
await tester.tap(find.byIcon(Icons.add).first);
await tester.pumpAndSettle();
// Verify quantity changed
expect(find.text('2'), findsOneWidget);
});
});
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
6. Testes de Integração
▶ Exemplo
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
: Teste end-to-end do fluxo de checkout
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';
// ⚙️ Dependency: integration_test SDK (add to dev_dependencies)
// app (ShopApp main entry) from project lib/main.dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Checkout Flow', () {
testWidgets('complete purchase end-to-end', (tester) async {
app.main();
await tester.pumpAndSettle();
// 1. Browse products
expect(find.text('ShopApp'), findsOneWidget);
// 2. Add product to cart
await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
await tester.pumpAndSettle();
// 3. Navigate to cart
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
// 4. Verify cart has item
expect(find.textContaining('\$'), findsWidgets);
// 5. Tap checkout
await tester.tap(find.text('Checkout'));
await tester.pumpAndSettle();
// 6. Fill checkout form
await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
await tester.pumpAndSettle();
// 7. Submit order
await tester.tap(find.textContaining('Pay'));
await tester.pumpAndSettle();
// 8. Verify order confirmation
expect(find.text('Order Confirmed'), findsOneWidget);
});
});
}
> Saída: Execute localmente com o Flutter SDK (Flutter 3.x / Dart 3.x). O servidor Piston não possui o Flutter instalado; use `flutter run` na sua máquina local para comparação. A UI/estado real pode variar ligeiramente entre plataformas.
7. Melhores Práticas de Teste
| Prática | Descrição |
|---|---|
| Padrão AAA | Arrange → Act → Assert |
| Testes independentes | setUp/setUpEach para inicialização, sem dependências entre testes |
| Mock dependências externas | Substitua rede/banco/APIs de plataforma por Mocks |
| Nomes descritivos de teste | test('addItem increments quantity when product exists') |
| Usar pumpAndSettle em testes de Widget | Esperar que todas as animações e operações assíncronas se completem |
❓ Perguntas Frequentes
P: Como distinguir testes unitários de testes de Widget? R: Testes unitários não renderizam UI — testam apenas lógica; testes de Widget renderizam Widgets em ambiente de teste e verificam UI e interação.
P: Qual a diferença entre mocktail e mockito? R: mocktail é a versão Dart-nativa do mockito — não precisa de geração de código e tem API mais simples. mocktail é recomendado.
P: Qual a diferença entre pump e pumpAndSettle? R: pump executa um frame; pumpAndSettle executa todos os frames até não haver novos (animações completas/async terminado).
P: Como rodar testes de integração em CI? R: Use
flutter test integration_test/em um emulador Android. iOS requer um runner macOS.
P: Como manter Golden tests? R: A primeira execução gera arquivos golden; execuções subsequentes comparam contra eles. Atualize com
--update-goldensquando a UI mudar. Cuidado com diferenças de renderização entre plataformas.
P: Como sobrescrever Riverpod Providers em testes? R: Use
ProviderScope(overrides: [myProvider.overrideWith(...)])para substituir o Provider real por uma versão de teste.
📖 Resumo
- Pirâmide de testes: 70% unitários + 20% Widget + 10% integração
- Testes unitários usam mocktail para Mock dependências externas, organizados com padrão AAA
- Testes de Widget usam testWidgets + Finder para verificar UI e interação
- Testes de integração fornecem verificação end-to-end de fluxos completos do usuário
- Sobrescritas de Riverpod Provider substituem dependências nos testes
📝 Exercícios
- Básico (dificuldade ⭐): Escreva 5 testes unitários para CartNotifier: inicial vazio, adicionar, adicionar duplicado, remover, limpar.
- Intermediário (dificuldade ⭐⭐): Escreva testes de Widget para ProductCard: exibe nome/preço do produto, tocar em adicionar ao carrinho dispara callback, exibe badge SALE.
- Desafio (dificuldade ⭐⭐⭐): Implemente uma suíte de testes completa: testes unitários do CartNotifier (8+ casos) + testes de Widget do CartPage (5+ casos) + teste de integração de checkout (fluxo completo do usuário).