Five Flutter bugs that pass `flutter analyze` but fail CI
I just put a big stretch of refactoring through CI on LitterBuggies — interface-extracting the whole data layer and clearing a backlog of UX, performance, and maintainability fixes. Every commit was flutter analyze-clean. The first CI run still had 32 failing tests and two boot-breaking bugs.
That gap is the whole point of this post. flutter analyze is necessary, not sufficient. It checks types and analyzer lints — it does not run your code generation, it does not run bloc_tools' lints, and it does not run your tests. Here are five real bugs from that branch that a clean analyze happily waved through, and how to catch each one earlier.
1. An @injectable constructor that throws at app boot
I added two test-seam knobs to a cubit:
@injectable
class ProfileCubit extends Cubit<ProfileState> {
ProfileCubit(
this._repository, {
Duration retryDelay = const Duration(milliseconds: 600),
int maxLoadAttempts = 4,
}) : ...;
}
build_runner dutifully generated this:
gh.factory<ProfileCubit>(
() => ProfileCubit(
gh<ProfileRepository>(),
retryDelay: gh<Duration>(), // not registered
maxLoadAttempts: gh<int>(), // not registered
),
);
Duration and int aren't in the DI graph, so resolving the cubit throws at startup. flutter analyze sees a valid constructor and valid generated code; the app just dies on launch.
The fix is to hand injectable a constructor it can actually satisfy and keep the knobs as a plain test seam:
@factoryMethod
factory ProfileCubit.resolve(ProfileRepository repository) =>
ProfileCubit(repository);
2. bloc lint is a different tool than flutter analyze
bloc_tools ships its own lints that flutter analyze never runs. Two bit me:
avoid_flutter_imports— I'd pulledpackage:flutter/foundation.dartinto a cubit (for@visibleForTesting). Cubits are meant to be Flutter-free.avoid_public_fields— that cubit's config fields were public.
Both fixes were trivial (make the fields private, drop the import). They only surface if bloc lint runs in CI:
dart pub global activate bloc_tools
bloc lint lib
3. setState during build
A profile form seeded its text field from loaded state, inside the BlocConsumer builder:
_nameController.text = profile.displayName ?? '';
Separately, I'd added a listener on that controller that calls setState (to keep an avatar initial in sync as you type). So this seed — running during build — fired the listener, which called setState mid-build, which throws. Every widget test that actually built the page failed at once. Analyze can't see it because nothing is type-wrong.
The fix is to silence the listener around the one-shot programmatic seed:
_nameController
..removeListener(_onNameChanged)
..text = profile.displayName ?? ''
..addListener(_onNameChanged);
4. A slider's accessibility semantics that crash the whole tree
I made a replay scrubber operable by screen readers by adding adjust actions:
Semantics(
slider: true,
value: '${(progress * 100).round()}%',
onIncrease: () => stepBy(0.05),
onDecrease: () => stepBy(-0.05),
...
)
Flutter asserts that if you expose increase/decrease, you must also expose increasedValue and decreasedValue (or have an empty value): "A SemanticsNode with action 'increase' needs to be annotated with either both 'value' and 'increasedValue' or neither." That assertion doesn't just fail the widget — it takes down the entire semantics subtree, so every test on that screen went red from one missing field.
value: pctAt(progress),
increasedValue: pctAt(progress + 0.05),
decreasedValue: pctAt(progress - 0.05),
5. An architecture change that leaked a Timer
This was my favorite. I moved a lifetime-stats read out of a widget and into an app-root cubit — good hygiene. But that cubit now resolves a Drift-backed repository, so booting the app opened the real database.
Meanwhile, the boot gate does roughly this:
await Future.wait([...]).timeout(const Duration(seconds: 6));
.timeout() arms a six-second Timer and cancels it when the awaited future completes. The extra boot work — opening a real DB — delayed that completion just enough that, in a widget test doing a single pump(), the timer hadn't been cancelled by the time the test tore down. Flutter's test binding then asserts "A Timer is still pending."
The fix wasn't in production code at all: the boot test needed to mock the new dependency, exactly like it already mocked everything else the app touches at startup. The lesson is that when you give an app-root object a new dependency, you've changed what boot does — and your boot tests need to know.
The meta-lesson
None of these are exotic. They live in the ordinary seams between what flutter analyze checks (types, analyzer lints) and what it doesn't (codegen resolution, bloc_tools lints, your actual tests). If CI runs bloc lint, dart run build_runner build, and the full suite, but your local loop only runs flutter analyze, then "clean locally" and "green on CI" are simply different claims.
The practical fix is boring and effective: push early and let CI tell you the truth. I watched one branch go 32 → 17 → 1 → 0 failing tests across a handful of pushes, and every red was a real defect or a stale test — not noise.