> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/quality-delivery/bdd-flutter-gherkin.md).

# BDD with Flutter Gherkin

How I keep Gherkin feature files while migrating BDD flows from Flutter Driver to integration\_test and WidgetTester

## Result

In my project, Gherkin feature files have been used to describe complete app behaviors. Each scenario states the initial context, the user's action, and the expected observable result through `Given`, `When`, and `Then`.

I want to preserve that behavior language. What I am changing is how each scenario is executed.

The legacy suite runs a Dart runner on the host, connects to the app through Flutter Driver, and uses an additional bridge to query routes, state, or storage inside the app. In the new path, feature files are converted into test code and executed with `integration_test`. Step definitions interact directly through `WidgetTester`.

```
Before — Flutter Driver

Feature ─► Host runner ─► Flutter Driver ─► requestData bridge ─► App


After — integration_test

Feature ─► build_runner ─► Generated suite ─► AppWorld ─► WidgetTester
                                                   │
                                                   ▼
                                          Widget tree + test deps

The host still: launches device ─► collects logs ─► creates JUnit report
```

I do not migrate the whole suite at once. I choose one feature and move its steps, World, hooks, mocks, and reporting together, then compare the outcome with the legacy suite.

The result I am working toward is:

* Feature files remain readable and contain no UI locators.
* Every scenario starts from a deterministic state.
* Steps use `WidgetTester` instead of a Flutter Driver bridge.
* A failed test includes the step name, exception, screenshot, and JUnit report.
* Android and iOS share features and steps but execute on their respective devices.
* CI preserves the correct failure status even when output passes through `tee` and a report-processing step runs afterward.

This is an ongoing migration, not a fully converted suite. I keep that limitation visible because the migration method is more useful than presenting only a cleaned-up final structure.

## Problem

BDD and Gherkin are not the parts that cost me the most effort. The problem is the execution engine that has accumulated around them over time.

### When feature files depend on a custom bridge

In the legacy path, the runner lives outside the app. Flutter Driver opens a communication channel to the app, while a `requestData` bridge provides extra commands to:

* Read the current route.
* Inspect state inside the store.
* Change or delete storage.
* Wait for an internal state.
* Control mock services and test dependencies.

A step now depends on more than user behavior. It also depends on the VM service, bridge message strings, and the app's internal implementation.

```
Scenario
   │
   ▼
Step definition
   │
   ▼
Flutter Driver command
   │
   ├── UI finder
   └── requestData("internal-command")
                         │
                         ▼
                 App implementation
```

When Flutter, Dart, or build tooling changes, several parts of this chain can break even when the feature file has not changed. In the current source, the legacy runner also needs patches inside `.pub-cache` to keep working with the newer toolchain.

The `integration_test` path then needs some of those patched files restored. Both execution paths are competing for the same dependency cache. This is migration debt, not an architecture I want to retain.

### Rewriting the whole suite at once is risky

A feature is more than a few `Given/When/Then` sentences. It also depends on:

```
Feature
  ├── Step definitions
  ├── World/test context
  ├── App initialization
  ├── Hooks that reset state
  ├── Mock responses
  ├── Waiting helpers
  └── Screenshots + reports
```

If I only copy the feature file and replace `driver.tap()` with `tester.tap()`, the scenario can still depend on state left by the previous scenario, wait with fixed delays, or return the wrong status to CI.

That is why my migration unit is a **complete vertical slice**, not an individual Dart file.

### BDD is more than turning tests into sentences

I only need a short mental model for Gherkin:

| Keyword | Role in this article               |
| ------- | ---------------------------------- |
| `Given` | Put the system into a known state. |
| `When`  | Perform a user action or event.    |
| `Then`  | Verify an observable outcome.      |

For example:

```gherkin
Feature: Shopping cart

  Scenario: Add a product
    Given the cart is empty
    When I add a product
    Then the cart shows 1 product
```

The feature does not mention a `ValueKey`, widget type, API path, or mock implementation. Those details belong in the step definitions.

