Kodokon kodokon.com

Тесты: юнит-тесты на Dart и тесты виджетов

Тестируй логику и виджеты с помощью самописных fake, симулированных часов и правильных рефлексов вокруг pump.

11 мин · 3 вопросов

Открыть этот урок в Kodokon

Пирамида тестов во Flutter: широкое основание из юнит-тестов на чистом Dart, слой тестов виджетов, немного интеграционных тестов. Юнит-тест никогда не должен импортировать material.dart - если твоей логике это нужно, проблема архитектурная, а не техническая. Чтобы изолировать код от зависимостей, предпочитай самописные fake сгенерированным мокам: fake - это упрощённая, но по-настоящему рабочая реализация контракта (repository в памяти, управляемые часы); она переживает рефакторинги и читается как обычный код. Моки, которые проверяют взаимодействия («этот метод вызвали дважды»), привязывают тест к деталям реализации - оставь их для протоколов, где взаимодействие и есть поведение.

DART
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();
  }
}
Код под тестом: ни одного импорта material.dart.

setUp пересоздаёт объекты перед каждым тестом: никакого общего состояния, а значит, ни одного теста, который зависит от порядка выполнения - именно это условие делает их надёжными. Составные матчеры (isA<Exception>(), throwsA, isNull) дают точные сообщения об ошибках, куда полезнее простого булева значения. Обрати внимание на второй тест ниже: он проверяет сбой, а затем восстановление сразу после него. Пути ошибок - такой же код, как любой другой: если они не покрыты тестами, считай их сломанными.

DART
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);
  });
}
Fake на пятнадцать строк заменяет целую библиотеку моков.

Тест виджета ничего не выводит на экран: он выполняется в специальном биндинге (AutomatedTestWidgetsFlutterBinding), где время симулировано и где каждый кадр производится по запросу. pumpWidget монтирует дерево; pump() продвигает фальшивые часы и производит один кадр; pump(const Duration(milliseconds: 150)) прыгает во времени - идеально, чтобы заморозить промежуточное состояние анимации и рассмотреть его. Finder-ы (find.text, find.byType, find.byKey) опрашивают настоящее дерево элементов Element, а не скриншот. Поскольку по-настоящему асинхронного здесь нет ничего - ни часов, ни сети - такие тесты детерминированы: сбой всегда воспроизводим.

DART
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);
  });
}
tap не запускает кадр: следующий за ним pump обязателен.
BASH
flutter test
flutter test test/score_controller_test.dart
flutter test --coverage
flutter test --update-goldens
Указать файл, измерить покрытие, перегенерировать golden-снимки.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Почему pumpAndSettle падает на анимации, запущенной через repeat()?
    • Он производит кадры, пока они остаются запланированными: бесконечная анимация не стабилизируется никогда, отсюда таймаут
    • repeat() не поддерживается тестовым биндингом
    • pumpAndSettle работает только с неявными анимациями
  2. В чём разница между fake и моком?
    • Fake - упрощённая, но рабочая реализация; мок нужен в основном для проверки взаимодействий
    • Мок пишут вручную, а fake генерирует инструмент
    • Никакой: это синонимы
    • Fake не может реализовать интерфейс Dart
  3. Что именно делает await tester.pump() в тесте виджета?
    • Он продвигает симулированные часы и производит ровно один новый кадр
    • Он ждёт завершения всех идущих анимаций
    • Он перезапускает тест с начала
    • Он ждёт завершения реальных сетевых запросов