Files
Musify/musify_flutter/.agents/skills/flutter-testing/references/unit-testing.md
T

8.1 KiB

Unit Testing Guide

Overview

Unit tests test individual functions, methods, or classes in isolation. They are the foundation of a well-tested Flutter app, providing fast feedback and high maintainability.

When to Write Unit Tests

  • Testing business logic functions
  • Validating data transformations
  • Testing state management logic (Bloc, Provider, Riverpod)
  • Mocking external services/API calls
  • Testing utility functions and helpers

Test Structure

Basic Test

import 'package:test/test.dart';

void main() {
  test('description', () {
    // Arrange
    final subject = MyClass();
    
    // Act
    final result = subject.myMethod();
    
    // Assert
    expect(result, expectedValue);
  });
}

Group Tests

import 'package:test/test.dart';

void main() {
  group('Counter', () {
    late Counter counter;
    
    setUp(() {
      counter = Counter();
    });
    
    test('value starts at 0', () {
      expect(counter.value, 0);
    });
    
    test('increment increases value', () {
      counter.increment();
      expect(counter.value, 1);
    });
    
    tearDown(() {
      counter.dispose();
    });
  });
}

Testing Patterns

Testing Pure Functions

// Function to test
int add(int a, int b) => a + b;

// Test
test('add returns sum of two numbers', () {
  expect(add(2, 3), 5);
  expect(add(-1, 1), 0);
});

Testing State Changes

class Counter {
  int _value = 0;
  int get value => _value;
  
  void increment() => _value++;
  void decrement() => _value--;
  void reset() => _value = 0;
}

// Tests
group('Counter state changes', () {
  late Counter counter;
  
  setUp(() => counter = Counter());
  
  test('initial value is 0', () {
    expect(counter.value, 0);
  });
  
  test('increment increases value by 1', () {
    counter.increment();
    expect(counter.value, 1);
  });
  
  test('decrement decreases value by 1', () {
    counter.decrement();
    expect(counter.value, -1);
  });
  
  test('multiple increments work correctly', () {
    counter.increment();
    counter.increment();
    counter.increment();
    expect(counter.value, 3);
  });
});

Testing Async Operations

class DataService {
  Future<String> fetchData() async {
    await Future.delayed(const Duration(seconds: 1));
    return 'data';
  }
}

// Test
test('fetchData returns data after delay', () async {
  final service = DataService();
  final result = await service.fetchData();
  expect(result, 'data');
});

Testing Streams

class CounterStream {
  final _controller = StreamController<int>();
  Stream<int> get stream => _controller.stream;
  
  void increment() => _controller.sink.add(1);
  void dispose() => _controller.close();
}

// Test
test('stream emits values', () async {
  final counter = CounterStream();
  
  expectLater(
    counter.stream,
    emitsInOrder([1, 1, 1]),
  );
  
  counter.increment();
  counter.increment();
  counter.increment();
  
  await Future.delayed(const Duration(milliseconds: 100));
  counter.dispose();
});

Matchers

Common Matchers

// Equality
expect(actual, equals(expected));

// Not equal
expect(actual, isNot(equals(expected)));

// Null checks
expect(value, isNull);
expect(value, isNotNull);

// Numeric comparisons
expect(5, greaterThan(3));
expect(5, lessThan(10));
expect(5, greaterThanOrEqualTo(5));
expect(5, lessThanOrEqualTo(5));

// Type checking
expect(obj, isA<String>());
expect(obj, isNot(isA<int>()));

// String matching
expect(text, contains('substring'));
expect(text, startsWith('prefix'));
expect(text, endsWith('suffix'));
expect(text, matches(RegExp(r'\d+')));

// Lists
expect(list, isEmpty);
expect(list, isNotEmpty);
expect(list, hasLength(3));
expect(list, contains(item));
expect(list, orderedEquals([1, 2, 3]));

Custom Matchers

import 'package:test/test.dart';

class HasLength extends Matcher {
  final int expectedLength;
  HasLength(this.expectedLength);
  
  @override
  bool matches(item, Map matchState) {
    return (item as List).length == expectedLength;
  }
  
  @override
  Description describe(Description description) {
    return description.add('has length $expectedLength');
  }
}

// Usage
test('list has custom length', () {
  final list = [1, 2, 3];
  expect(list, HasLength(3));
});

Exception Testing

class Calculator {
  int divide(int a, int b) {
    if (b == 0) throw ArgumentError('Cannot divide by zero');
    return a ~/ b;
  }
}

// Test
test('divide throws when dividing by zero', () {
  final calculator = Calculator();
  expect(
    () => calculator.divide(10, 0),
    throwsArgumentError,
  );
});

test('divide throws with specific message', () {
  final calculator = Calculator();
  expect(
    () => calculator.divide(10, 0),
    throwsA(isA<ArgumentError>()
      .having((e) => e.message, 'message', contains('zero'))),
  );
});

Mocking

Using Mockito

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

import 'user_service_test.mocks.dart';

@GenerateMocks([ApiClient])
void main() {
  group('UserService', () {
    late MockApiClient mockApiClient;
    late UserService userService;
    
    setUp(() {
      mockApiClient = MockApiClient();
      userService = UserService(mockApiClient);
    });
    
    test('fetches user data', () async {
      when(mockApiClient.getUser('123'))
        .thenAnswer((_) async => User(id: '123', name: 'John'));
      
      final user = await userService.getUser('123');
      
      expect(user.name, 'John');
      verify(mockApiClient.getUser('123')).called(1);
    });
  });
}

Generate the mock file with:

dart run build_runner build

Manual Mocking

class MockStorage implements Storage {
  String? savedData;
  
  @override
  void save(String data) {
    savedData = data;
  }
  
  @override
  String? load() => savedData;
}

// Test
test('saves and loads data', () {
  final mockStorage = MockStorage();
  final service = DataService(mockStorage);
  
  service.saveData('test');
  expect(mockStorage.savedData, 'test');
  
  final loaded = service.loadData();
  expect(loaded, 'test');
});

Testing with Fake Clock

import 'package:clock/clock.dart';

test('time-based operations', () {
  withClock(Clock(() => DateTime(2024, 1, 1)), () {
    final timestamp = DateTime.now();
    expect(timestamp.year, 2024);
  });
});

Test Organization

File Structure

lib/
  counter.dart
test/
  counter/
    counter_test.dart
    counter_value_test.dart
    counter_operations_test.dart

Naming Conventions

  • Test files: *_test.dart
  • Test groups: Group related functionality
  • Test names: Describe what and why
// Good
test('value increments when increment is called', () {});

// Avoid
test('increment', () {});

Best Practices

  1. Arrange-Act-Assert - Structure tests with clear sections
  2. One assertion per test - When possible, keep tests focused
  3. Descriptive names - Explain what and why, not just what
  4. Avoid test interdependence - Each test should be independent
  5. Use setUp/tearDown - Avoid code duplication
  6. Test edge cases - Test boundaries, nulls, and errors
  7. Keep tests fast - Avoid slow operations in unit tests
  8. Mock external dependencies - Tests should be deterministic

Common Pitfalls

Testing Implementation Details

// Bad - tests implementation
test('counter calls _update', () {
  verify(counter._update()).called(1);
});

// Good - tests behavior
test('counter value increases after increment', () {
  expect(counter.value, 1);
});

Fragile Tests

// Bad - depends on exact timing
test('completes within 100ms', () async {
  final stopwatch = Stopwatch()..start();
  await operation();
  expect(stopwatch.elapsedMilliseconds, lessThan(100));
});

// Good - tests completion
test('completes successfully', () async {
  await operation();
  // operation completed without error
});

Running Tests

# Run all tests
flutter test

# Run specific file
flutter test test/counter/counter_test.dart

# Run with coverage
flutter test --coverage

# Run verbose
flutter test --verbose

# Run specific test
flutter test --name "value increments"