7 Silent Failures in My Flutter App That No Test Caught

A token refresh that never ran, a crash filter that threw away crashes, and a test that defended a bug

On 2026-10-03, four tests for a review screen in the Flutter app I maintain started failing. They couldn't find a snackbar. The snackbar was fine. The real error was this:

[core/no-app] No Firebase App '[DEFAULT]' has been created

The app decides whether it's running under test with a compile-time constant, and the crash reporter swaps in a no-Firebase instance only when that constant is true:

/// The Flutter tool instead pushes well-known flags (including FLUTTER_TEST) in compile time via --dart-define.
static const bool isTestMode = bool.fromEnvironment('FLUTTER_TEST');

Read the comment, then read the error again. A bare flutter test did not set that define, so the reporter reached for FirebaseCrashlytics.instance and blew up. The CI step passes --dart-define=FLUTTER_TEST=true. My local run didn't. With the define, all 11 review-screen tests passed.

So that's one kind of lie a test suite can tell you: red when nothing is broken. The same day turned up the other kind: things that were broken while nothing went red. Six of them came out of a read-only survey of the codebase by a coding agent; the seventh surfaced that morning during an await cleanup. None of the seven was caught by a test. (One of them did turn a test red — after it was fixed. We'll get there.)

Code that is still in the app is quoted from main as of 2026-10-12; the two that are already fixed are quoted from just before their fixes.

1. The 401 refresh that could never run

The API client accepted every status from 200 to 499 as a success:

validateStatus: (int? status) {
  return status != null && (status >= 200 && status < 500);
},

The auth interceptor handled a 401 in exactly one place, onError:

@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
  if (err.requestOptions.extra['isRefresh'] == true ||
      err.requestOptions.path.contains(VERIFY_ENDPOINT) ||
      err.response?.statusCode != 401) {
    return handler.next(err);
  }

Put those two together and a 401 is a perfectly successful response. It never becomes a DioException, so onError never runs, so the refresh never happens.

And the tests? They built a DioException with a 401 by hand and called interceptor.onError(exception, handler) directly, against a MockDio. They tested the method thoroughly. They never tested whether anything could ever call it.

The fix landed on 2026-10-04 as PR #10, which refreshes and retries a 401 once in onResponse. The part I'd copy is the verification: a new test file drives a real Dio with the client's own validateStatus and a fake adapter. Before the fix, 4 of its first 5 cases failed for the expected reasons. The same PR also cleared the single-flight refresh lock after every refresh — before that, the proactive refresh path never reset it, so after the first refresh every later one awaited the old completed future, and a failed refresh stayed cached the same way.

2. A Sentry filter that dropped every Error

..beforeSend = (SentryEvent event, Hint hint) async {
  final exception = event.throwable;
  if (exception is Exception) {
    if (exception is! CustomException && exception is! SignInException) {
      return event;
    }
  }
  return null;
}

The intent was "don't report our own expected exceptions." The effect is "only report Exceptions." In Dart, StateError, TypeError, and FlutterError are Errors, not Exceptions, so all of them fall through to return null — which are exactly the crashes you installed Sentry for.

Write an exclusion filter (drop the known noise), not an inclusion filter (keep the known shape). This one was still on main as of 2026-10-12.

3. A try/catch around something that cannot throw

try {
  unawaited(
    future.then((_) {
      // Remove from pending operations on success
      _pendingOperations.remove(future);
    }),
  );
} on Exception catch (exception) {

unawaited(...) returns immediately. It does not throw synchronously, so that catch is decoration. When the future fails, the error becomes an unhandled zone error, and the failed future sits in _pendingOperations forever, because only the success path removes it.

The handler belongs on the future itself: future.then(onSuccess, onError: ...), or catchError.

4. Route guards that don't notice you logged out

The guards are tidy typed-route mixins:

mixin RequireAuth on GoRouteData {
  @override
  FutureOr<String?> redirect(BuildContext context, GoRouterState state) {
    return context.shouldBeSignedIn(state);
  }
}

shouldBeSignedIn reads the session with ref.read(authManagerProvider), and the GoRouter is built without a refreshListenable. Redirects run when navigation happens, not when auth state changes. So when the session ends, the protected page stays on screen until the next navigation.

Wire auth state into refreshListenable.

5. Error mapping that quietly turns off retry

A shared provider error handler records known exceptions and calls a fallback. Anything it doesn't recognize hits the default branch:

default:
  throw StateError(Error.safeToString(error));

Now look at the retry policy:

if (error is ProviderException || error is Error) {
  return null;
}

StateError is an Error, so it is never retried. It also skips the fallback the caller passed in. An unrecognized transport failure — the kind retry exists for — gets converted into the one type that retry ignores.

Bonus irony: under the test define, the app's retry function hands over to noRetry (if (PlatformUtil.isTestMode)), so a test run with the define never reaches the real retry policy through it.

Classify transport failures before mapping them, and retry network, timeout, and server errors — not 4xx.

6. Static fields that freeze locale and screen scale

static String defaultMessage = '알 수 없는 오류가 발생했습니다.'.tr();
static TextStyle displayLarge = FigmaTextStyle(
  debugLabel: 'display-large',
  fontSize: 48,
  fontWeight: FontWeight.w700,
  height: 1.30,
);

FigmaTextStyle turns fontSize into fontSize.sp. A static field with an initializer is evaluated on first read, once. Read it before localization or screen-size initialization and it keeps the raw key, or the wrong scale, for the life of the process.

Anything that depends on locale or screen metrics should be a getter.

7. The test that defended the bug

This one hurts the most, because a test was involved the whole time.

Future<bool> sendEmail({required String email, required String subject, String body = ''}) async {
  logger.i('Email $email Body $body');
  try {
    // ...
    if (await canLaunchUrl(uri)) {
      return launchUrl(uri, mode: LaunchMode.externalApplication);
    }
    return false;
  } on Exception catch (exception, stack) {
    // ...
    return false;
  }
}

return launchUrl(...) without await returns the future and leaves the try block. When that future fails, the function is already gone, so the error skips the catch and lands on the caller.

The test for it was named "returns false when launchUrl throws an exception." It asserted this:

final sendEmail = repository.sendEmail(email: 'user@example.com', subject: 'Subject');
expect(() => sendEmail, throwsA(isA<Exception>()));

The name described the intended behavior. The body asserted the bug. On 2026-10-03, commit 039f0703 added await across 17 files as a cleanup, the catch started working, sendEmail returned false, and the test failed. The test was corrected in cbc1b8b2 to expect false.

One trick from that hunt is worth stealing: the same commit passed in a clean git worktree and failed in my main tree, which pointed at an uncommitted working-tree change rather than at the code under test.

What I'm Taking Away

  1. Test through the same pipe production uses. Calling onError directly proved the method worked and said nothing about whether it was reachable. PR #10's test used a real Dio with the real validateStatus, and it failed before the fix.
  2. Prefer injection to a compile-time flag. If the crash reporter is passed in, test behavior stops depending on how somebody typed flutter test. If the flag stays, put the exact test command in one place and run that same command locally.
  3. In an async function, return await inside try. And write the test where the inner call throws — that's the only test that can see it.
  4. When a test asserts the opposite of its own name, fix the test, not the code.
  5. Filter crashes by exclusion. Then go check that beforeSend of yours.

If you want a quick audit, grep your own Flutter app for validateStatus, beforeSend, unawaited( inside a try, and static fields that call .tr() or .sp. I'd genuinely like to know how many of these seven you find — or which eighth one I missed.