Привет, хабровчане! Меня зовут Александр и я Flutter-разработчик. В этой статье хочу рассказать о том как я подружил ИИ-агентов с интеграционными тестами Flutter, какой инструмент пришлось для этого написать и что вообще из этого вышло. Летс гоу.

Проблема

Представьте, что вы попросили агента написать для вас интеграционный тест. На моих проектах очень часто это выглядело следующим образом:

  1. Агент изучает код

  2. Пишет тест

  3. Запускает flutter test

  4. Тест не проходит

  5. Агент пытается понять в чем дело, делает фикс

  6. Переходит к пункту 3

И таких итераций может быть много. Каждая из них это сжигание токенов, контекстного окна, времени на очередное "я нашел в чем проблема, сейчас точно заработает" и времени на пересборку. По моему личному опыту, на такой цикл может потратиться и 15 и 20 минут или он вообще может закончиться без успешного результата, с забитым контекстым окном и несколькими саммарайзами.

Таким образом определились следующие узкие места при разработке интеграционных тестов:

  • Сжигание токенов и контекстного окна на чтение всех логов

  • Время на пересборку

  • Непонимание только по логам на каком этапе теста возникла проблема

  • Время и токены на исправление несуществующих проблем и создание новых

  • Отсутствие у агента визуального представления того что на экране

Все это приводит к дополнительным итерациям и увеличению времени каждой из них. В ходе решения этой дилеммы, я написал инструмент который покрывает все эти проблемы.

Решение

Testwire - это утилита для пошагового исполнения интеграционных тестов. Тест разбивается на логические шаги, которые имеют состояния: не выполнен, выполнен, выполняется, выполнен с ошибкой. Агент запускает тест, подключается по MCP к тесту через VM Service и контролирует его выполнение.

Как это выглядит с точки зрения кода (о том почему тест пишется в виде отдельного класса чуть позже):

class MyTest extends TestwireTest {
  MyTest() : super(
    'Submit feedback form',
    setUp: (tester) async {
      app.main();
      await tester.pumpAndSettle();
    },
  );

  @override
  Future<void> body(WidgetTester tester) async {
    await step(
      description: 'Navigate to Leave Review',
      context: 'Tap the "Leave Review" tile on the home screen.',
      action: () async {
        await tester.tap(find.byKey(const Key('leave_review_tile')));
        await tester.pumpAndSettle();
      },
    );

    await step(
      description: 'Enter name',
      context: 'Type "Alex" into the name field.',
      action: () async {
        await tester.enterText(
          find.byKey(const Key('name_field')), 'Alex');
        await tester.pumpAndSettle();
      },
    );

    await step(
      description: 'Tap 5-star rating',
      context: 'Tap the 5th star to set rating to 5.',
      action: () async {
        await tester.tap(find.byKey(const Key('star_5')));
        await tester.pumpAndSettle();
      },
    );

    await step(
      description: 'Verify result',
      context: 'Check that the success message is displayed.',
      action: () async {
        expect(find.text('Thank you!'), findsOneWidget);
        expect(find.text('5 stars from Alex'), findsOneWidget);
      },
    );
  }
}

Доступные MCP инструменты:

Инструмент

Что делает

connect

Подключиться к тесту через VM service URI

step_forward

Следующий шаг, потом пауза

run_remaining

Выполнить все оставшиеся шаги (стоп при ошибке)

retry_step

Перепрогнать упавший шаг

get_test_state

Статус всех шагов

hot_reload_testwire_test

Hot reload с сохранением прогресса

hot_restart_testwire_test

Полный рестарт

screenshot

Скриншот UI

disconnect

Отключиться

Hot Reload - ключевая механика

Для разработки, в агентном режиме, тест запускается через flutter run, таким образом позволяя агенту подключиться к VM (в том числе через Dart MCP), считывать состояние, делать скриншоты.
Если какой-то шаг зафейлился, то выполнение теста приостанавливается и агент выясняет причины фейла уже имея доступ не только к логам но и к VM, а так же к визуальному состоянию. После фикса агент делает hot reload и делает ретрай последнего шага.

Именно из-за того что агенту необходим hot reload, тесты в testwire это именно отдельный класс а не просто функция.

Как это работает

Архитектура Testwire
Архитектура Testwire

Три компонента:

  1. Тест - запускается через flutter run (не test!) с --dart-define=AGENT_MODE=true. Приложение стартует в дебаг-режиме, тест регистрирует экстеншены Dart VM service и ждет

  2. testwire_mcp - MCP сервер, который подключается к тесту через VM service, предоставляя агенту необходимые инструменты

  3. ИИ-агент - ваш любимый MCP-клиент, использует предоставленные инструменты, имеет доступ к состоянию каждого шага

Как это меняет мою разработку

Неожиданным образом я так же обнаружил, что теперь могу дать агенту задачу и для верификации ее выполнения попросить написать интеграционный тест с использованием testwire (заранее подготовив для этого отдельный скилл), убивая при этом одним выстрелом двух зайцев: логически работающая фича, работающий интеграционный тест. Интеграционный тест при этом становится не чем-то что я может быть потом напишу, а может и нет, а обязательным пунктом, частью задачи.

Один файл - два режима

При этом на CI тест запускается в том же файле, но как обычный, без флага AGENT_MODE. То есть два режима:

# Агентский режим с hot reload
flutter run --dart-define=AGENT_MODE=true integration_test/my_test.dart

# Обычный CI прогон
flutter test integration_test/my_test.dart

Заключение

Сам инструмент не гарантирует, что все с первого раза заведется и заработает в вашем конкретном случае. Интеграция в проект может потребовать дополнительных настроек в виде написания дополнительных агентских скиллов, документации по проекту и т.д.

Буду рад фидбэку. Посмотреть примеры и как стартануть можно по ссылке в GitHub репозитории.