DEV Community

Hamber
Hamber

Posted on AI-assisted

Your Tests Pass. That Proves Nothing.

Writing Flutter tests in the age of AI, where the scarce skill is no longer writing them.


Last year I shipped a fix for an audio glitch in GoGBA — a crackle at the seam where the emulator splices its ring buffer. I wrote four unit tests. All four passed. I committed.

The fix was inert. The tests never called the function that configured it. The ramp length was zero for the entire test run.

Worse: when I later went back and disabled the fix completely, two of those four tests still passed.

That is the story I want to tell, because it is the central problem of testing in 2026. AI has made writing tests nearly free. It has not made them true. The bottleneck moved — from "I don't have time to write tests" to "I have 1400 tests and I don't know which ones are load-bearing."

GoGBA is my GBA/GBC/GB emulator, built solo in Flutter, shipped on both stores. It has 1423 Dart tests that run in about a minute, plus a native C++ suite for the audio path. Most of them were written with AI assistance. This article is about the discipline that makes that number mean something instead of nothing.


1. The Asymmetry Nobody Priced In

Here is what actually changed when coding assistants got good.

Before: writing a test cost 10 minutes. Verifying it was honest cost 30 seconds. Nobody skipped the verification, because it was free relative to the writing.

After: writing a test costs 10 seconds. Verifying it is honest still costs 30 seconds.

The verification step is now 3x more expensive than the thing it verifies. Every incentive in your workflow now points at skipping it. And the output looks identical either way — a green checkmark.

This is not a complaint about AI. The generated tests are usually syntactically perfect, idiomatically correct, and well-named. That is precisely the problem: they are plausible. A test that is plausible and wrong is worse than no test, because it occupies the slot where a real test would have gone and it tells you the slot is filled.

The 2026 rule: a passing test is not evidence. A test that fails when you break the code is evidence.


2. Mutation Testing, By Hand, In 30 Seconds

You do not need a mutation-testing framework. You need a habit.

For every test you keep: break the code it covers, and confirm that specific test goes red.

Let me show you this on real GoGBA code, because the abstract version never lands.

The bug

GoGBA's D-pad has a dead zone — the centre 10% of the radius, so resting your thumb doesn't send phantom inputs. The original onPanStart looked roughly like this:

onPanStart: (details) {
  final localPosition = box.globalToLocal(details.globalPosition);
  // Only "activate" if the initial touch already resolved to a direction.
  if (_calculateDirection(localPosition).isNotEmpty) {
    setState(() => _isActive = true);
  }
  _updateDirection(localPosition);
},

onPanUpdate: (details) {
  if (_isActive) {            // <-- gated on that flag
    _updateDirection(box.globalToLocal(details.globalPosition));
  }
},
Enter fullscreen mode Exit fullscreen mode

Read it and the bug is almost invisible. Play the game and it is brutal: your thumb rests at the centre of the pad. That is where the dead zone is. So the common gesture — land in the centre, push out to the right — starts inside the dead zone, never sets _isActive, and stays inert for its entire life. The D-pad simply does nothing until you lift off and tap again.

The fix is one line: a touch anywhere on the pad owns the gesture.

onPanStart: (details) {
  final localPosition = box.globalToLocal(details.globalPosition);

  // A touch anywhere on the pad owns the gesture, even one landing in
  // the dead zone: the thumb rests at the centre and pushes out from
  // there, and gating this on an initial direction left that whole
  // gesture inert until the user lifted off.
  hintUserInteractionResume(ref);
  setState(() => _isActive = true);
  _updateDirection(localPosition);
},
Enter fullscreen mode Exit fullscreen mode

The test pair

testWidgets('a drag starting in the dead zone still steers', (tester) async {
  final session = await pumpPad(tester);
  final centre = tester.getCenter(find.byType(DPad));

  // Land dead centre, then push onto the right arm.
  final gesture = await tester.startGesture(centre);
  await tester.pump(const Duration(milliseconds: 16));
  await gesture.moveBy(const Offset(50, 0));
  await tester.pump(const Duration(milliseconds: 16));

  expect(
    session.events,
    contains((GBAButton.right, true)),
    reason: 'pushing out of the dead zone must register a direction',
  );

  await gesture.up();
});

testWidgets('resting in the dead zone presses nothing', (tester) async {
  final session = await pumpPad(tester);
  final centre = tester.getCenter(find.byType(DPad));

  // Hold near the centre and jitter inside the dead zone.
  final gesture = await tester.startGesture(centre);
  await tester.pump(const Duration(milliseconds: 16));
  await gesture.moveBy(const Offset(3, -2));
  await tester.pump(const Duration(milliseconds: 16));

  expect(
    session.events.where((e) => e.$2),
    isEmpty,
    reason: 'the dead zone must still swallow thumb jitter',
  );

  await gesture.up();
});
Enter fullscreen mode Exit fullscreen mode

Now actually run the mutation

I did not reason about whether these tests work. I reverted the fix in the real file and ran them:

00:00 +0 -1: a drag starting in the dead zone still steers [E]
00:00 +1 -1: Some tests failed.
Enter fullscreen mode Exit fullscreen mode

Test one went red. Test two stayed green. That is the shape you want:

  • Test one discriminates — it dies without the fix.
  • Test two is the anchor — it proves the fix didn't overshoot and delete the dead zone entirely.

Two tests, two independent failure modes, neither redundant. That pair is worth more than twenty generated tests asserting that _calculateDirection returns the right enum for sixteen different angles.

Notice also what the tests are asserting on. Not internal state, not _isActive, not a call count on a private method. They assert on what the emulator core actually received — a recording fake of the session port:

class _RecordingSession implements EmulatorSessionPort {
  final List<(int, bool)> events = [];

  @override
  Future<void> setButtonState(int button, bool pressed) async =>
      events.add((button, pressed));

  @override
  dynamic noSuchMethod(Invocation invocation) => null;
}
Enter fullscreen mode Exit fullscreen mode

That noSuchMethod line is the whole reason this stays maintainable: the port has a dozen methods and the test cares about one. When the port grows, this fake does not break.


3. A Second Mutation: The Bug That Only Exists in the Framework

Some bugs aren't in your logic at all. They live in a framework default you didn't know you were accepting — which makes them exactly the kind AI-generated tests never find, because the AI generated the test from your code, and your code doesn't mention the default.

GoGBA centralizes motion into tokens, so no animation writes a bare Duration:

abstract final class AppDuration {
  static const instant  = Duration(milliseconds: 100);
  static const quick    = Duration(milliseconds: 180);
  static const standard = Duration(milliseconds: 250);
  static const deliberate = Duration(milliseconds: 400);
  static const cartridgeInsert = Duration(milliseconds: 1000);
}
Enter fullscreen mode Exit fullscreen mode

The page transitions used these tokens. And yet navigation felt subtly wrong — pushing a page and popping it back didn't feel like the same motion.

The cause: slideTransition set only reverseTransitionDuration. go_router silently defaults transitionDuration to 300ms. So every forward navigation ran at 300ms and every return leg at 250ms. No error, no warning, no lint. A 50ms asymmetry that you feel and cannot name.

The test doesn't check a constant. It checks a relationship:

// Regression: slideTransition once set only reverseTransitionDuration, so
// go_router defaulted the forward direction to 300ms while the return leg
// ran at 250ms. Enter and exit must travel the same path at the same speed.
for (final entry in <String, CustomTransitionPage<void> Function({
  required GoRouterState state,
  required Widget child,
})>{
  'fadeTransition': PageTransitions.fadeTransition<void>,
  'slideTransition': PageTransitions.slideTransition<void>,
}.entries) {
  testWidgets('${entry.key} is symmetric', (tester) async {
    late CustomTransitionPage<void> page;
    final router = GoRouter(routes: [
      GoRoute(path: '/', pageBuilder: (context, state) {
        page = entry.value(state: state, child: const SizedBox.shrink());
        return page;
      }),
    ]);
    addTearDown(router.dispose);

    await tester.pumpWidget(MaterialApp.router(routerConfig: router));
    await tester.pumpAndSettle();

    expect(page.transitionDuration, page.reverseTransitionDuration);
    expect(page.transitionDuration, AppDuration.standard);
  });
}
Enter fullscreen mode Exit fullscreen mode

Mutation check — I deleted the transitionDuration: line from the real router file:

Failing tests:
  test/theme/app_motion_test.dart: page transitions fadeTransition is symmetric
  test/theme/app_motion_test.dart: page transitions slideTransition is symmetric
Enter fullscreen mode Exit fullscreen mode

Both red. The four AppDuration token tests in the same file stayed green — they cover a different property, so they should stay green. A mutation that turns your whole file red is telling you your tests are entangled.

The lesson generalizes: assert on the invariant, not on the value. expect(duration, 250) would pass for a broken app. expect(forward, reverse) cannot.


4. Where AI Genuinely Wins: Property Coverage

I have been hard on generated tests. Here is where they are outstanding, and where I now use them by default.

Some tests are not about a single failure mode — they are about a combinatorial claim across a set of inputs. Humans write three cases and get bored. AI writes all of them and never gets bored.

GoGBA calls Gemini for on-screen text translation. The model id is Remote Config-driven — which means it changes without a release, and a model generation I have never run can arrive in production at any time. Successive Gemini generations have taken mutually exclusive thinking knobs, and sending the wrong one is not an error: thinking stays at the model default, silently eats the 512-token output budget, and truncates the translation mid-sentence.

This is the class of bug where writing down today's correct answer is worthless — today's answer expires. What you want pinned is the rule for picking the knob, and the guarantee that you never send two.

test('current generations use thinkingLevel', () {
  final config = GeminiTranslator.thinkingConfigFor('gemini-3.5-flash');
  expect(config.thinkingLevel, ThinkingLevel.minimal);
  expect(config.thinkingBudget, isNull);
});

test('a generation newer than any I have run is treated as current', () {
  final config = GeminiTranslator.thinkingConfigFor('gemini-9-flash');
  expect(config.thinkingLevel, ThinkingLevel.minimal);
  expect(config.thinkingBudget, isNull);
});

test('unparseable model id falls back to the legacy knob', () {
  final config = GeminiTranslator.thinkingConfigFor('custom');
  expect(config.thinkingBudget, 0);
  expect(config.thinkingLevel, isNull);
});

// The one that matters most: a property, over the whole input space.
test('the two knobs are never set together', () {
  const ids = [
    'gemini-3.5-flash', 'gemini-9-flash', 'custom', '',
  ];
  for (final id in ids) {
    final config = GeminiTranslator.thinkingConfigFor(id);
    expect(
      config.thinkingBudget == null || config.thinkingLevel == null,
      isTrue,
      reason: 'both knobs set for "$id"; the API rejects that',
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

The first three are enumeration — AI does this perfectly and I let it. The last one is the property, and it is the one that survives a refactor. Note that it covers gemini-9-flash, a model that does not exist. That is deliberate: the model id arrives from Remote Config, so the input space includes ids I have never seen, and the property has to hold across all of them.

Division of labour that works for me:

Task Owner
Enumerate the cases AI
Set up fakes, fixtures, harness boilerplate AI
Port a test to a second platform AI
Decide what the invariant is Me
Run the mutation Me
Delete the redundant tests Me

The three in bold are the entire job now. They are also the three that feel least like "work," which is why they get skipped.


5. Physical Properties Beat Incidental Values

The hardest bugs in GoGBA are in the audio path, and they taught me the sharpest version of this rule.

The GBA core runs at 32040.5 Hz. Not 32040. That .5 looks like noise. Round it off and you get a 15.6 ppm drift between producer and consumer, the ring buffer slowly fills, and roughly once every few minutes it overflows and clips — an audible pop with no apparent cause.

Now here is the trap that actually bit me. A test like this passes with the bug present:

// BROKEN: producer and resampler both read the same (rounded) constant,
// so the error cancels and the test is green forever.
const int rate = kCoreRate;
producer.configure(rate);
resampler.configure(rate, 48000);
Enter fullscreen mode Exit fullscreen mode

Both sides used one rounded constant, the errors cancelled exactly, and drift was structurally unobservable. The test could never fail. It was, in the most literal sense, testing nothing.

The fix is to read the rate independently on each side, so a rounding error on one side shows up as drift. And then assert on the physical property, not a sample value.

This is the general principle, and it is the one I'd carry to any codebase:

When you can, assert on a property the user can perceive — not on a number that happens to be correct today.

For the audio splice, the assertion that finally caught a real regression was not "the first sample after the seam equals X." It was "the maximum adjacent-sample step across the splice is no larger than the clean waveform's own maximum step." That is the definition of "no click." A first-sample check passed happily while a too-fast fade was still audible; the step-size check killed it immediately.

For the D-pad, the property was "the core received a right-press." For transitions, "forward equals reverse." Every one of these is a sentence you could say to a user. That is the test for whether it's the right assertion.

The native suite runs under sanitizers, which is the same idea applied to memory:

./run_tests.sh          # build + run
./run_tests.sh --asan   # AddressSanitizer + UBSan
./run_tests.sh --tsan   # ThreadSanitizer, checks the ring's locking
Enter fullscreen mode Exit fullscreen mode

with one detail worth stealing:

# Surface the first error instead of stopping at it, and make UBSan fatal --
# by default it only prints and carries on, which would let a real defect pass.
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1"
Enter fullscreen mode Exit fullscreen mode

UBSan defaults to printing and continuing. A default that turns a caught bug into a green build. Check yours.


6. The Tests You Should Delete

A suite where every test passes and no test can fail is a suite with a coverage number and no coverage.

I delete a test when:

  1. No mutation can kill it. Break the code three plausible ways; if it stays green, it is decoration.
  2. Another test strictly dominates it. If test B fails everywhere test A fails, A is noise in your CI log.
  3. It asserts on an implementation detail. Private method call counts, internal flags, widget tree shapes that aren't the user-visible outcome. These break on every refactor and catch nothing.
  4. It was generated to hit a coverage target. Coverage measures which lines ran, not which bugs would be caught. A test with no expect runs plenty of lines.

The GoGBA suite has 1423 tests — it briefly had more, until I applied this section to it. Not one of them exists because a line was uncovered. Each exists because a real failure mode would otherwise go unnoticed — and the project's own rules say so out loud:

Only the necessary tests. One per real failure mode that would otherwise go unnoticed — not one per method. A test must fail without the fix: revert it, watch it go red, put it back.

That last clause is the whole article in one sentence.


7. What the Machine Should Enforce Instead

There is a category of correctness that does not belong in tests at all, and it's the highest-leverage thing you can set up in 2026.

If a rule can be checked statically, a test for it is the wrong tool — it only fires when someone remembered to write the test. A lint fires on every keystroke, for everyone, forever.

GoGBA ships a gogba_custom_lint package. Each rule is a bug class that used to recur:

Rule The bug it ends
domain_layer_dependency domain/ importing Flutter or dart:io, quietly making pure logic untestable
presentation_no_data_imports UI reaching past the port into a datasource
no_ref_in_dispose Riverpod ref.read() on the teardown path → crash
no_invalidate_app_config invalidate() after a config write → AsyncLoading flash
no_hardcoded_ui_string A string that never reaches the 24 translation files

Plus one script that runs identically on my machine and in CI:

./scripts/check.sh           # format, analyze, custom_lint, repo invariants
./scripts/check.sh --check   # CI mode: no writes
Enter fullscreen mode Exit fullscreen mode

Every stage always runs even if an earlier one fails, so one invocation surfaces every problem rather than making you play whack-a-mole.

The AI angle is the important part: the assistant reads these rules before it writes code. The constraints live in skill.md files the assistant loads at the start of every session, and the lints catch anything that slips through. The rules stop depending on anyone's memory — mine or the model's.

That is the real 2026 workflow. Not "AI writes my tests." It's: encode the rules once, and the cost of following them drops to zero for every future contributor, human or not.


The Checklist

Everything above, compressed:

  • [ ] For each new test: break the code, watch that test go red, put it back. Non-negotiable. It costs 30 seconds.
  • [ ] Assert on the physical/perceivable property, not an incidental value.
  • [ ] Assert on relationships (forward == reverse) over constants (== 250).
  • [ ] In numeric tests, read each side's inputs independently — shared constants cancel errors and make bugs unobservable.
  • [ ] Let AI enumerate cases and build fakes. You decide the invariant.
  • [ ] Delete tests no mutation can kill, and tests another test dominates.
  • [ ] Move every statically-checkable rule from tests into lints.
  • [ ] Make sanitizers and checkers fatal — verify their defaults don't print-and-continue.
  • [ ] One verify script, identical locally and in CI.

Closing

The thing I keep coming back to: AI didn't make testing easier. It made writing testing artifacts easier, which is not the same activity, and the gap between those two is where bugs now live.

The skill that mattered in 2020 was knowing how to write a widget test. That skill is now commoditized and I'm glad — it was never the interesting part. The skill that matters in 2026 is judgment about what a test proves, and it is less commoditized than before, because now there are a thousand plausible tests where there used to be three, and someone still has to know which ones are real.

You get that judgment exactly one way. Break your own code and watch what happens. If nothing turns red, you have just learned something considerably more valuable than a green checkmark.


GoGBA is a GBA/GBC/GB emulator for Android and iOS — Flutter UI over a libretro/mGBA core, with AI on-screen translation. Search GoGBA on the App Store or Google Play.

Top comments (0)