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

1. O Que Você Vai Aprender


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.

DART
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'));
});
TEXT
> 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

100%
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]
TEXT
> 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

YAML
# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.0
  build_runner: ^2.4.0

▶ Exemplo

TEXT
> 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

DART
// 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');
TEXT
> 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

TEXT
> 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)

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

TEXT
> 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

DART
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);
    });
  });
}
TEXT
> 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

TEXT
> 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

DART
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);
    });
  });
}
TEXT
> 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

TEXT
> 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

DART
// 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);
    });
  });
}
TEXT
> 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-goldens quando 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


📝 Exercícios

  1. Básico (dificuldade ⭐): Escreva 5 testes unitários para CartNotifier: inicial vazio, adicionar, adicionar duplicado, remover, limpar.
  2. 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.
  3. 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).

← Aula Anterior | Próxima Aula →

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%