This document explains how tests are structured, bootstrapped, and written in this app. If you are new to the project, read this before adding tests.
For the HTTP mock data layer (how assets/mock/*.json is served to the app
at dev/test time), see mock-architecture.md. This
document is about the test suite itself.
- Mirror the source tree.
test/mirrorslib/one-to-one, so the test for any file is exactly where you'd expect it. - Mock at the boundary, not the wire. Unit and BLoC tests mock the
repository/use-case interface with
mocktail. They do not go through HTTP or the mock interceptor. - One global bootstrap. Cross-cutting setup (binding init, logger, fallback registration, per-test environment reset) lives in one place, not in every file. Test files contain only what is unique to them.
- Sealed states, exhaustive matching. BLoC tests assert on sealed state
variants (see
CLAUDE.md→ State Modeling). - Determinism. Tests must not depend on wall-clock time, ordering, or leaked state between tests.
| Tool | Purpose |
|---|---|
flutter_test |
Test runner, WidgetTester, matchers |
bloc_test |
blocTest(...) + MockBloc for BLoC/Cubit state assertions |
mocktail |
Mocks/fakes without code generation (Mock, Fake, when, verify, any) |
fake_async |
Virtual time for timer/timeout logic |
No mockito, no build_runner for mocks — mocktail is the single mocking
library. The end-to-end smoke is a full-app widget test under test/integration/
(see below) — it runs headless under flutter test, so the integration_test
package is not a dependency.
test/ mirrors lib/:
test/
├── flutter_test_config.dart # GLOBAL bootstrap (auto-discovered by flutter test)
├── support/
│ ├── dart_code.dart # codeOnly(): Dart source without comments and string text, for guards
│ └── test_env.dart # TestEnv: reset(), authenticate(), apiClient(), autoReset
├── mocks/
│ ├── mock_classes.dart # Mock*/Fake* classes + registerAllFallbackValues()
│ └── fake_data.dart # Shared fixtures (mockUserFullPayload, ...)
├── architecture/ # Meta-tests enforcing rules (see below)
│ ├── android_manifest_test.dart # Android INTERNET, backup off
│ ├── arb_translations_test.dart # every English message has a Turkish one with the same placeholders, no ARB key twice
│ ├── credential_to_string_test.dart # a class with a credential field keeps it out of toString
│ ├── feature_generator_test.dart # tool/generate_feature.dart output is clean and passes its tests
│ ├── import_guard_test.dart # layer dependency rules, url_launcher only in external_link.dart
│ ├── log_lazy_arguments_test.dart # AppLogger arguments are not turned into text before the level check
│ ├── log_placeholders_test.dart # every AppLogger call has one {} for each argument
│ ├── log_secrets_test.dart # no AppLogger call passes a value named for a credential
│ ├── mock_asset_coverage_test.dart # every repository method succeeds on the mock
│ ├── mock_asset_naming_test.dart # mock asset names use '_', never '-'
│ ├── oidc_platform_setup_test.dart # OIDC scheme placeholder, no client secret
│ ├── sentry_native_scrub_patterns_test.dart # Kotlin scrub patterns equal the Dart ones, Gradle DSN gate
│ ├── test_file_naming_test.dart # forbids *.test.dart (dot) files
│ └── ui_text_localized_test.dart # widgets show ARB text through S, not string literals
├── fixtures/ # inputs and expected outputs shared with the Android JVM tests
├── app/ # app/ layer tests (app_test.dart, router, shell, theme, di, ...)
├── core/ # core/ tests (errors, logging, security, result, ...)
├── features/<feature>/ # application/ data/ domain/ navigation/ presentation/
├── infrastructure/ # config, http (+ interceptors), storage, cache, ...
├── integration/ # app_smoke_test.dart, the full-app smoke test
└── shared/ # design_system, l10n, models, utils, widgets
flutter test automatically discovers test/flutter_test_config.dart and wraps
every test under test/ with its testExecutable. This is where all
cross-cutting setup lives:
// test/flutter_test_config.dart
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
// One-time per isolate.
TestWidgetsFlutterBinding.ensureInitialized();
AutomatedTestWidgetsFlutterBinding.instance.defaultTestTimeout = const Timeout.factor(1); // see "Timeouts" below
AppLogger.configure(isProduction: false, logFormat: LogFormat.simple);
EquatableConfig.stringify = true;
registerAllFallbackValues();
// Per-test in every file, opted out or not (see "The asset cache" below).
setUp(rootBundle.clear);
tearDown(rootBundle.clear);
// Per-test, unless a file opts out.
setUp(() async { if (TestEnv.autoReset) await TestEnv.reset(); });
tearDown(() async { if (TestEnv.autoReset) await TestEnv.reset(); });
await testMain();
}What this means for you: a normal test file needs no manual setup for storage, router, secure-storage, fallback registration, or logging. Just write the test. The environment is clean before and after every test.
A static helper — the single test-support entry point:
| Member | Use |
|---|---|
TestEnv.reset() |
Re-installs the secure-storage mock and clears all storage. No language is stored, so an App starts in the language passed to it, as on a first launch. Called automatically by the bootstrap. |
TestEnv.authenticate() |
Seeds a mock JWT. Call in setUp or a test body — never in setUpAll. |
TestEnv.apiClient({Dio? dio}) |
Builds a mock-backed ApiClient (test AppConfig, shared secure adapter). Pass a stub dio to control responses. |
TestEnv.autoReset |
Set false in a file's setUpAll to opt that file out of the global reset. |
Key facts that make this safe
flutter testruns each file in its own isolate, soTestEnv.autoResetis isolated per file — opting out in one file cannot affect another.- A file's
setUpAllruns after the bootstrap body (which registers the globalsetUp) but before the first test'ssetUp, so flippingautoResetinsetUpAllis observed by the global reset at runtime.- The global
setUpclears the secure store before every test — that's whyauthenticate()must run insetUp/test body, notsetUpAll.
A file that installs its own MethodChannel or SharedPreferences mock should
opt out so the global reset doesn't trample it:
void main() {
setUpAll(() => TestEnv.autoReset = false); // first thing in main()
// ... file manages its own setUp/tearDown ...
}Real example: test/infrastructure/storage/local_storage_test.dart installs a
MockSharedPreferences per group, so it opts out and resets explicitly.
A mock on a channel the reset never touches needs no opt-out.
test/support/url_launcher_stub.dart installs one for url_launcher in the test body and removes it when the test ends, so its tests keep the reset and TestEnv.authenticate().
rootBundle caches the Future of every string asset it loads, including the assets/mock/*.json files behind MockInterceptor.
Code that awaits a cached Future is resumed by a microtask of the zone that created it.
Each testWidgets body runs in its own FakeAsync zone, and nothing runs that zone's microtasks once the test ends.
So once a testWidgets had loaded an asset, every later testWidgets in the same file that needed it waited forever: only the first test of a file got its mock responses, and the others hung until they timed out.
The bootstrap therefore empties the cache with rootBundle.clear() before and after every test, in every file, including files that opt out of TestEnv.reset(), so no test's cached Future outlives it, not even into a later group's setUpAll.
test/support/test_env_test.dart and test/flutter_test_config_test.dart guard this.
Every test, testWidgets included, times out after the 30 seconds set in dart_test.yaml, or twice that in a file tagged @Tags(['integration']).
On its own, testWidgets passes the binding's defaultTestTimeout of 10 minutes to test(), and a test's own duration replaces the configured timeout, so a hung widget test held CI for 10 minutes.
The bootstrap sets defaultTestTimeout to Timeout.factor(1), which keeps the configured timeout as a plain test() does, and test/flutter_test_config_test.dart guards it.
A test that needs longer gets its own timeout and a comment that says why, as test/architecture/feature_generator_test.dart has @Timeout(Duration(minutes: 5)) for its nested flutter test run, instead of a higher timeout for everyone in dart_test.yaml.
No HTTP, no bootstrap concerns. Pure mocktail:
import 'package:mocktail/mocktail.dart';
import '../../../../mocks/mock_classes.dart';
void main() {
late MockIConnectionRepository mockRepo;
late ListManagedConnectionsUseCase useCase;
setUp(() {
mockRepo = MockIConnectionRepository();
useCase = ListManagedConnectionsUseCase(mockRepo);
});
test('returns the managed connections of the repository', () async {
when(() => mockRepo.listManaged()).thenAnswer((_) async => const Success(connections));
final result = await useCase.call();
expect(result, isA<Success<List<StudioConnection>>>());
verify(() => mockRepo.listManaged()).called(1);
});
}Mock the use case (or repository), assert exact state transitions:
blocTest<ConnectionListBloc, ConnectionListState>(
'emits [loading, loaded] when the list loads',
setUp: () => when(() => repository.listManaged()).thenAnswer((_) async => const Success(connections)),
build: () => ConnectionListBloc(listManagedConnectionsUseCase: ListManagedConnectionsUseCase(repository)),
act: (bloc) => bloc.add(const ConnectionListRequested()),
expect: () => const [ConnectionListLoading(), ConnectionListLoaded(connections: connections)],
);- Assert on sealed variants (
isA<ConnectionListLoaded>()or matching variant fields), not on a status enum. MockBloc(frombloc_test, declared inmock_classes.dart) is used by widget tests that need to feed a BLoC a scripted state stream.
Repositories talk to ApiClient. In tests, build the client via
TestEnv.apiClient() (mock-backed by assets/mock/*.json) or inject a stub Dio
for failure paths.
testWidgets('renders the shell when authenticated', (tester) async {
TestEnv.authenticate(); // in the test body, AFTER global reset
await tester.pumpWidget(const App(language: 'en'));
await tester.pumpAndSettle();
expect(find.byType(ResponsiveScaffold), findsOneWidget);
});Provide BLoC state with a MockBloc so the UI renders deterministically without
real business logic.
Verifying actions — by event type, not any(). Assert the dispatched event
with a type matcher and an explicit count:
verify(() => bloc.add(any(that: isA<SsoSignInRequested>()))).called(1); // action fired
verifyNever(() => bloc.add(any(that: isA<SsoSignInRequested>()))); // must NOT fire (e.g. a misconfigured build)Do not use verify(() => bloc.add(any())).called(...) or .called(greaterThan(0)):
screens add an init event on mount (a load/Reset), so a loose verify is satisfied
even if the action under test never fired — a false-confidence test. (A real case:
a "submit valid form" test passed under add(any()) while the form was actually
invalid — a required field unfilled — so no submit ever dispatched. Tightening to
isA<…Submit>() exposed it; the fix was to make the test fill a genuinely valid form.)
Other screen-test rules: don't keep a repository mock that isn't injected (the
screen is driven through its mock BLoC); prefer whenListen + a seeded state over
re-implementing the bloc's state machine inside an add stub; use unique fixture
values so findsOneWidget is unambiguous (avoid common tokens like 'User'); size
the test surface with tester.view.physicalSize + addTearDown(tester.view.reset)
(one consistent API — not binding.setSurfaceSize); and assert navigation by what
the destination renders, not just that the source screen disappeared.
A testWidgets body runs in fake time, and tester.pump() without a duration does not move it: it runs microtasks and draws a scheduled frame, but it fires no timer, not even one of zero length.
Dio runs each interceptor step in a Future, which starts on a zero-length Timer, so a request through ApiClient, whether the test sends it through TestEnv.apiClient() or the pumped app sends it, stays where it is however often the test calls pump().
A test that ends there fails with A Timer is still pending even after the widget tree was disposed and a stack through DioMixin.fetch.
Awaiting the request in the test body is worse: nothing pumps while the body waits, so the test hangs until it times out.
Start the request without awaiting it, pump with a duration, then check or await the result:
TestEnv.authenticate(); // the secure store is empty at the start of every test, and /auth/me needs a token
Response<String>? response;
unawaited(TestEnv.apiClient().get('/auth/me').then((value) => response = value));
await tester.pump(Duration.zero); // any duration fires the timers that are due, Duration.zero included
expect(response, isNotNull, reason: 'the mock request never completed');pumpAndSettle() completes such a request as well, because its first pump advances 100 ms, but it stops as soon as no frame is scheduled.
So it does not wait out the 500 ms that MockInterceptor adds under AppConfig.dev(), which App uses unless the test passes other dependencies: pump at least that long before settling, as pumpAppOnSettings in test/features/settings/presentation/pages/settings_screen_test.dart does.
Every test that pumps App sends at least one request: the Studio status check reads GET /health when the app starts, unless the test replaces the Studio status repository.
Do not use tester.runAsync to let a mock request finish: it runs the request in real time, and it is meant for real I/O.
test/support/test_env_test.dart shows the pattern.
Inject a custom interceptor through the dio parameter to simulate transport
errors:
late _StubInterceptor stub;
late ApiClient client;
setUp(() {
stub = _StubInterceptor();
final testDio = Dio(BaseOptions(baseUrl: 'https://test.api', responseType: ResponseType.plain));
testDio.interceptors.add(stub);
client = ApiClient(appConfig: const AppConfig.prod(), dio: testDio);
});
test('maps 401 to UnauthorizedException', () async {
stub.stubDioError(DioExceptionType.badResponse, statusCode: 401);
expect(() => client.get('/endpoint'), throwsA(isA<UnauthorizedException>()));
});ApiClient is constructed via dependency injection — there is no global
singleton or setTestInstance.
test/integration/app_smoke_test.dart boots the whole app in mock mode
(LocalSignInDependencies(), as main_local.dart passes it: AppConfig.dev(), which serves
assets/mock/*.json, and the local sign-in in place of Keycloak) and drives cold-start → single sign-on → connection list, exercising
routing + DI + BLoCs + the mock HTTP layer together. It is a full-app widget test
(TestWidgetsFlutterBinding), so it runs headless under the normal
flutter test — no device, no integration_test package, no CI changes. The
global bootstrap supplies the secure-storage mock + a clean storage reset, so the
app starts unauthenticated and lands on login. Scope is one happy-path smoke — not
multi-flow e2e. (For real on-device e2e later, add integration_test back and a
device-targeted CI job.)
All shared doubles live in test/mocks/:
mock_classes.dartMock*classes — repositories (concrete + interface),MockBloc<E,S>for each BLoC,MockSharedPreferences.Fake*classes — fallback values for non-nullableany()arguments.registerAllFallbackValues()— registers every fallback once; called globally by the bootstrap, so you do not call it per file.
fake_data.dart: shared fixtures (mockUserFullPayload,kTestInstant, ...). Prefer these over building entities inline.
Three categories — reach for a mocktail mock first; drop to a hand-fake only for genuine stateful behavior; Dio handlers are always hand-written:
- mocktail mock (default) — the
Mock*classes inmock_classes.dart. Use for interface doubles where the test only configures return values (when(...).thenReturn/thenAnswer) and verifies calls (verify). This is the default for repository / use-case / BLoC doubles. - hand-written fake — a private
_Fake*/_Memory*class next to the test, when the double needs real stateful or behavioral logic that mocktail makes awkward: in-memory stores, throw-on-Nth-call, selective/sequenced failures, return-driven branching (e.g._FakeAuthRepositoryinlogin_bloc_test.dart,_MemorySecureStorage). These are purpose-built per test — two same-named fakes in different files implement different surfaces and are not duplication; do not consolidate them into a shared "god" fake. - Dio handler / interceptor stub — a private
_Test*Handler/_StubInterceptorextending Dio'sInterceptor/RequestInterceptorHandler/ResponseInterceptorHandler/ErrorInterceptorHandler. Used to test interceptors at the Dio layer; they can't be mocked cleanly, and each records the signals its test asserts on.
test/architecture/ holds meta-tests that fail the build on rule violations:
import_guard_test.dartenforces layer dependency rules (core → nothing,shared → core,features → shared/infra/core, no cross-feature internals). Known exceptions are listed explicitly and should shrink over time, not grow. It also fails if a file other thanlib/shared/utils/external_link.dartimportsurl_launcher. Links go throughopenExternalLink, which tells the user when a link does not open and never askscanLaunchUrl, so the Android manifest needs no<queries>intent for them.test_file_naming_test.dartfails if any file is named*.test.dart(a dot instead of_test.dart) — such files compile and passanalyzebut are silently never executed byflutter test.android_manifest_test.dartfails ifAndroidManifest.xmldrops theINTERNETpermission,android:allowBackup="false", or the references to the two backup rule files, or if either rule file stops excluding shared preferences. Release builds need the permission to reach the API, and a restored backup or device transfer brings back session tokens that no longer decrypt. CI builds the release APK, but that build succeeds without any of these.mock_asset_naming_test.dartfails if a file underassets/mock/has a-in its name.MockInterceptormaps-in request paths to_, so such a file is never served and the request fails with 501.mock_asset_coverage_test.dartdrives every method of every repository that usesApiClientthroughTestEnv.apiClient(); it fails when one of them does not succeed.MockInterceptorfails a GET, POST, PUT or PATCH whose mock file is missing with 501, so a missing file fails the test and the failure names it. A last test reads the repository interfaces underlib/and fails when a method is not driven, so a new repository method needs an entry in the test, and its mock file, before the build passes.feature_generator_test.dartrunstool/generate_feature.dartin a temporary copy of the project and fails if the generated feature is not formatted, does not analyze cleanly or fails its own tests. Nothing else compiles the generator's templates, so they drift silently when an API they call changes. The generated repository and route tests go through the realApiClientandMockInterceptor, so a mock file named differently from the request it should answer fails the guard too. It is the slowest guard, mostly because of the nestedflutter testrun.log_placeholders_test.dartfails if the message of anAppLoggercall inlib/has a different number of{}than the call has arguments.AppLoggernever prints an argument with no{}left and prints a{}with no argument as it is, so such a call leaves a value out of the line or shows a bare{}, and neither fails at runtime or influtter analyze. It reads the calls whose message is a string literal and whose arguments are a list literal.log_secrets_test.dartfails if an argument of anAppLoggercall inlib/, or an interpolation in its message, names a credential: an identifier that contains one ofsensitiveBodyKeySubstrings, such aspasswordChangeDTOorevent.otpCode. The console of every build that prints the level keeps the line, so log that the action happened instead. A token may still be logged throughmaskToken, which keeps four characters at each end.credential_to_string_test.dartfails if a class inlib/has aStringfield whose name contains one ofsensitiveBodyKeySubstrings, such aspassword,otpCodeorrefreshToken, and itstoStringis not overridden, prints such a field, or prints the props. A log line, the dev console and a Sentry event print an object throughtoString, and an Equatable prints its props there wheneverstringifyis on, the default of every debug build. A token may be printed throughmaskToken; a password or an OTP is printed assecretMask.oidc_platform_setup_test.dartguards the platform setup of the single sign-on (docs/oidc.md). It fails ifdefaultConfiginandroid/app/build.gradle.ktsdoes not setmanifestPlaceholders["oidcRedirectScheme"]toOidcSettings.defaultRedirectScheme: theoidc_androidmanifest merge needs the placeholder in every build, and the redirect never reaches the app when it differs from the scheme the Dart side sends. It fails if a line inlib/names a client secret in any spelling (client_secret,OIDC_CLIENT_SECRET,clientSecret,clientSecretPost), except the scrubber's doc comment, or if anyOidcClientAuthenticationinlib/is other than.none, since the app is a public PKCE client. It fails if anyandroid/app/src/*/AndroidManifest.xmlmentions the redirect scheme or theoidcRedirectSchemeplaceholder: an intent filter there would let Android offerMainActivityfor the callback instead of the package's redirect activity.sentry_native_scrub_patterns_test.dartguards the Kotlin copy of the scrub policy that the Android host masks native Sentry events and breadcrumbs with (NativeScrubPatterns.kt,NativeSentryScrubber.ktunderandroid/app/src/main/kotlin). It fails if a Kotlin pattern, set or mask differs from the Dart one inlib/core/security/sensitive_data_scrubber.dartorlib/infrastructure/analytics/sentry_scrub.dart, if a shared pattern uses\w,\d,\b,\s, a bare.or a case-insensitive flag, which ICU, OpenJDK and Dart read differently, or if the Kotlin scrubber compiles a pattern with an option. It fails ifandroid/app/build.gradle.ktspassesSENTRY_DSNto the native SDK other than through the check on the target file name, or if that file is not the only entry point that selects the prod environment, and ifMainActivity.ktstops answering the channelnativeSdkReadyasks. The logic of the two copies is held equal bytest/fixtures/native_scrub_cases.jsonandtest/fixtures/native_scrub_map_cases.json, which the Dart tests and the Android JVM tests (android/app/src/test/kotlin) both run; see "Running tests" for the JVM command.
The single sign-on has more guards next to the code they guard:
test/app/di/app_scope_test.dartfails if a misconfigured build creates or provides an OIDC client.test/architecture/local_sign_in_test.dartfails ifmain_prod.dartimports the debug-only local sign-in ofmain_local.dart, directly or not.test/features/auth/data/repositories/auth_session_repository_impl_test.dartfails if the session clear leaves the OIDC ID token or the access token expiry behind, since the clear deletes keys one by one and is not exhaustive.test/app/router/app_router_test.dartfails if a callback URL such as/oauth2redirect?code=...&state=...becomes a router location or areturnUrl, since the router logs locations.test/features/auth/sso_log_hygiene_test.dartdrives a full sign-in, refresh, logout and pushed callback route against the fake provider oftest/infrastructure/auth/oidc/support/fake_provider.dart, and fails if AppLogger,printedLinesor apackage:loggingrecord shows a token, the code, the verifier,stateor the nonce.
- Time: use
fakeAsyncfor time-based logic (timers, debounce, timeouts).event_transformers_test.dartis the reference — it drives the bloc insidefakeAsyncand advances virtual time withasync.elapse(...), with no wall-clock dependency.idle_timeout_observer_test.dartuses the same approach. - Mocked async: do not use real-time
blocTest(wait:)to "let a mock settle" —blocTestalready awaits the bloc's state stream. Sequence dependent events withawait bloc.stream.firstWhere((s) => s is <State>), not a millisecond delay. - Fixtures: use the fixed
kTestInstant(test/mocks/fake_data.dart) for any fixture timestamp whose exact value no assertion depends on — neverDateTime.now(). - Goldens: golden / visual-regression tests use
alchemist. Each golden test lives next to the code it covers (feature-first, mirroringlib/— same as every other test), with its images in a siblinggoldens/ci/dir; e.g.test/shared/design_system/components/app_button_golden_test.dart,test/features/auth/presentation/pages/login_screen_golden_test.dart. The bootstrap sets a defaultAlchemistConfig(light theme) and disables platform goldens, so only the CI (Ahem-rendered) variant runs — identical on macOS/Linux/CI, no font flake. Golden files setsetUpAll(() => TestEnv.autoReset = false)and usepumpBeforeTest: pumpOncefor animated widgets. Regenerate withflutter test --tags golden --update-goldens. Coverage: all design-system components, shared widgets, and key screens (rendered with mocked BLoCs via thetest/support/golden_app.dartgoldenScreenhelper) — 60 goldens, light + dark. The responsive shell / home screen is intentionally not goldened (it boots via real DI inApp()rather than mockable to a single deterministic frame; its sub-widgets are already covered by the component/widget goldens). - Tags:
dart_test.yamldeclareswidget(applied to alltestWidgetsfiles via@Tags(['widget'])) andgolden(auto-applied by alchemist). Anintegrationtag is declared but reserved for a possible future on-device (flutter drive) suite — the current e2e smoke is atest/integration/widget test (taggedwidget). Slice the suite withflutter test --tags widget/--exclude-tags widget. The per-test timeout (30s) lives indart_test.yamland applies totestWidgetstoo (see "Timeouts" above). - Pumping: use
pumpAndSettle()only when all animations are expected to finish (route/page transitions, one-shot reveals). ItsDurationargument is the per-pump interval, not a timeout (the timeout is a separate, later parameter), so prefer the bare form. For continuous / indeterminate animations (spinner, shimmer, looping) neverpumpAndSettle: it pumps until its own timeout of 10 minutes of fake time, 6000 frames at the default interval, and then throwspumpAndSettle timed out, usually within seconds, unless slow frames reach the 30 second test timeout first. Assert the spinner withpump()and usepump(const Duration(...))for a fixed frame; in goldens usepumpBeforeTest: pumpOnce.
These are documented so newcomers know what is intentional vs. not-yet-done.
CI computes line coverage with generated/bootstrap code excluded, via
scripts/check_coverage.sh (excludes lib/generated/**, *.g.dart,
*.freezed.dart, lib/main/main_*.dart, *.config.dart). The current floor is
65%. Run locally:
fvm flutter test --coverage
scripts/check_coverage.sh coverage/lcov.info 65fvm flutter test # whole suite
fvm flutter test test/features/connections # a subtree
fvm flutter test test/path/to/specific_test.dart # one file
fvm flutter test --coverage # with coverage
fvm flutter test --concurrency=1 --test-randomize-ordering-seed=random # flush order/leak bugsThe Android JVM unit tests (android/app/src/test/kotlin) cover the Kotlin scrubber of native Sentry events and run in the Build Android job of CI.
They need JDK 17 and the Gradle wrapper, which is not committed: any Flutter Android build writes it, for example fvm flutter build apk --config-only --target lib/main/main_prod.dart.
cd android && ./gradlew :app:testDebugUnitTest -Ptarget=lib/main/main_prod.dart- Create
test/features/<feature>/mirroring the feature'slib/structure. - Use case tests (
application/usecases/): mock the repository interface (MockI<Feature>Repository) withmocktail. - BLoC/Cubit tests (
application/):blocTest, assert sealed state variants; mock the use case or repository. - Repository tests (
data/repositories/):TestEnv.apiClient()or a stub Dio. Add each repository method that sends requests throughApiClientto the table intest/architecture/mock_asset_coverage_test.dart. - Widget tests (
presentation/):MockBlocfor state;TestEnv.authenticate()insetUp/body for auth-gated screens. - Add any new mocks/fakes to
test/mocks/mock_classes.dart(and register fallbacks inregisterAllFallbackValues()if you useany()with a new non-nullable type). - Do not add per-file binding/logger/storage setup — the bootstrap handles
it. Only opt out (
TestEnv.autoReset = false) if your file installs its own channel/prefs mock.
| Purpose | Path |
|---|---|
| Global bootstrap | test/flutter_test_config.dart |
| Test-support harness | test/support/test_env.dart |
| Shared mocks/fakes | test/mocks/mock_classes.dart, test/mocks/fake_data.dart |
| Layer dependency guard | test/architecture/import_guard_test.dart |
| Test-file naming guard | test/architecture/test_file_naming_test.dart |
| Android manifest guard | test/architecture/android_manifest_test.dart |
| Mock asset naming guard | test/architecture/mock_asset_naming_test.dart |
| Mock asset coverage guard | test/architecture/mock_asset_coverage_test.dart |
| Feature generator guard | test/architecture/feature_generator_test.dart |
| Log placeholder guard | test/architecture/log_placeholders_test.dart |
| Log secrets guard | test/architecture/log_secrets_test.dart |
| Credential text guard | test/architecture/credential_to_string_test.dart |
| OIDC platform setup guard | test/architecture/oidc_platform_setup_test.dart |
| Native Sentry scrub guard | test/architecture/sentry_native_scrub_patterns_test.dart |
| Fixtures shared with the Android JVM tests | test/fixtures/ |
| OIDC log hygiene | test/features/auth/sso_log_hygiene_test.dart |
| Fake OpenID provider | test/infrastructure/auth/oidc/support/fake_provider.dart |
| Coverage gate | scripts/check_coverage.sh |
| Mock data layer | docs/mock-architecture.md |
| Conventions | CLAUDE.md → Testing |