write-tests
npx skills add https://github.com/netr/2048-flutter-ios --skill write-tests
Agent 安装分布
Skill 文档
Write Tests
Write meaningful tests that prove behavior works. Tests exist to prove YOUR code’s behavior works, not to test Flutter framework internals or duplicate implementation details.
Core Philosophy: The One Question
Before writing ANY test, ask: “What behavior am I proving works?”
If your answer is:
- “That Flutter’s setState works” â DON’T WRITE IT (Flutter is already tested)
- “That my widget has these fields” â DON’T WRITE IT (the compiler already checks this)
- “That Provider/Riverpod notifies listeners” â DON’T WRITE IT (the package tests this)
- “That my game logic produces the right output” â WRITE IT (this is YOUR logic)
- “That this function handles edge cases correctly” â WRITE IT (this is YOUR behavior)
- “That these components integrate correctly” â WRITE IT (this is YOUR integration)
The Three Test Types
1. Unit Tests (70-80% of tests)
Test pure logic without Flutter widgets. Fast, isolated, most valuable.
// test/game_logic_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('GameController', () {
late GameController controller;
setUp(() {
controller = GameController();
});
// â
GOOD: Clear scenario AND expected outcome
test('merge combines adjacent tiles with same value', () {
controller.setBoard([
[2, 2, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
controller.move(Direction.left);
expect(controller.board[0][0], 4);
});
// â
GOOD: Tests edge case in YOUR logic
test('move does nothing when no tiles can move', () {
controller.setBoard([
[2, 4, 2, 4],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
final boardBefore = controller.board.map((r) => [...r]).toList();
controller.move(Direction.left);
expect(controller.board, boardBefore);
});
});
}
2. Widget Tests (15-25% of tests)
Test widgets in isolation. Verify UI responds correctly to state.
// test/widgets/game_tile_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// â
GOOD: Tests YOUR widget's behavior
testWidgets('GameTile displays value when non-zero', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: GameTile(value: 2048),
),
);
expect(find.text('2048'), findsOneWidget);
});
// â
GOOD: Tests YOUR conditional rendering logic
testWidgets('GameTile shows nothing for zero value', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: GameTile(value: 0),
),
);
expect(find.text('0'), findsNothing);
});
// â
GOOD: Tests YOUR gesture handling
testWidgets('GameBoard calls onMove when swiped right', (tester) async {
Direction? capturedDirection;
await tester.pumpWidget(
MaterialApp(
home: GameBoard(
onMove: (dir) => capturedDirection = dir,
),
),
);
await tester.fling(find.byType(GameBoard), const Offset(300, 0), 500);
expect(capturedDirection, Direction.right);
});
}
3. Integration Tests (5-10% of tests)
Test complete user flows. Run on device/emulator.
// integration_test/game_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// â
GOOD: Tests complete user journey
testWidgets('new game starts with score zero and two tiles', (tester) async {
app.main();
await tester.pumpAndSettle();
expect(find.text('0'), findsWidgets); // Score display
// Verify initial board state
});
// â
GOOD: Tests that interactions work end-to-end
testWidgets('game over dialog appears when no moves left', (tester) async {
app.main();
await tester.pumpAndSettle();
// Play until game over (or set up game over state)
// ...
expect(find.text('Game Over'), findsOneWidget);
});
}
Writing Quality Tests
Descriptive Test Names
Test names should include both the scenario being tested and the expected outcome.
// â BAD: What's the expected outcome?
test('test move left', () { ... });
// â
GOOD: Clear scenario AND expected outcome
test('move left merges adjacent tiles with same value', () { ... });
// â BAD: Too vague
test('test score', () { ... });
// â
GOOD: Specific behavior
test('score increases by merged tile value after merge', () { ... });
One Scenario Per Test
Each test should exercise one scenario. A red flag: after asserting one thing, the test does more actions.
// â BAD: Tests multiple scenarios
test('game logic', () {
// Scenario 1: merge
controller.setBoard([[2, 2, 0, 0], ...]);
controller.move(Direction.left);
expect(controller.board[0][0], 4);
// Scenario 2: score
expect(controller.score, 4);
// Scenario 3: game over
controller.setBoard([[2, 4, 2, 4], ...]);
expect(controller.isGameOver, true);
});
// â
GOOD: Each scenario is its own test
test('move left merges adjacent tiles with same value', () { ... });
test('merge adds merged value to score', () { ... });
test('game is over when no moves are possible', () { ... });
Narrow Assertions
Test only what matters for this specific behavior.
// â BAD: Broad assertion - breaks when any field changes
test('move updates state', () {
controller.move(Direction.left);
expect(controller.state, GameState(
board: [[4, 0, 0, 0], ...],
score: 4,
isGameOver: false,
hasWon: false,
moveCount: 1,
// ... 10 more fields
));
});
// â
GOOD: Only checks what matters
test('move left merges tiles', () {
controller.setBoard([[2, 2, 0, 0], ...]);
controller.move(Direction.left);
expect(controller.board[0][0], 4);
});
Cause and Effect Together
The action being tested should appear immediately before the assertion.
// â BAD: Setup is far from assertion
test('score updates', () {
controller.setBoard([[2, 2, 0, 0], ...]); // Line 10
controller.move(Direction.left);
controller.move(Direction.right);
controller.move(Direction.up);
// ... 20 more lines ...
expect(controller.score, 4); // Why 4? Have to hunt for it
});
// â
GOOD: Cause and effect are adjacent
test('merge adds merged value to score', () {
controller.setBoard([[2, 2, 0, 0], ...]);
controller.move(Direction.left); // Merges 2+2=4
expect(controller.score, 4); // Obvious: merged tile value
});
What Makes a Test Wasteful
Testing Flutter/Package Internals
// â DELETE: You're testing Provider, not your code
test('ChangeNotifier notifies listeners', () {
final controller = GameController();
var notified = false;
controller.addListener(() => notified = true);
controller.move(Direction.left);
expect(notified, true);
});
Provider already tests this. You don’t need to.
Testing That Code Compiles
// â DELETE: If it compiles, it works
test('can create GameController', () {
final controller = GameController();
expect(controller, isNotNull);
});
The Dart compiler guarantees this.
Duplicating Implementation in Assertions
// â DELETE: This just mirrors the implementation
test('default board is 4x4', () {
final controller = GameController();
expect(controller.board.length, 4);
expect(controller.board[0].length, 4);
});
If you change the default, you change the test. Zero value.
The Bug-First Testing Pattern
When you find a bug, write a test FIRST that proves the bug exists, then fix the bug.
// 1. Bug reported: "Tiles don't merge when moving into wall"
// 2. Write a failing test that demonstrates the bug
test('tiles merge when pushed against wall', () {
controller.setBoard([
[0, 0, 2, 2], // Should merge to [0, 0, 0, 4]
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
controller.move(Direction.right);
expect(controller.board[0][3], 4); // FAILS before fix
});
// 3. Fix the bug
// 4. Test passes
// 5. You now have a regression test forever
This ensures:
- The bug is reproducible
- The fix actually works
- The bug never comes back
Test File Organization
test/
âââ unit/
â âââ game_controller_test.dart
â âââ board_logic_test.dart
â âââ score_calculator_test.dart
âââ widgets/
â âââ game_tile_test.dart
â âââ game_board_test.dart
â âââ score_display_test.dart
âââ helpers/
âââ test_helpers.dart
integration_test/
âââ game_flow_test.dart
Test Helpers
Create helpers for common setup, but keep assertions inline:
// test/helpers/test_helpers.dart
/// Creates a board with specific tile positions
List<List<int>> boardWith(Map<(int, int), int> tiles) {
final board = List.generate(4, (_) => List.filled(4, 0));
for (final entry in tiles.entries) {
board[entry.key.$1][entry.key.$2] = entry.value;
}
return board;
}
/// Creates a controller with a preset board
GameController controllerWith(List<List<int>> board) {
final controller = GameController();
controller.setBoard(board);
return controller;
}
Usage:
test('tiles slide to edge', () {
final controller = controllerWith([
[0, 0, 0, 2],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
]);
controller.move(Direction.left);
expect(controller.board[0][0], 2);
});
Decision Framework
| Question | If Yes | If No |
|---|---|---|
| Does this test YOUR logic? | Write it | Don’t write it |
| Would a bug here cause user-visible problems? | Write it | Probably skip |
| Am I testing Flutter/package internals? | Don’t write it | N/A |
| Am I duplicating the implementation? | Don’t write it | N/A |
| Does the test name describe scenario AND outcome? | Good | Rename it |
| Does this test exercise only ONE scenario? | Good | Split it |
| Is cause immediately before effect? | Good | Restructure |
Running Tests
# All unit + widget tests
flutter test
# With coverage report
flutter test --coverage
# Single file
flutter test test/unit/game_controller_test.dart
# Integration tests (requires device/emulator)
flutter test integration_test/
Summary
Write tests that:
- Prove YOUR code’s behavior works
- Have descriptive names (scenario + expected outcome)
- Test ONE scenario per test
- Use narrow assertions (only check relevant fields)
- Keep cause and effect adjacent
- Start with a failing test when fixing bugs
Don’t write tests that:
- Test Flutter/package internals
- Duplicate the implementation in assertions
- Check that widgets have fields
- Verify defaults equal what you wrote
- Combine multiple scenarios in one test