8.1 KiB
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
- Arrange-Act-Assert - Structure tests with clear sections
- One assertion per test - When possible, keep tests focused
- Descriptive names - Explain what and why, not just what
- Avoid test interdependence - Each test should be independent
- Use setUp/tearDown - Avoid code duplication
- Test edge cases - Test boundaries, nulls, and errors
- Keep tests fast - Avoid slow operations in unit tests
- 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"