Testea la lógica y los widgets con fakes escritos a mano, un reloj simulado y los reflejos correctos de pump.
Abrir esta lección en KodokonLa pirámide de tests de Flutter: una amplia base de tests unitarios en Dart puro, una capa de tests de widgets, unos pocos tests de integración. Un test unitario nunca debería importar material.dart: si tu lógica lo necesita, el problema es de arquitectura, no técnico. Para aislar el código de sus dependencias, prefiere los fakes escritos a mano frente a los mocks generados: un fake es una implementación simplificada pero realmente funcional del contrato (repository en memoria, reloj controlable); sobrevive a las refactorizaciones y se lee como código normal. Los mocks, que verifican interacciones ("este método se llamó dos veces"), acoplan el test a los detalles de implementación: resérvalos para protocolos donde la interacción es el comportamiento.
import 'package:flutter/foundation.dart';
abstract interface class ScoreRepository {
Future<int> fetchBest();
}
class ScoreController extends ChangeNotifier {
ScoreController(this._repository);
final ScoreRepository _repository;
int? best;
Object? error;
Future<void> load() async {
try {
best = await _repository.fetchBest();
error = null;
} on Exception catch (e) {
error = e;
}
notifyListeners();
}
}setUp recrea los objetos antes de cada test: sin estado compartido, así que ningún test depende del orden de ejecución, esa es la condición de su fiabilidad. Los matchers componibles (isA<Exception>(), throwsA, isNull) producen mensajes de fallo precisos, mucho más útiles que un booleano. Fíjate en el segundo test de abajo: verifica el fallo y luego la recuperación justo después. Los caminos de error son código como cualquier otro: si no se testean, dalos por rotos.
import 'package:flutter_test/flutter_test.dart';
class FakeScoreRepository implements ScoreRepository {
int calls = 0;
bool failNextCall = false;
@override
Future<int> fetchBest() async {
calls++;
if (failNextCall) {
failNextCall = false;
throw Exception('network unavailable');
}
return 42;
}
}
void main() {
late FakeScoreRepository repository;
late ScoreController controller;
setUp(() {
repository = FakeScoreRepository();
controller = ScoreController(repository);
});
test('exposes the best score', () async {
await controller.load();
expect(controller.best, 42);
expect(controller.error, isNull);
expect(repository.calls, 1);
});
test('captures an error then recovers', () async {
repository.failNextCall = true;
await controller.load();
expect(controller.error, isA<Exception>());
await controller.load();
expect(controller.best, 42);
expect(controller.error, isNull);
});
test('notifies its listeners', () async {
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(notifications, 1);
});
}Un test de widget no renderiza nada en la pantalla: se ejecuta en un binding especializado (AutomatedTestWidgetsFlutterBinding) donde el tiempo está simulado y donde cada frame se produce bajo demanda. pumpWidget monta el árbol; pump() avanza el reloj falso y produce un frame; pump(const Duration(milliseconds: 150)) da un salto en el tiempo, ideal para congelar un estado intermedio de animación e inspeccionarlo. Los Finder (find.text, find.byType, find.byKey) consultan el árbol real de Elements, no una captura de pantalla. Como nada es verdaderamente asíncrono, ni el reloj ni la red, estos tests son deterministas: un fallo siempre es reproducible.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class BadgeScreen extends StatefulWidget {
const BadgeScreen({super.key});
@override
State<BadgeScreen> createState() => _BadgeScreenState();
}
class _BadgeScreenState extends State<BadgeScreen> {
bool _visible = true;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (_visible) const Text('New'),
IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
setState(() => _visible = false),
),
],
),
);
}
}
void main() {
testWidgets('hides the badge after the tap',
(tester) async {
await tester.pumpWidget(
const MaterialApp(home: BadgeScreen()),
);
expect(find.text('New'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close));
await tester.pump();
expect(find.text('New'), findsNothing);
});
}flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens