Skip to main content

Testing Strategy

Mulligans Law uses Test-Driven Development (TDD) to ensure high code quality, maintainability, and confidence in changes. This guide covers our testing approach, patterns, and best practices.

Overview

Test-Driven Development (TDD)

Philosophy: Write tests BEFORE writing production code.

Benefits:

  • ✅ Forces good design (testable code is well-designed code)
  • ✅ Prevents regression bugs
  • ✅ Serves as living documentation
  • ✅ Increases confidence in changes
  • ✅ Reduces debugging time

Warning: ❌ Writing code first and tests later is NOT TDD!

The Red-Green-Refactor Cycle

Every feature is built in small TDD cycles:

🔴 RED → Write a failing test

🟢 GREEN → Write minimal code to pass

🔵 REFACTOR → Improve code quality while keeping tests green

(Repeat)

Example Cycle (5-15 minutes each):

// 🔴 RED: Write failing test
test('should calculate net score correctly', () {
final calculator = ScoreCalculator();
expect(calculator.calculateNet(gross: 5, handicap: 1), 4);
});
// Run: flutter test → ❌ Fails (ScoreCalculator doesn't exist)

// 🟢 GREEN: Make it pass (minimal code)
class ScoreCalculator {
int calculateNet({required int gross, required int handicap}) {
return gross - handicap;
}
}
// Run: flutter test → ✅ Passes

// 🔵 REFACTOR: Improve (if needed)
class ScoreCalculator {
int calculateNet({required int gross, required int handicap}) {
if (gross < 0 || handicap < 0) {
throw ArgumentError('Scores must be non-negative');
}
return gross - handicap;
}
}
// Run: flutter test → ✅ Still passes

Test Types

Test Pyramid

Our testing strategy follows the test pyramid:

      /\
/ \ 10% Integration Tests (E2E)
/____\
/ \ 20% Widget Tests (UI)
/________\
/ \
/____________\ 70% Unit Tests (Logic)

Distribution:

  • 70% Unit Tests - Fast, isolated, test business logic
  • 20% Widget Tests - Test UI components and interactions
  • 10% Integration Tests - Test complete user flows

1. Unit Tests

Purpose: Test individual functions, classes, and use cases in isolation.

What to Test:

  • ✅ Domain entities and value objects
  • ✅ Use cases (business logic)
  • ✅ Calculators and validators
  • ✅ Repository implementations
  • ✅ Data transformations

What NOT to Test:

  • ❌ Third-party libraries (trust they work)
  • ❌ Simple getters/setters with no logic
  • ❌ Data classes with no behavior
  • ❌ Flutter framework code

Example: Testing a Use Case

// test/features/scores/domain/usecases/submit_score_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

import 'submit_score_test.mocks.dart';

([ScoreRepository])
void main() {
late SubmitScoreUseCase useCase;
late MockScoreRepository mockRepository;

setUp(() {
mockRepository = MockScoreRepository();
useCase = SubmitScoreUseCase(mockRepository);
});

group('SubmitScoreUseCase', () {
final testScore = Score(
id: 'score-1',
roundId: 'round-1',
holeScores: List.filled(18, 4),
);

test('should submit complete score successfully', () async {
// Arrange
when(mockRepository.submitScore(testScore))
.thenAnswer((_) async => testScore);

// Act
final result = await useCase(testScore);

// Assert
expect(result, testScore);
verify(mockRepository.submitScore(testScore)).called(1);
});

test('should throw IncompleteScoreException when score incomplete', () {
// Arrange
final incompleteScore = Score(
id: 'score-1',
roundId: 'round-1',
holeScores: [4, 3, 5], // Only 3 holes!
);

// Act & Assert
expect(
() => useCase(incompleteScore),
throwsA(isA<IncompleteScoreException>()),
);
});
});
}

Running Unit Tests:

# All unit tests
flutter test

# Specific test file
flutter test test/features/scores/domain/usecases/submit_score_test.dart

# Tests matching pattern
flutter test --name "SubmitScore"

# With coverage
flutter test --coverage

2. Widget Tests

Purpose: Test UI components and user interactions.

What to Test:

  • ✅ Widgets render correctly
  • ✅ User interactions (taps, swipes, input)
  • ✅ Widget responds to state changes
  • ✅ Navigation flows
  • ✅ Form validation

What NOT to Test:

  • ❌ Static layouts without logic
  • ❌ Third-party widgets
  • ❌ Pixel-perfect rendering

Example: Testing a Widget

// test/features/scores/presentation/widgets/score_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('ScoreCard Widget', () {
testWidgets('displays hole information correctly', (tester) async {
// Arrange & Act
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ScoreCard(
hole: 1,
par: 4,
strokeIndex: 10,
score: 5,
onScoreChanged: (_) {},
),
),
),
);

// Assert
expect(find.text('Hole 1'), findsOneWidget);
expect(find.text('Par 4'), findsOneWidget);
expect(find.text('SI 10'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
});

testWidgets('calls onScoreChanged when score button tapped', (tester) async {
// Arrange
int? capturedScore;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ScoreCard(
hole: 1,
par: 4,
strokeIndex: 10,
score: 0,
onScoreChanged: (score) => capturedScore = score,
),
),
),
);

