PDM · DAW 2

Aula 25 · Semana 11 · Segunda, 16 de novembro de 2026 · 16:00 às 18:00

Testes automatizados e CI

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.

Repo: pdm-aula-25

No fim desta aula consegues

A pirâmide de testes

Integração / E2E poucos · lentos · telemóvel real Widget alguns · pumpWidget, sem telemóvel Unitários muitos · rápidos · sem rede · sem Flutter O que cada nível apanha no Tempo Açores A app não arranca no Android 12 integration_test · emulador ou telemóvel O botão de atualizar desapareceu pumpWidget · tester.tap · find.text fromJson errado, estado do notifier sem rede: fakes e mocks, em milissegundos CI: flutter analyze + flutter test em cada pull request GitHub Actions; com o main protegido, um teste a falhar bloqueia o merge
Muitos unitários, alguns de widget, poucos de integração: quanto mais alto, mais lento e mais caro.
NívelO que verificaQuantosVelocidadeCusto de manutenção
UnitárioUma função ou classe Dart, sem Flutter: fromJson, cálculos, o notifierMuitosMilissegundosBaixo
WidgetUm widget ou ecrã numa árvore de teste, sem telemóvel: aparece o texto, o botão faz o que deveAlgunsDécimos de segundoMédio: parte quando muda a UI
Integração / E2EA app inteira num telemóvel ou emulador, com rede real ou simuladaPoucosMinutosAlto: 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.

Analogia O controlo de qualidade numa fábrica de carros. Testar cada parafuso à saída da máquina (unitário) é rápido, barato e apanha quase tudo. Montar o painel e carregar em cada botão (widget) apanha os problemas de encaixe. Conduzir o carro completo na estrada (integração) é o teste mais real e o mais caro, por isso faz-se poucas vezes e no fim.

O que testar em mobile

O que não vale a pena Testar que um 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.

Mocks e fakes: testar sem rede

Para os testes: um fake é uma implementação simplificada escrita à mão; um mock é programado no próprio teste e regista as chamadas.

Golden tests

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.

Integração contínua

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.

Prática: testes e CI no Tempo Açores

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.

Antes de começar Confirma no pubspec.yaml que mocktail está em dev_dependencies. Se não, flutter pub add dev:mocktail.

1. Teste unitário do modelo

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

1. Teste unitário: o que interessa

Exercício 1 Faz passar os dois testes acima e escreve o terceiro: sem 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.

2. Teste do notifier com um fake (1 de 2)

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,
);

2. Teste do notifier com um fake (2 de 2)

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

2. O mesmo com mocktail: loading → erro

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

2. Notifier: o que interessa

Exercício 2 TODO 2: faz passar os três testes. Depois escreve o quarto: 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>())).

3. Teste de widget (1 de 2)

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

3. Teste de widget (2 de 2): o tap

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

3. Teste de widget: o que interessa

Exercício 3 TODO 3: faz passar os dois testes. Depois escreve o do estado Vazio: com lista vazia aparece "Sem previsões para esta localidade" e não aparece nenhum PrevisaoCard.

4. GitHub Actions: o workflow

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

4. CI: proteger o main e mostrar o badge

  1. Commit do workflow num branch, abre o PR. No separador Checks vês o job verificar a correr; a primeira execução demora 3 a 4 minutos (depois a cache acelera).
  2. Settings → Branches → Add branch ruleset para 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.
  3. Badge no README:
    ![CI](https://github.com/pdm-daw-2026/tempo-acores-grupo-01/actions/workflows/ci.yml/badge.svg)
    Substitui pelo nome do vosso repo. Verde no README é a definição de pronto visível a quem passa.
  4. Parte-o de propósito: comenta uma linha do fromJson, faz push, vê o PR ficar vermelho, repõe. Agora sabes o que o CI faz por ti.
Exercício 4 TODO 4: cria o workflow no vosso repositório do projeto (não no da aula), protege o 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.

Exercício final: um teste por estado

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.

EstadoO teste afirma
LoadingExiste um CircularProgressIndicator ou skeleton e não existe lista
ErroAparece a mensagem humana e o botão "Tentar de novo"; o tap chama o repository outra vez
VazioAparece o texto de vazio com a ação sugerida
DadosAparecem tantos cartões quantas previsões; o primeiro tem a temperatura certa
Exercício 5 No repositório do projeto: um teste de widget por estado para o ecrã de previsão, mais o teste do fromJson de cada modelo. Com o CI a correr, é isto que dá o "CI verde com pelo menos cinco testes" pedido para quarta.

Até quarta

Para fazer

  • CI verde no repositório do projeto com pelo menos cinco testes: os fromJson dos modelos e um teste por estado de UI.
  • main protegido: PR obrigatório e check verificar obrigatório. Badge no README.
  • Quarta é a review da sprint 2: demo de 5 minutos preparada, com o CI verde como parte do que mostram.

Para ler