I avoid writing a feature like this:

```gherkin
When I tap the widget with key "add_button"
Then the Text widget has value "1"
```

This style turns Gherkin into a longer wrapper around a widget test. When the UI changes but the behavior stays the same, the behavior documentation has to change as well.

### BDD and CI solve different parts

BDD does not automatically protect every merge. It helps me describe the behavior I need to preserve. CI is responsible for executing that behavior again when the code changes.

| Part             | Question it answers                                    |
| ---------------- | ------------------------------------------------------ |
| BDD/Gherkin      | How should the app behave in this situation?           |
| Integration test | Does the scenario actually work in the app?            |
| CI               | Is new code checked automatically before it is merged? |

Together, these parts catch a case where code still compiles but breaks a complete user flow:

```
Developer pushes code
        │
        ▼
CI builds and opens the app
        │
        ▼
Run the Given/When/Then scenario
        │
        ├── Pass ─► continue review/merge
        │
        └── Fail ─► red job + failed step + screenshot/JUnit
```

BDD does not replace unit tests or widget tests. It adds an end-to-end behavior check that an isolated test cannot prove.

## Solution

### Migrate one feature first

I start with a feature that has both a happy path and a failure path but few native UI dependencies. The first feature should not require a camera, permission dialog, notification, or platform view because `integration_test` cannot interact directly with those native interfaces.

I consider a feature migrated only when it meets all of these conditions:

1. The new scenario verifies the same user-visible outcome as the legacy scenario.
2. The scenario runs independently and does not depend on execution order.
3. Mocks and storage are reset before the app starts.
4. Steps do not use fixed delays as their primary waiting strategy.
5. A failed test produces diagnostic information.
6. The command returns a non-zero status when a scenario fails.
7. The feature runs on both Android and iOS before the legacy version is removed.

I do not measure progress by how many step files have been copied. I measure it by how many features run independently on the new path and have been removed from the old path.

### Choose dependencies intentionally

The architecture in this article uses three main components:

```yaml
dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_gherkin: 3.0.0-rc.17
  build_runner: ^2.11.1
```

`integration_test` comes from the Flutter SDK. `flutter_gherkin` parses or generates scenarios and connects them to step definitions. `build_runner` generates Dart tests from feature files.

The `flutter_gherkin` version above is the version in my current source, **not a version I recommend copying into a new project**. It is an old prerelease, declares an older Dart SDK range than my current toolchain, and my source carries compatibility patches for it. If you apply this pattern, pin a version or fork that you have verified with your project's Flutter and Dart versions, then test code generation before expanding the suite.

I do not treat editing `.pub-cache` as a normal installation step. If a temporary patch is unavoidable during migration, move it into a reviewed fork or isolated workspace, verify the package version or hash, and define when the patch will be removed.

### Organize directories by feature

The new path can be reduced to this structure:

```
integration_test/
├── features/
│   └── cart/cart.feature
├── steps/
│   ├── common/common_steps.dart
│   └── cart/cart_steps.dart
├── gherkin/
│   ├── configuration.dart
│   ├── hooks/
│   ├── reporters/
│   └── world/app_world.dart
└── gherkin_suite_test.dart
```

I move a step into `common/` only when its language and behavior are genuinely reused across features. An overly generic step such as "I tap element X" makes the feature harder to read. An overly specific step creates duplicate implementations.

I keep this boundary:

```
Feature language
      │
      ▼
Step definition
      │
      ▼
AppWorld / test helper
      │
      ▼
WidgetTester + test dependencies
```

### Let `build_runner` read `integration_test`

`gherkin_suite_test.dart` lives outside `lib/`, so the builder must include sources under `integration_test/`:

```yaml
targets:
  $default:
    sources:
      - lib/**
      - pubspec.*
      - $package$
      - integration_test/**.dart
```

I then create the suite entry point:

