Aula 25 · Semana 11 · Segunda, 16 de novembro de 2026 · 16:00 às 18:00
Teoria: pirâmide de testes, o que vale a pena testar em mobile, mocks e fakes, golden tests e integração contínua. Prática: testes unitários com mocktail, testes de widget, e um workflow do GitHub Actions que bloqueia pull requests com falhas.
mocktail e testar um notifier sem rede.pumpWidget, find, tap e pumpAndSettle.flutter analyze e flutter test em cada PR e proteger o main.| Nível | O que verifica | Quantos | Velocidade | Custo de manutenção |
|---|---|---|---|---|
| Unitário | Uma função ou classe Dart, sem Flutter: fromJson, cálculos, o notifier | Muitos | Milissegundos | Baixo |
| Widget | Um widget ou ecrã numa árvore de teste, sem telemóvel: aparece o texto, o botão faz o que deve | Alguns | Décimos de segundo | Médio: parte quando muda a UI |
| Integração / E2E | A app inteira num telemóvel ou emulador, com rede real ou simulada | Poucos | Minutos | Alto: frágil, lento, precisa de dispositivo |
Para os testes: muitos unitários, alguns de widget, poucos de integração; quanto mais alto na pirâmide, mais lento e mais caro.
fromJson com JSON real da API, com campos em falta, com null, com tipos errados. É onde a app crasha em produção quando o backend muda.Loading para Dados; para Erro quando o repository falha; para Vazio com lista vazia. Sem UI, sem rede.Text tem a cor do tema, testar getters triviais, testar o package http, ou escrever E2E para cada ecrã. Testa o teu código, não o do Flutter.Para os testes: em mobile testa-se primeiro o parsing e a lógica de estado, porque é o que falha quando a API muda ou a rede cai.
PrevisaoRepository. No teste, injetas outra implementação. Foi para isto que fizemos a interface na aula 13.mocktail: class MockRepo extends Mock implements PrevisaoRepository {}; programas respostas com when(() => repo.previsao(any())).thenAnswer(...) e verificas chamadas com verify.mockito com build_runner). Por isso o usamos.Para os testes: um fake é uma implementação simplificada escrita à mão; um mock é programado no próprio teste e regista as chamadas.
matchesGoldenFile('previsao_card.png')).find.text apanha: padding que mudou, cor errada no tema escuro, texto cortado com letra grande.flutter test --update-goldens). Sem revisão, as imagens de referência são atualizadas sem verificação.Para os testes: golden tests comparam pixels com uma imagem de referência; são valiosos para componentes visuais e frágeis entre máquinas diferentes.
flutter analyze e flutter test em cada push e em cada PR. Se falha, o PR fica vermelho e não pode entrar em main.subosito/flutter-action para instalar o Flutter no runner.runs-on: macos-latest custa 10 vezes mais minutos. Alternativas feitas para mobile: Codemagic e Bitrise, com máquinas Mac, assinatura e upload às lojas incluídos.flutter analyze avisa no teu portátil, avisa no CI.Para os testes: CI corre analyze e test em cada PR; builds iOS exigem um runner macOS, que o GitHub cobra mais caro e o Codemagic inclui.
git clone https://github.com/pdm-daw-2026/pdm-aula-25.git
cd pdm-aula-25/starter
flutter pub get
flutter run
O starter/ tem a app da aula 14 (estados de UI com sealed class) com TODOs numerados e uma pasta test/ com testes a falhar. O solucao/ tem o estado final, incluindo o workflow do Actions. Corre flutter test agora e conta os vermelhos: são os teus objetivos.
Quatro blocos: teste unitário do modelo, teste do notifier com fake e com mock, teste de widget, e CI.
pubspec.yaml que mocktail está em dev_dependencies. Se não, flutter pub add dev:mocktail.import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:tempo_acores/models/previsao.dart';
const amostra = '''
{"locationId": "3420300", "forecastDate": "2026-11-16",
"tMin": 14.2, "tMax": 19.8, "weatherType": "rain", "precipProb": 65}
''';
void main() {
group('Previsao.fromJson', () {
test('lê os campos do JSON v1 da API', () {
final p = Previsao.fromJson(jsonDecode(amostra) as Map<String, dynamic>);
expect(p.localidadeId, '3420300');
expect(p.dia, DateTime(2026, 11, 16));
expect(p.tipo, TipoTempo.chuva);
});
test('calcula a amplitude térmica', () {
final p = Previsao.fromJson(jsonDecode(amostra) as Map<String, dynamic>);
expect(p.amplitude, closeTo(5.6, 0.001));
});
});
}
test/ que termina em _test.dart, com um main(). flutter test encontra-os todos; flutter test test/models corre só uma pasta.group agrupa, test é um caso, expect(valor, matcher) verifica. Matchers úteis: equals, isA<T>(), closeTo, isNull, throwsA, contains.double com ==: 19.8 - 14.2 não é exatamente 5.6. Usa closeTo.tMax no mapa, Previsao.fromJson lança FormatException com uma mensagem útil (expect(() => Previsao.fromJson(semTMax), throwsA(isA<FormatException>())); TODO 1 no modelo). Depois um quarto: weatherType desconhecido cai em TipoTempo.desconhecido, sem lançar.import 'package:flutter_test/flutter_test.dart';
import 'package:tempo_acores/data/previsao_repository.dart';
import 'package:tempo_acores/models/previsao.dart';
import 'package:tempo_acores/state/previsao_notifier.dart';
class FakePrevisaoRepository implements PrevisaoRepository {
FakePrevisaoRepository({this.resposta = const [], this.erro});
final List<Previsao> resposta;
final Object? erro;
@override
Future<List<Previsao>> previsao(String localidadeId) async {
if (erro != null) throw erro!;
return resposta;
}
}
final previsaoTeste = Previsao(
localidadeId: '3420300', dia: DateTime(2026, 11, 16),
tMin: 14.2, tMax: 19.8, tipo: TipoTempo.chuva,
);
void main() {
test('loading → dados quando o repository responde', () async {
final notifier = PrevisaoNotifier(FakePrevisaoRepository(resposta: [previsaoTeste]));
final estados = <PrevisaoEstado>[];
notifier.addListener(() => estados.add(notifier.estado));
await notifier.carregar('3420300');
expect(estados.first, isA<Loading>());
expect(estados.last, isA<Dados>());
expect((estados.last as Dados).lista, hasLength(1));
});
test('loading → vazio quando a lista vem vazia', () async {
final notifier = PrevisaoNotifier(FakePrevisaoRepository());
await notifier.carregar('3420300');
expect(notifier.estado, isA<Vazio>());
});
}
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:tempo_acores/data/api_exception.dart';
import 'package:tempo_acores/data/previsao_repository.dart';
import 'package:tempo_acores/state/previsao_notifier.dart';
class MockPrevisaoRepository extends Mock implements PrevisaoRepository {}
void main() {
late MockPrevisaoRepository repo;
late PrevisaoNotifier notifier;
setUp(() {
repo = MockPrevisaoRepository();
notifier = PrevisaoNotifier(repo);
});
test('loading → erro quando a API falha', () async {
when(() => repo.previsao(any()))
.thenAnswer((_) async => throw ApiException('Sem ligação', podeRepetir: true));
await notifier.carregar('3420300');
expect(notifier.estado, isA<Erro>().having((e) => e.mensagem, 'mensagem', 'Sem ligação'));
verify(() => repo.previsao('3420300')).called(1);
});
}
addListener deixa-te afirmar a sequência (primeiro Loading, depois Dados), não só o fim.when(...).thenAnswer((_) async => ...) para métodos assíncronos; thenThrow lança de forma síncrona e pode fugir ao teu try. any() aceita qualquer argumento.verify(...).called(1) apanha o notifier a chamar a API duas vezes por engano. verifyNever apanha o contrário.carregar chamado duas vezes seguidas não deixa o estado em Loading no fim. Dica: verify(...).called(2) e expect(notifier.estado, isNot(isA<Loading>())).import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:provider/provider.dart';
import 'package:tempo_acores/state/previsao_notifier.dart';
import 'package:tempo_acores/ui/previsao_screen.dart';
import 'package:tempo_acores/ui/widgets/previsao_card.dart';
import '../helpers.dart'; // MockPrevisaoRepository, previsaoTeste, ApiException
Widget appDeTeste(MockPrevisaoRepository repo) => ChangeNotifierProvider(
create: (_) => PrevisaoNotifier(repo)..carregar('3420300'),
child: const MaterialApp(home: PrevisaoScreen(localidadeId: '3420300')),
);
void main() {
testWidgets('mostra a mensagem de erro e o botão Tentar de novo', (tester) async {
final repo = MockPrevisaoRepository();
when(() => repo.previsao(any()))
.thenAnswer((_) async => throw ApiException('Sem ligação', podeRepetir: true));
await tester.pumpWidget(appDeTeste(repo));
await tester.pumpAndSettle();
expect(find.text('Sem ligação'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'Tentar de novo'), findsOneWidget);
});
}
testWidgets('Tentar de novo volta a carregar e mostra a lista', (tester) async {
final repo = MockPrevisaoRepository();
when(() => repo.previsao(any()))
.thenAnswer((_) async => throw ApiException('Sem ligação', podeRepetir: true));
await tester.pumpWidget(appDeTeste(repo));
await tester.pumpAndSettle();
// A partir daqui a API "recupera"
when(() => repo.previsao(any())).thenAnswer((_) async => [previsaoTeste]);
await tester.tap(find.text('Tentar de novo'));
await tester.pumpAndSettle(); // até não haver mais frames agendados
expect(find.text('Sem ligação'), findsNothing);
expect(find.byType(PrevisaoCard), findsOneWidget);
verify(() => repo.previsao('3420300')).called(2);
});
pumpWidget monta a árvore; precisa de um MaterialApp à volta, senão não há tema, direção de texto nem Navigator.Provider entra no teste como na app: o ecrã não sabe que o repository é um mock.pump() avança um frame; pumpAndSettle() avança até a árvore estabilizar. Se o ecrã tem uma animação infinita (o spinner) e o estado nunca muda, pumpAndSettle falha por timeout: usa pump(const Duration(seconds: 1)).find.text, find.byType, find.byKey, find.widgetWithText. Para elementos repetidos, dá-lhes uma Key.Loading, faz o mock devolver um Completer<List<Previsao>>().future que nunca completas, dá um pump() e procura o CircularProgressIndicator. Se deixasses o mock responder logo, o loading passava entre dois frames e não o apanhavas.previsaoTeste vivem em test/helpers.dart, partilhados por todos os testes. Um teste de widget corre no portátil, sem emulador, em décimos de segundo.Vazio: com lista vazia aparece "Sem previsões para esta localidade" e não aparece nenhum PrevisaoCard.name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verificar:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter --version
- run: flutter pub get
- run: dart format --output=none --set-exit-if-changed lib test
- run: flutter analyze --fatal-infos
- run: flutter test --reporter expanded
main e mostrar o badgeverificar a correr; a primeira execução demora 3 a 4 minutos (depois a cache acelera).main: liga "Require a pull request before merging" e "Require status checks to pass", e escolhe o check verificar. A partir daqui um PR vermelho não tem botão de merge.
Substitui pelo nome do vosso repo. Verde no README é a definição de pronto visível a quem passa.fromJson, faz push, vê o PR ficar vermelho, repõe. Agora sabes o que o CI faz por ti.main e põe o badge no README. Um membro do grupo abre um PR de teste e outro confirma que o merge fica bloqueado com o CI vermelho.A app do projeto tem quatro estados em cada ecrã (aula 14). Cada estado merece um teste de widget que prova que a UI faz o que o design diz.
| Estado | O teste afirma |
|---|---|
Loading | Existe um CircularProgressIndicator ou skeleton e não existe lista |
Erro | Aparece a mensagem humana e o botão "Tentar de novo"; o tap chama o repository outra vez |
Vazio | Aparece o texto de vazio com a ação sugerida |
Dados | Aparecem tantos cartões quantas previsões; o primeiro tem a temperatura certa |
fromJson de cada modelo. Com o CI a correr, é isto que dá o "CI verde com pelo menos cinco testes" pedido para quarta.fromJson dos modelos e um teste por estado de UI.main protegido: PR obrigatório e check verificar obrigatório. Badge no README.pumpWidget, finders, tap e pumpAndSettle passo a passo.when, verify, registerFallbackValue.