// Act
await tester.tap(find.text('4'));
await tester.pump();

// Assert
expect(capturedScore, 4);
});

testWidgets('highlights current score', (tester) async {
// Arrange & Act
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ScoreCard(
hole: 1,
par: 4,
strokeIndex: 10,
score: 5,
onScoreChanged: (_) {},
),
),
),
);

// Assert
final scoreButton = tester.widget<ElevatedButton>(
find.widgetWithText(ElevatedButton, '5'),
);
expect(scoreButton.style?.backgroundColor?.resolve({}), isNotNull);
});
});
}

Running Widget Tests:

# All widget tests
flutter test test/**/widgets/

# Specific widget
flutter test test/features/scores/presentation/widgets/score_card_test.dart

3. BLoC Tests

Purpose: Test state management logic using bloc_test package.

Example: Testing a BLoC

// test/features/scores/presentation/bloc/score_capture_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

void main() {
late ScoreCaptureBloc bloc;
late MockGetRoundUseCase mockGetRound;
late MockSubmitScoreUseCase mockSubmitScore;

setUp(() {
mockGetRound = MockGetRoundUseCase();
mockSubmitScore = MockSubmitScoreUseCase();
bloc = ScoreCaptureBloc(
getRound: mockGetRound,
submitScore: mockSubmitScore,
);
});

group('LoadRound', () {
final testRound = Round(
id: 'round-1',
course: testCourse,
date: DateTime.now(),
);

blocTest<ScoreCaptureBloc, ScoreCaptureState>(
'emits [Loading, Loaded] when LoadRound succeeds',
build: () {
when(mockGetRound('round-1'))
.thenAnswer((_) async => testRound);
return bloc;
},
act: (bloc) => bloc.add(LoadRound('round-1')),
expect: () => [
ScoreCaptureLoading(),
ScoreCaptureLoaded(round: testRound, currentHole: 0),
],
);

blocTest<ScoreCaptureBloc, ScoreCaptureState>(
'emits [Loading, Error] when LoadRound fails',
build: () {
when(mockGetRound('round-1'))
.thenThrow(RoundNotFoundException());
return bloc;
},
act: (bloc) => bloc.add(LoadRound('round-1')),
expect: () => [
ScoreCaptureLoading(),
ScoreCaptureError('Round not found'),
],
);
});
}

4. Integration Tests

Purpose: Test complete user flows end-to-end.

When to Use:

  • Critical user journeys (login → create round → submit score)
  • Cross-feature flows
  • Smoke tests for major functionality

Example: Integration Test

// integration_test/score_capture_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('Score Capture Flow', () {
testWidgets('complete score capture journey', (tester) async {
// Start app
await tester.pumpWidget(MyApp());
await tester.pumpAndSettle();

// 1. Login
await tester.enterText(find.byKey(Key('email')), 'test@example.com');
await tester.enterText(find.byKey(Key('password')), 'password123');
await tester.tap(find.byKey(Key('login-button')));
await tester.pumpAndSettle();

// 2. Navigate to rounds
await tester.tap(find.text('Rounds'));
await tester.pumpAndSettle();

// 3. Select a round
await tester.tap(find.text('Sunday Medal'));
await tester.pumpAndSettle();

// 4. Enter scores for 18 holes
for (int hole = 1; hole <= 18; hole++) {
await tester.tap(find.text('4')); // Par for each hole
if (hole < 18) {
await tester.drag(
find.byType(PageView),
Offset(-400, 0), // Swipe left
);
await tester.pumpAndSettle();
}
}

// 5. Submit score
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();

// 6. Verify success
expect(find.text('Score submitted!'), findsOneWidget);
});
});
}

Running Integration Tests:

# On device/emulator
flutter test integration_test/

# With specific device
flutter test integration_test/ -d <device-id>

Mocking Strategy

Minimal Mocking

Rule: Only mock external dependencies, use real objects for everything else.

Do Mock:

  • ✅ External APIs (Supabase client)
  • ✅ Local database (Drift)
  • ✅ Repositories (in use case tests)
  • ✅ Use cases (in BLoC tests)
  • ✅ Complex services

Don't Mock:

  • ❌ Domain entities
  • ❌ Value objects
  • ❌ Data classes
  • ❌ Simple DTOs

Example: Proper Mocking

// ✅ GOOD: Mock repository (external dependency)
([ScoreRepository])
void main() {
late MockScoreRepository mockRepository;
late SubmitScoreUseCase useCase;

setUp(() {
mockRepository = MockScoreRepository();
useCase = SubmitScoreUseCase(mockRepository);
});

test('should submit score', () async {
// Use real Score object
final score = Score(
id: 'score-1',
roundId: 'round-1',
holeScores: List.filled(18, 4),
);

when(mockRepository.submitScore(score))
.thenAnswer((_) async => score);

await useCase(score);

verify(mockRepository.submitScore(score)).called(1);
});
}
// ❌ BAD: Mock domain entity (unnecessary)
([Score])
void main() {
test('should calculate total', () {
final mockScore = MockScore();
when(mockScore.calculateTotal()).thenReturn(72);

// This is pointless - just use a real Score!
final total = mockScore.calculateTotal();
expect(total, 72);
});
}