```dart
import 'package:flutter_gherkin/flutter_gherkin.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gherkin/gherkin.dart';

import 'gherkin/configuration.dart';

part 'gherkin_suite_test.g.dart';

@GherkinTestSuite(
  useAbsolutePaths: false,
  featurePaths: ['integration_test/features/cart/**.feature'],
)
void main() {
  executeTestSuite(
    appMainFunction: startTestApp,
    configuration: testConfiguration,
    scenarioExecutionTimeout: const Timeout(Duration(minutes: 5)),
  );
}
```

I generate the suite with:

```bash
flutter pub run build_runner build --delete-conflicting-outputs
```

The output is `gherkin_suite_test.g.dart`. It contains `testWidgets` cases generated from the feature file and invokes the matching steps in scenario order.

If the repository ignores `*.g.dart`, both local development and CI must generate the file before running tests. If the repository commits generated files, CI should verify that they are not stale. I do not leave a project in a state where generated files are ignored while the test silently depends on a leftover local copy.

In my current source, `build.yaml` does not explicitly include `integration_test/**.dart`, while the generated file is ignored. I need to fix and rerun this part before I consider the pilot complete.

### Create an `AppWorld` for each scenario

World holds context for the lifetime of one scenario. With `integration_test`, I extend `FlutterWidgetTesterWorld` to expose the `WidgetTester`:

```dart
import 'package:flutter_gherkin/flutter_gherkin.dart';
import 'package:flutter_test/flutter_test.dart';

final class AppWorld extends FlutterWidgetTesterWorld {
  WidgetTester get tester => rawAppDriver;
}
```

I register the World and step definitions in the configuration:

```dart
import 'package:flutter_gherkin/flutter_gherkin.dart';
import 'package:gherkin/gherkin.dart';

import '../main_for_test.dart' as app;
import '../steps/cart/cart_steps.dart';
import 'hooks/reset_scenario_hook.dart';
import 'world/app_world.dart';

final testConfiguration = FlutterTestConfiguration(
  createWorld: (_) async => AppWorld(),
  stepDefinitions: [...cartSteps],
  hooks: [
    ResetScenarioHook(),
    AttachScreenshotOnFailedStepHook(),
  ],
  tagExpression: 'not @todo and not @ignore',
  stopAfterTestFailed: false,
  defaultTimeout: const Duration(seconds: 30),
  reporters: [
    StdoutReporter(MessageLevel.error),
    ProgressReporter(),
    TestRunSummaryReporter(),
  ],
);

Future<void> startTestApp(World world) async {
  app.main();
}
```

I do not turn `AppWorld` into a service locator for the whole application. Only dependencies that are truly shared by multiple steps belong in World. Dependencies with a suite-wide lifecycle, such as a mock server, are managed through hooks.

### Write steps with `WidgetTester`

The shopping-cart feature maps to three step definitions:

```dart
import 'package:flutter/material.dart';
import 'package:flutter_gherkin/flutter_gherkin.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gherkin/gherkin.dart';

import '../../gherkin/world/app_world.dart';

final _givenEmptyCart = given<AppWorld>(
  'the cart is empty',
  (context) async {
    await waitUntilVisible(
      context.world.tester,
      find.byKey(const ValueKey('cart-count')),
    );
    expect(find.text('0'), findsOneWidget);
  },
);

final _whenAddProduct = when<AppWorld>(
  'I add a product',
  (context) async {
    final tester = context.world.tester;
    final addButton = find.byKey(const ValueKey('add-product'));

    await waitUntilVisible(tester, addButton);
    await tester.tap(addButton);
    await tester.pump();
  },
);

final _thenCartHasOneProduct = then<AppWorld>(
  'the cart shows 1 product',
  (context) async {
    await waitUntilVisible(context.world.tester, find.text('1'));
  },
);

final cartSteps = <StepDefinitionGeneric>[
  _givenEmptyCart,
  _whenAddProduct,
  _thenCartHasOneProduct,
];
```

