write-tests
Skill0 starsChanged 56 days agoMITView on GitHub
Write unit and widget tests — the testing pyramid, where each layer is tested, mocktail fakes at the right seam, MobX observable assertions, tracking verified on failure paths, JSON fixtures, widget tests for the loading/error/empty/content branches, keyed rows that survive a removal, overriding the page state (getIt for a whole page, Provider.value for a sub-widget), request-shape tests for API providers, and the "can this fail if the real code is broken?" check. Use whenever adding or changing a store, state, use case, service, platform channel or page.
76 other projects use a version of this.
Adopted by
Most recently taken up by
- Doist/todoist-aitheir own version, first seen Nov 9, 2026
- CheckPickerUpper/skillstheir own version, first seen Nov 8, 2026
- Doist/todoist-clitheir own version, first seen Nov 8, 2026
- aniketshukla1/sdlc-kittheir own version, first seen Nov 8, 2026
- helmut-hoffer-von-ankershoffen/heinzeltheir own version, first seen Nov 8, 2026
- unlockers-ai/unlockers-skills-librarytheir own version, first seen Nov 8, 2026
Used with
Projects with it also use close-spec, design-with-images and 10 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
dzhngSkillclose-spec
When a spec under specs/<feature>/ is done shipping (a write-spec build, or any planned task that produced a spec), archive it to specs/done/ and rewrite it from a build-plan into a durable rationale — the why, the principles, the invariants — pointing back to the real code for the how. Use when implementation has landed and the plan no longer matches what shipped, or the user says a feature/spec is finished. Pairs with [write-spec](../write-spec/SKILL.md) (the plan this closes) and [review](../review/SKILL.md) (run before closing).
Also in 5 projects that use this
dzhngSkilldesign-with-images
Explore UI and visual design with image generation, then iterate the real implementation against the user-selected concept. Use when brainstorming visual directions, offering mockup variants, or implementing an approved generated design; do not stop at the mockups or the first code approximation.
Also in 5 projects that use this
transitive-bullshitSkillcompare-screenshots
Compare screenshots against the intended design, distinguishing approved references from historical baselines. Use for iterative implementation matching, before/after visual review, objective image telemetry, or checking a lone capture for flat, empty, or badly framed content.
Also in 5 projects that use this
LucasMatuszewskiSkillaudit-agents
Audit and maintain agent configuration files (.claude/agents, AGENTS.md, CLAUDE.md, agent-memory). Checks for duplicates, outdated info, naming conflicts, wrong technical facts, and files over 200 lines. Fixes all issues and commits.
Also in 5 projects that use this
wolverin0Skillaudit-decisions
Standardize remediation decisions, baseline-policy entries, and legal-review banners for accepted, deferred, or policy-blocked audit findings.
Also in 4 projects that use this
rhnfzlSkillexplore-unknowns
Walk the user through mapping the unknowns of a task before, during, and after they build it, and hand them a four-quadrant map (settled ground, open decisions, taste, and landmines) they keep. Reach for this eagerly whenever a request is ambiguous or underspecified, the codebase or domain is unfamiliar, the user will "know it when they see it", a reference implementation must be understood before porting, a build-queue or architecture-changing task is about to start, a mid-build deviation needs capturing, or a finished change needs buy-in or verified understanding before merge. Trigger even when the skill is not named: "do a blindspot pass", "find my unknown unknowns", "interview me about this change", "brainstorm the approach for a task I am about to build", "help me scope this", "what am I missing here", "plan this before I build", "I know nothing about X in this codebase". When a territory profile is present it reads memory and the graph first and feeds the map into the build pipeline; without one it degrades to a plain general map. Not for trivial or mechanical tasks with clear acceptance criteria, single-fact lookups, pure debugging of a known symptom, or pure code review, those have their own tools. Do not use it to run the build itself; the map is the deliverable and implementation remains a separate task, whether it has not started or is already underway.
Also in 5 projects that use this
- +5 more from dzhng
Other versions76 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 76
- remix-run/remix★ 33ksame name
- elie222/inbox-zero★ 13ksame name
- getsentry/sentry-javascript★ 8.8ksame name
- dzhng/jevgrep★ 3.2ksame name
- meta-pytorch/OpenEnv★ 2.8ksame name
- huggingface/OpenEnv★ 2.8ksame name
- JamieMason/syncpack★ 2.1ksame name
- dzhng/skills★ 1.5ksame name
- clice-io/clice★ 1.4ksame name
- ryokun6/ryos★ 1.3ksame name
66 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository18 other files it gives its agents.
- payload/AGENTS.mdAGENTS.md
- add-design-tokenSkill
- add-localizationSkill
- add-platform-channelSkill
- add-routeSkill
- create-adrSkill
- create-api-providerSkill
- create-ds-componentSkill
- create-dtoSkill
- create-featureSkill
- create-pageSkill
- create-storeSkill
- create-use-caseSkill
- handle-errorsSkill
- plan-featureSkill
- qa-featureSkill
- triage-bug-reportSkill
- write-maestro-flowSkill
The file
We split this skill into the rules it gives an agent
9 rules. 0 are common, 5 are distinctive, and 4 are very specific to this project.
0
Common
Most of the 71 edited versions we read keep these.
5
Distinctive
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 59
Always group(), named after the class under test.
- Very specific: line 60
Test names begin with should: test('should set error when the request fails', …).
- Distinctive: line 66
Before committing a test, ask: "can this fail if the real code is broken?"
- Distinctive: line 77
Never assert a stub's own side effect: When a stub changes something and the test reads that back, the test checks the stub.
- Distinctive: line 147
Every store test covers at minimum: success, API failure, empty result.
- Distinctive: line 215
When the assertion is about a *change* rather than a final value, use MobX's own tools:
- Very specific: line 227
Always dispose the reaction, or it leaks into the next test.
- Distinctive: line 295
Test wiring, not logic: does the branch render, does the callback fire.
- Very specific: line 357
Use test/helpers/pump_app.dart.
“Common” is measured: kept by at least 6 in 10 of the 71 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Write unit and widget tests
Governed by ADR-0015. Unit and widget tests are two of the three mandatory tiers; the third is write-maestro-flow.
Concepts behind this playbook: state-management-guide.
The pyramid
Many unit tests, fewer widget tests, few E2E flows.
| Tier | How many | What it proves | Cost |
| Unit | many — every branch of logic | a store, state, use case or service does the right thing on success, failure and empty | milliseconds, no device |
| Widget | fewer — the branches of each page | the page renders loading / error / empty / content and wires its callbacks | a second or so, no device |
| E2E (Maestro) | few — happy, failure, edge per feature | the journey works on a real device, through DI, routing and localization bootstrap | minutes, a booted device |
Push each check down to the cheapest tier that can catch the bug. An edge case in the paging logic is a unit test, not a Maestro flow. A widget test that re-checks business logic duplicates a unit test and runs ten times slower. If a bug can only be seen on a device, that is what the E2E tier is for.
Where tests live
test/ mirrors lib/: a test sits at its source file's path. The tier is set by what the test exercises (test() or testWidgets()), not by the folder, so a page state's unit test and its page's widget test sit side by side in view/.
test/
features/{feature}/
mobx/{feature}_store_test.dart
view/{feature}_page_state_test.dart unit
view/{feature}_page_test.dart widget
view/use_cases/{name}_use_case_test.dart next to its source
modals/{modal}/mobx/{modal}_state_test.dart
modals/{modal}/use_cases/{name}_use_case_test.dart
core/… services (interceptors/ too), platform channels, utils
helpers/pump_app.dart the widget-test harnessCreated with their first use, since none exist yet: test/helpers/fixture.dart and test/fixtures/ (see Fixtures), and packages/api/test/providers/{resource}_provider/{resource}_api_provider_test.dart for request-shape tests.
Test the seam below the unit
Each layer has exactly one collaborator type to fake (ADR-0001):
| Testing | Fake | Never fake |
| Store | DioService (or the provider it exposes), IssueTrackingService | the store's own observables |
| Page state | the store, AppNavigator, use cases | the state under test |
| Use case | DioService / services / AppNavigator | — |
| Platform channel wrapper | the native side, via a mock method-call handler | the wrapper under test |
| Page (widget) | the page state | the widget tree |
Prefer real objects > fake > mock. A real TodoDto beats a mocked one. A hand-written fake store with a settable field beats a mocked store with five when(...) stubs.
Conventions
- Always
group(), named after the class under test. - Test names begin with
should:test('should set error when the request fails', …). - Arrange-Act-Assert, with a blank line between the three.
- One behaviour per test. A test asserting four unrelated things tells you nothing when it goes red.
The check that matters
Before committing a test, ask: "can this fail if the real code is broken?"
// ❌ asserts that a collaborator was called, not what happened
when(() => store.loadTodos()).thenAnswer((_) async {});
await state.init();
verify(() => store.loadTodos()).called(1);Assert the observable outcome — state after the act — not just that a collaborator was called. verify is right when the only effect is the call (navigation, analytics, error tracking); it is wrong as a substitute for asserting resulting state.
Never assert a stub's own side effect. When a stub changes something and the test reads that back, the test checks the stub. It stays green when the code under test is broken.
// ❌ the stub removes the todo, and the test reads the stub's list back.
// Nothing here depends on how the page renders the removal.
when(() => state.onTodoDismissed('2')).thenAnswer((_) async {
runInAction(() => todos.removeWhere((todo) => todo.id == '2'));
});
// ... swipe row '2' ...
expect(todos, hasLength(2));
// ✅ same stub, but assert what the page did: it called the state with the
// right id, and the row left the tree
verify(() => state.onTodoDismissed('2')).called(1);
expect(find.byKey(const ValueKey('2')), findsNothing);Store test
class _MockDioService extends Mock implements DioService {}
class _MockTodosApiProvider extends Mock implements TodosApiProvider {}
class _MockAuthStore extends Mock implements AuthStore {}
class _MockIssueTrackingService extends Mock implements IssueTrackingService {}
void main() {
late _MockDioService dioService;
late _MockTodosApiProvider todosProvider;
late _MockIssueTrackingService issueTracking;
late HomeStore store;
setUp(() {
dioService = _MockDioService();
todosProvider = _MockTodosApiProvider();
issueTracking = _MockIssueTrackingService();
when(() => dioService.todosProvider).thenReturn(todosProvider);
store = HomeStore(dioService, _MockAuthStore(), issueTracking);
});
group('HomeStore', () {
test('should expose loaded todos and clear loading on success', () async {
when(() => todosProvider.getTodos()).thenAnswer(
(_) async => [TodoDto(id: '1', title: 'a', completed: false)],
);
await store.loadTodos();
expect(store.todos, hasLength(1));
expect(store.isLoading, isFalse);
expect(store.error, isNull);
});
test('should set error and clear loading when the request fails', () async {
when(() => todosProvider.getTodos()).thenThrow(
DioException(requestOptions: RequestOptions(), message: 'boom'),
);
await store.loadTodos();
expect(store.error, 'boom');
expect(store.isLoading, isFalse); // the finally-block regression guard
expect(store.todos, isEmpty);
});
});
}Every store test covers at minimum: success, API failure, empty result. The failure case must assert that loading was cleared — a spinner that never stops is the most common production symptom.
Working example: test/features/home/mobx/home_store_test.dart.
Verify tracking on every failure path
A catch that swallows an error must track it (ADR-0020), and the failure-path test proves it. Mock IssueTrackingService and verify the exact method and handlingContext / context. When the caller shows its own error, also verifyNever on trackIssue — that is the guard against two messages for one failure.
test('should report a failed load as handled, not as a notice', () async {
final error = DioException(
requestOptions: RequestOptions(path: '/todos'),
message: 'boom',
);
when(() => todosProvider.getTodos()).thenThrow(error);
await store.loadTodos();
verify(
() => issueTracking.trackHandledError(
error,
stackTrace: any(named: 'stackTrace'),
handlingContext: 'load_todos',
),
).called(1);
verifyNever(
() => issueTracking.trackIssue(
any(),
stackTrace: any(named: 'stackTrace'),
message: any(named: 'message'),
context: any(named: 'context'),
notifyOnConnectivityError: any(named: 'notifyOnConnectivityError'),
),
);
});verifyNever lists every named argument with any(named: …). Leave one out and mocktail only matches calls that used its default, so the guard misses the rest.
For trackIssue, verify context: and message: as values, and notifyOnConnectivityError: on a rollback path. A timeout is the case that flag exists for, so use one. .tr() needs setUpAll(initLocalizationForTests) from test/helpers/pump_app.dart. Illustrative: onTodoDismissed and homePage_deleteTodoFailed do not exist in this repo.
test('should notify, even on a timeout, when a delete rolls back', () async {
final error = DioException(
requestOptions: RequestOptions(path: '/todos/2'),
type: DioExceptionType.receiveTimeout,
);
when(() => homeStore.deleteTodo('2')).thenThrow(error);
await state.onTodoDismissed('2');
verify(
() => issueTracking.trackIssue(
error,
stackTrace: any(named: 'stackTrace'),
context: 'delete_todo',
message: LocaleKeys.homePage_deleteTodoFailed.tr(),
notifyOnConnectivityError: true,
),
).called(1);
});- A layer that rethrows must not track: verify
verifyNeveron all three methods there, and the tracking in the caller's test. For a store that restores then rethrows, also assert the state came back: the removed item is at its old index. - The service's own filtering (offline, cancellation,
401) is tested once, intest/core/services/issue_tracking_service_test.dart. Do not re-test it per store.
Reacting to observables
When the assertion is about a change rather than a final value, use MobX's own tools:
final values = <bool>[];
final dispose = autorun((_) => values.add(store.isLoading));
await store.loadTodos();
expect(values, [false, true, false]);
dispose();Always dispose the reaction, or it leaks into the next test. (A test file that imports all of package:mobx cannot also use mocktail's when — see the gotchas below.)
Fixtures
Realistic API payloads live as files under test/fixtures/, grouped by feature (test/fixtures/todos/todo.json), not as long string literals inside tests. The template has no fixtures yet: add the folder and this helper, at test/helpers/fixture.dart, with the first one:
import 'dart:convert';
import 'dart:io';
/// Reads `test/fixtures/{path}` as a string.
String fixture(String path) => File('test/fixtures/$path').readAsStringSync();
/// Reads `test/fixtures/{path}` as a JSON object.
Map<String, dynamic> jsonFixture(String path) =>
jsonDecode(fixture(path)) as Map<String, dynamic>;final todo = TodoDto.fromJson(jsonFixture('todos/todo.json'));- Paths are relative to the package root, which is where
flutter testandmelos run testrun. - The helper reads synchronously on purpose: async file IO inside
testWidgetsruns in a fake-async zone and never completes unless wrapped intester.runAsync. - A fixture is a copy of a real response. Parsing it through the DTO's
fromJsonalso tests the DTO against what the server sends. - Small, hand-built values (
TodoDto(id: '1', …)) stay inline. Use a fixture when the payload is large, nested, or pasted from the API.
Widget test
Register a mock state in getIt, pump the page through the shared harness, and assert each branch renders.
import '../../../helpers/pump_app.dart';
void main() {
setUpAll(initLocalizationForTests);
late _MockHomePageState state;
setUp(() {
state = _MockHomePageState();
when(state.init).thenAnswer((_) async {});
getIt.registerFactory<HomePageState>(() => state);
});
tearDown(getIt.reset);
testWidgets('should show the empty state when there are no todos', (tester) async {
// Stub every getter the page reads from its state. The real test does
// this once in a `_stubState` helper.
when(() => state.todos).thenReturn(ObservableList.of([]));
when(() => state.visibleTodos).thenReturn([]);
when(() => state.doneCount).thenReturn(0);
when(() => state.filter).thenReturn(TodoFilter.all);
when(() => state.isLoading).thenReturn(false);
when(() => state.error).thenReturn(null);
await tester.pumpApp(const HomePage());
expect(find.byKey(const Key(HomeKeys.emptyState)), findsOneWidget);
expect(find.byKey(const Key(HomeKeys.todoList)), findsNothing);
});
}Working example: test/features/home/view/home_page_test.dart.
- Find by key, never by displayed text. Text is localized and will change. Ids are Strings (so Maestro can use the same value), so wrap them:
find.byKey(const Key(HomeKeys.emptyState)). A list row is found by theValueKey(item.id)it is keyed with. - Test wiring, not logic: does the branch render, does the callback fire. Business logic belongs in the state/store unit test where it is cheap.
- Cover all four branches: loading, error, empty, content — plus one
dark: truepump.
Rows keep their element when a row is removed
A list whose rows can be removed or inserted gets this test (create-page). Capture the Element of a row that survives, remove a row above it, and check the survivor is the same Element, not a rebuilt one:
// import 'package:mobx/mobx.dart' show ObservableList, runInAction;
testWidgets('should keep the rows below a removed todo mounted', (tester) async {
final todos = ObservableList.of([_todo('1'), _todo('2'), _todo('3')]);
when(() => state.todos).thenReturn(todos);
when(() => state.visibleTodos).thenReturn(todos);
// ... the other getters, stubbed as above ...
await tester.pumpApp(const HomePage());
final survivor = tester.element(find.byKey(const ValueKey('3')));
runInAction(() => todos.removeAt(0));
await tester.settleFrames();
expect(tester.element(find.byKey(const ValueKey('3'))), same(survivor));
});- Without
findItemIndexCallback/findChildIndexCallback, row'3'is rebuilt from scratch andsamefails. That is the defect this catches: rows below a removed one lose their state and replay their entry animation. - The test changes the data the way the store would, then asserts what the page did with it. Mutate the
ObservableListinsiderunInAction, as the store would inside an action. A write outside one is not rejected, but it is not batched with the writes around it (state-management-guide).
Overriding the page state
Which override a test uses depends on what it pumps (ADR-0009):
| The test pumps | The state comes from | So the test |
The whole page (HomePage) | the page root's Provider(create: (_) => getIt<HomePageState>()) | registers the mock in getIt and resets it afterwards |
A widget below the root that calls context.read<HomePageState>() | the nearest Provider above it | wraps the widget in Provider<HomePageState>.value — no DI swap |
Whole page:
setUp(() {
getIt.registerFactory<HomePageState>(() => mockState);
});
tearDown(getIt.reset);Sub-widget (any public widget under view/widgets/ that reads the state):
await tester.pumpApp(
Provider<HomePageState>.value(
value: mockState,
child: const TodoListSection(),
),
);Provider.value never calls init() or dispose() on the mock, so stub only what the widget reads. Private widgets (_TodoCard) cannot be imported; test them through the page, or make the widget public in widgets/ when it deserves its own test.
Three gotchas the harness already solves
Use test/helpers/pump_app.dart. It exists because each of these costs an hour to rediscover:
mobxandmocktailboth exportwhen. A test importing both fails to compile with "'when' is imported from both …". Import mobx narrowly:import 'package:mobx/mobx.dart' show ObservableList;- Do not mount
EasyLocalizationper test. Its asset load is asynchronous; on the secondtestWidgetsin a file it never resolves underpump(), so the whole tree renders empty and every finder returns nothing — with no error.initLocalizationForTests()loads the translations once into theLocalizationsingleton insetUpAllinstead. Skipping localization entirely is not an option either:.plural()throwsLateInitializationErroron the uninitialised locale. - Never
pumpAndSettlethese screens. The skeleton loader and the FAB animation never reach a steady state, so it times out after 10 s instead of failing usefully. Use the harness's boundedsettleFrames().
Platform channel test
Fake the native side with a mock method-call handler on the test binary messenger, and clear it in tearDown. Assert the method name and arguments against the contract, the typed result, and the tracking call on a PlatformException. Full example: add-platform-channel.
API provider test
A Retrofit provider's interface is not generated code: its annotations are hand-written, and a store test that mocks the provider cannot see a wrong method or path. Each endpoint gets a request-shape test that builds the real provider over a Dio whose httpClientAdapter is a fake recording the RequestOptions, then asserts method and path (DELETE /todos/2). Full example: create-api-provider.
What not to test
- The contents of generated files (
*.g.dart,*.freezed.dart,*.gr.dart) line by line. Test the behaviour they produce instead: a DTO parsing a fixture, a provider sending the right request. - Framework behaviour — that
Providerprovides, thatObserverobserves. - Getters that return a field.
Running
melos run test # app + packages
flutter test test/features/home/ # one directory
flutter test test/features/home/mobx/home_store_test.dart # one file
flutter test --name 'should set error' # one test, by name
flutter test --reporter expanded # one line per test, for a failing run
melos run test:coverage # lcovChecklist
- Right tier: logic in unit tests, branches in widget tests, journeys in Maestro
- Path mirrors
lib/ group()named after the class; test names start withshould- Faked at the seam below, never at the layer under test
- Store/use-case tests cover success, failure, and empty
- Failure tests assert loading was cleared and verify the tracking method and
context/handlingContext(plusmessagefortrackIssue) verifyNeverontrackIssuelists every named argument- Large payloads come from
test/fixtures/, loaded with the fixture helper (added with the first fixture) - Widget tests find by key, never by displayed text; cover loading / error / empty / content
- Removable or insertable rows: the survivor's
Elementissamebefore and after a removal - New API endpoint: request-shape test over a fake
HttpClientAdapter - Whole page → mock registered in
getIt; sub-widget →Provider.value - Every test could fail if the real code broke; no assertion reads back a stub's own side effect
- Reactions disposed, mock handlers cleared,
getItreset; no cross-test leakage
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