Generating Mocks

Use mockito to generate mocks:

// Add annotation above test
([ScoreRepository, RoundRepository])
import 'my_test.mocks.dart';

void main() {
// Mocks available: MockScoreRepository, MockRoundRepository
}

Generate mocks:

flutter pub run build_runner build

Test Helpers

Using Test Helpers

The project includes helper utilities in test/helpers/:

test_helper.dart - Widget testing utilities:

import 'package:test/helpers/test_helper.dart';

testWidgets('my widget test', (tester) async {
// Helper to pump widget with MaterialApp
await pumpTestWidget(tester, MyWidget());

// Helper assertions
verifyWidgetExists('Welcome');
verifyWidgetNotExists('Error');
});

mock_factories.dart - Test data generators:

import 'package:test/helpers/mock_factories.dart';

test('my test', () {
// Create test data easily
final score = createTestScore(
id: 'score-1',
totalStableford: 36,
);

final member = createTestMember(
handicap: 18,
role: 'CAPTAIN',
);
});

Coverage

Target Coverage

Overall: 70% minimum

By Layer:

  • Domain: 90%+ (critical business logic)
  • Data: 70%+ (repository implementations)
  • Presentation: 60%+ (BLoCs and important widgets)

Measuring Coverage

# Generate coverage
flutter test --coverage

# View coverage HTML report (requires lcov)
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

CI Integration:

  • Coverage automatically uploaded to Codecov
  • Coverage trends tracked over time
  • PRs show coverage changes

Coverage Exclusions

Files excluded from coverage:

// coverage:ignore-file - Entire file
// coverage:ignore-start / coverage:ignore-end - Block
// coverage:ignore-line - Single line

Auto-excluded:

  • **/*.g.dart (generated files)
  • **/*.freezed.dart (generated files)
  • main.dart (app entry point)

Best Practices

Writing Good Tests

1. Follow AAA Pattern

Arrange-Act-Assert:

test('should calculate stableford points correctly', () {
// Arrange - Set up test data and dependencies
final calculator = StablefordCalculator();
final netScore = 4;
final par = 4;

// Act - Execute the code being tested
final points = calculator.calculate(netScore: netScore, par: par);

// Assert - Verify the result
expect(points, 2);
});

2. Test One Thing

Each test should verify one behavior:

// ✅ GOOD: Tests one thing
test('should calculate stableford points for par', () {
final points = calculator.calculate(netScore: 4, par: 4);
expect(points, 2);
});

test('should calculate stableford points for birdie', () {
final points = calculator.calculate(netScore: 3, par: 4);
expect(points, 3);
});

// ❌ BAD: Tests multiple things
test('should calculate stableford points', () {
expect(calculator.calculate(netScore: 4, par: 4), 2); // Par
expect(calculator.calculate(netScore: 3, par: 4), 3); // Birdie
expect(calculator.calculate(netScore: 5, par: 4), 1); // Bogey
// Too much in one test!
});

3. Use Descriptive Names

Test names should describe the behavior:

// ✅ GOOD: Clear what's being tested
test('should throw ValidationException when handicap is negative', () {
// ...
});

test('should calculate net score by subtracting handicap strokes', () {
// ...
});

// ❌ BAD: Vague names
test('handicap test', () {
// ...
});

test('test calculation', () {
// ...
});

4. Keep Tests Independent

Tests should not depend on each other:

// ✅ GOOD: Each test sets up its own data
test('test A', () {
final score = Score(holeScores: [4, 3, 5]);
// ...
});

test('test B', () {
final score = Score(holeScores: [4, 4, 4]);
// ...
});

// ❌ BAD: Tests share mutable state
Score? sharedScore;

test('test A', () {
sharedScore = Score(holeScores: [4, 3, 5]);
// ...
});

test('test B', () {
sharedScore!.holeScores[0] = 3; // Modifies shared state!
// ...
});

5. Test Edge Cases

Don't just test the happy path:

group('StablefordCalculator', () {
test('should calculate points for par', () {
expect(calculator.calculate(netScore: 4, par: 4), 2);
});

test('should calculate points for albatross', () {
expect(calculator.calculate(netScore: 2, par: 5), 5);
});

test('should return 0 points for double bogey or worse', () {
expect(calculator.calculate(netScore: 6, par: 4), 0);
expect(calculator.calculate(netScore: 7, par: 4), 0);
});

test('should handle negative scores gracefully', () {
expect(
() => calculator.calculate(netScore: -1, par: 4),
throwsA(isA<ArgumentError>()),
);
});
});

Continuous Integration

GitHub Actions CI

Every push triggers automated testing:

- name: Run tests
run: flutter test --coverage

- name: Upload coverage
uses: codecov/codecov-action@v5

CI Requirements:

  • ✅ All tests must pass
  • ✅ Code must be formatted (dart format)
  • ✅ No analysis errors (flutter analyze)
  • ✅ Builds must succeed (iOS & Android)

Pull requests cannot merge until CI passes.

Next Steps