`ValueKey` belongs in the step definition, not the feature file. If I replace the button with another widget but the "add a product" behavior remains the same, the scenario does not change.

In `Then`, I verify an outcome visible to the user. I avoid reading a database or a deeply nested store field only because it makes the test easier to write. When I must observe technical state, I put it behind an explicit test adapter instead of adding another message string to the bridge.

### Wait for a condition instead of a fixed delay

One source of slow and flaky integration tests is waiting for a guessed duration:

```dart
await Future<void>.delayed(const Duration(seconds: 5));
```

Five seconds can be wasteful on a fast machine and still too short on a slower device. I wait for an observable condition and use an explicit timeout:

```dart
Future<void> waitUntilVisible(
  WidgetTester tester,
  Finder finder, {
  Duration timeout = const Duration(seconds: 10),
}) async {
  final deadline = DateTime.now().add(timeout);

  while (finder.evaluate().isEmpty && DateTime.now().isBefore(deadline)) {
    await tester.pump(const Duration(milliseconds: 100));
  }

  expect(
    finder,
    findsOneWidget,
    reason: 'Widget was not visible after $timeout',
  );
}
```

I also do not use `pumpAndSettle()` for every screen. If an animation or stream never settles, the app might never become idle. On those screens, a helper that waits for the exact finder or state produces a clearer error and avoids an unbounded wait.

### Reset state with hooks

Every scenario must run correctly on its own. I divide the lifecycle like this:

```
Before test run  ─► Start mock server

Before scenario  ─► Reset mock responses
                 ─► Await storage cleanup
                 ─► Start app
                 ─► Run steps

After failure    ─► Screenshot + error context
After test run   ─► Stop mock server + emit report
```

A reduced hook looks like this:

```dart
import 'package:gherkin/gherkin.dart';

final class ResetScenarioHook extends Hook {
  @override
  Future<void> onBeforeRun(TestConfiguration config) async {
    await testBackend.start();
  }

  @override
  Future<void> onBeforeScenario(
    TestConfiguration config,
    String scenario,
    Iterable<Tag> tags,
  ) async {
    await testBackend.reset();
    await testStorage.clear();
  }

  @override
  Future<void> onAfterRun(TestConfiguration config) async {
    await testBackend.stop();
  }
}
```

The important detail is that every asynchronous cleanup operation is awaited. If storage is still being cleared while the app starts loading state, the new scenario can still receive data left by the previous one.

I also separate three different actions:

* Reset between scenarios: clear fixtures and persisted test state.
* Log out inside a scenario: exercise the app's real logout behavior.
* Clean up the suite: stop servers and release resources.

### Remove the old bridge helper by helper

I do not move the whole `requestData` bridge into a new bridge with another name. I review each legacy helper according to the outcome it was meant to verify:

| Legacy Flutter Driver         | `integration_test` direction                                                    |
| ----------------------------- | ------------------------------------------------------------------------------- |
| `driver.tap(finder)`          | Call `tester.tap(finder)` and then pump.                                        |
| `driver.waitFor(finder)`      | Poll the finder with a timeout, or use `pumpAndSettle` when the app can settle. |
| `requestData('currentRoute')` | Prefer verifying the visible screen or output.                                  |
| Message that resets storage   | Use a typed hook or test dependency and await it.                               |
| Message that controls mocks   | Give the mock server or test adapter its own lifecycle.                         |

The question I ask while migrating a helper is: **can the user observe this outcome, or is the test coupled too deeply to the implementation?**

When the output is visible in the UI, I verify the UI. When I need a technical test seam, I use a typed interface or test dependency instead of a free-form command string.

### Run the same feature on Android and iOS

Features, steps, World, and hooks are shared code. The main differences are the build and execution environments:

| Part      | Android                                                      | iOS                                                                                                  |
| --------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Target    | Emulator or physical device                                  | Simulator or physical device                                                                         |
| Build     | Select the correct flavor/application variant                | Select the correct scheme/flavor; physical devices require signing                                   |
| Reset app | Uninstall or clear app data between jobs when needed         | Uninstall/reset the simulator; Keychain has its own lifecycle if the scenario touches Secure Storage |
| Native UI | Cannot directly control permission dialogs or platform views | Same limitation; system prompts can live outside the Flutter widget tree                             |

With the current reporter, I run the suite through `flutter drive` so the host can collect stdout from the target:

```bash
flutter drive \
  --driver=test_driver/integration_test_driver.dart \
  --target=integration_test/gherkin_suite_test.dart \
  --flavor=test \
  -d "$DEVICE_ID"
```

I pass the device ID from the local environment or CI runner instead of hardcoding a real identifier in the repository.

A scenario passing on Android does not prove that it passes on iOS. Keyboards, animations, plugins, storage, and system prompts can behave differently. I only consider a feature migrated after the same assertions run on both target platforms.

### Bring JUnit from the target device to the host

A reporter running inside the app cannot assume that a file written on the target automatically becomes an artifact on the host. In the pilot, I cross this boundary through stdout:

```
Target device
  JUnitReporter
      │ print XML between two markers
      ▼
flutter drive stdout
      │ tee drive.log
      ▼
Host extractor
      │
      ▼
reports/integration-junit-report.xml
```

The reporter prints XML between two markers:

```
---JUNIT-XML-START---
<testsuites>...</testsuites>
---JUNIT-XML-END---
```

The host script removes the `flutter drive` log prefix, extracts the content between the markers, and creates the JUnit file. If a marker is missing or the XML is invalid, the extractor must return a non-zero status.

My report needs to retain:

* Feature and scenario names.
* Execution time.
* The failed step name.
* Exception and stack trace.
* A screenshot path when available.

The report must not contain credentials, real payloads, or user data. A failing test can easily leak a data table or request body into CI logs.

### Preserve the real exit status in CI

JUnit lets GitLab display test results, but a JUnit artifact does not make a job fail by itself. The script must return a non-zero status when `flutter drive` fails.

When output passes through `tee`, I take the status of the first command in the pipeline from `PIPESTATUS`:

```makefile
SHELL := /bin/bash

it-bdd:
	@mkdir -p reports
	@status=0; \
	flutter drive \
	  --driver=test_driver/integration_test_driver.dart \
	  --target=integration_test/gherkin_suite_test.dart \
	  -d "$(DEVICE)" 2>&1 | tee reports/drive.log; \
	drive_status=$${PIPESTATUS[0]}; \
	if [ $$drive_status -ne 0 ]; then status=$$drive_status; fi; \
	python3 scripts/extract_junit.py \
	  reports/drive.log reports/integration-junit-report.xml \
	  || status=$$?; \
	exit $$status
```

GitLab keeps the artifact even when the test fails:

```yaml
bdd_test:
  script:
    - DEVICE="$BDD_DEVICE" make it-bdd
  artifacts:
    when: always
    paths:
      - reports/integration-junit-report.xml
    reports:
      junit: reports/integration-junit-report.xml
```

I verify this configuration with an intentionally failing scenario. The correct result must satisfy both conditions:

1. The job changes to failed.
2. The JUnit report remains available for inspecting the failed step.

If the job is green while the report contains a failed test, the exit status has been hidden. If the job is red but the report is missing, the artifact or extractor is configured incorrectly.

In the current source, BDD CI is not enabled and the pipeline does not invoke the pilot target. I do not consider this part complete until a failing test turns a real CI job red.

### Migrate in stages

I divide the migration into five stages:

```
1. Pilot
   One feature with few native dependencies
        │
        ▼
2. Shared infrastructure
   World + hooks + wait helpers + JUnit
        │
        ▼
3. Migrate by feature
   Pass Android/iOS before removing the old version
        │
        ▼
4. CI and sharding
   Active job + correct exit status + report always available
        │
        ▼
5. Remove legacy
   Delete Flutter Driver bridge, cache patches, and old runner
```

During the transition, both suites coexist and increase CI time. I accept that cost for each feature, but I do not keep duplicates indefinitely. Once the new feature is stable, its Flutter Driver counterpart must be removed.

### Verify the result

A migrated feature goes through this matrix:

| Case               | Legacy suite | `integration_test` | Result to compare                        |
| ------------------ | ------------ | ------------------ | ---------------------------------------- |
| Happy path         | Pass         | Pass               | The same user-visible outcome.           |
| Failure path       | Pass         | Pass               | The same error state or message.         |
| Run scenario alone | Supported    | Supported          | No dependency on the previous scenario.  |
| API error or delay | Mocked       | Mocked             | Wait for a condition, not a fixed delay. |
| Test fails         | Logs         | Screenshot + JUnit | CI returns non-zero.                     |
| Platform           | Android/iOS  | Android/iOS        | The same assertions on both platforms.   |

Beyond app scenarios, I also verify the infrastructure:

* Hook cleanup completes before app initialization.
* The mock server resets responses before every scenario.
* Waiting helpers time out with a clear error.
* The JUnit reporter escapes XML and records the failed step.
* The extractor returns an error when markers are missing.
* CI still uploads the report when the test command fails.

### Current status and trade-offs

The source pilot already has a feature, generated suite, steps, hooks, and a reporter. However, I still need to complete four items:

1. Explicitly include `integration_test/` sources for `build_runner`.
2. Await all storage cleanup before a scenario starts.
3. Preserve the `flutter drive` exit status through `tee`.
4. Enable the job and rerun it on Android and iOS with all dependencies available.

The latest local check could not compile the target because the package cache was missing Gherkin dependencies. I therefore do not claim that "the new suite has passed" in this article.

This approach also has trade-offs:

* Gherkin adds mapping and code-generation layers. A small app whose tests are only read by developers might be better served by plain integration tests.
* A mocked backend makes scenarios deterministic but does not prove that the production API still matches the contract.
* Resetting all storage isolates scenarios but can hide persistence or migration bugs; those flows need dedicated scenarios.
* `integration_test` cannot interact with native platform UI.
* Physical devices increase platform confidence but slow the pipeline and require device and signing management.
* An old Gherkin package adds fork, patch, and upgrade costs.
* Running legacy and new suites together increases CI time during migration.

### Versions reviewed

* Flutter: `3.41.2`.
* Dart: `3.11.0`.
* `flutter_gherkin`: `3.0.0-rc.17` — an old prerelease with compatibility debt.
* `gherkin`: `3.1.0`.
* `integration_test`: package from the Flutter SDK.
* Android min SDK in the source app: `24`.
* iOS deployment target in the source app: `15.0`.

### References

* [Flutter — Integration testing](https://docs.flutter.dev/testing/integration-tests)
* [Flutter — Migrating from flutter\_driver](https://docs.flutter.dev/release/breaking-changes/flutter-driver-migration)
* [Flutter — Integration testing concepts](https://docs.flutter.dev/cookbook/testing/integration/introduction)
* [Cucumber — Gherkin reference](https://cucumber.io/docs/gherkin/reference/)
* [`flutter_gherkin` versions](https://pub.dev/packages/flutter_gherkin/versions)
* [GitLab — Unit test reports](https://docs.gitlab.com/ci/testing/unit_test_reports/)

## Conclusion

I am not abandoning Gherkin because Flutter Driver and the legacy bridge have become a burden. Feature files are still useful as readable behavior contracts. I am replacing the execution engine underneath them.

My approach is to migrate one vertical slice at a time to `integration_test` and `WidgetTester`, including that feature's hooks, mocks, waiting strategy, and reporting. The cost is that two suites coexist for a while. In return, I can compare outcomes before removing the legacy runner.

A feature is only fully migrated when its scenarios run independently, the same assertions pass on Android and iOS, failures turn CI red, and JUnit remains available for diagnosis. Until then, the pilot demonstrates the direction, not the completion of the entire BDD migration.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
