Unit Testing Guidelines
This document provides guidelines for writing unit tests across Tom Framework projects.
General Principles
1. Test Behavior, Not Implementation - Focus on what the code does, not how it does it 2. One Assert Per Test - Prefer focused tests over multi-assertion tests 3. Arrange-Act-Assert - Structure tests with clear setup, execution, and verification phases 4. Descriptive Names - Test names should describe the scenario and expected outcome
Test File Organization
Location
project/
├── lib/
│ └── src/
│ └── feature/
│ └── my_class.dart
└── test/
└── src/
└── feature/
└── my_class_test.dart
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Test file | <source_file>_test.dart |
my_class_test.dart |
| Test group | Describes the class/function | group('MyClass', ...) |
| Test case | Describes behavior | 'should return empty list when no items' |
Test Structure
Basic Template
import 'package:test/test.dart';
import 'package:my_package/src/feature/my_class.dart';
void main() {
group('MyClass', () {
late MyClass instance;
setUp(() {
instance = MyClass();
});
tearDown(() {
// Clean up if needed
});
group('methodName', () {
test('should return expected value when given valid input', () {
// Arrange
final input = 'test';
// Act
final result = instance.methodName(input);
// Assert
expect(result, equals('expected'));
});
test('should throw when given invalid input', () {
// Arrange
final input = null;
// Act & Assert
expect(
() => instance.methodName(input),
throwsA(isA<ArgumentError>()),
);
});
});
});
}
Test Groups
Use nested groups to organize related tests:
group('ConfigParser', () {
group('parse', () {
test('should parse valid YAML', () { ... });
test('should handle empty input', () { ... });
});
group('validate', () {
test('should accept valid config', () { ... });
test('should reject missing required fields', () { ... });
});
});
Matchers
Common Matchers
// Equality
expect(result, equals(expected));
expect(result, isNot(equals(other)));
// Types
expect(result, isA<String>());
expect(result, isNull);
expect(result, isNotNull);
// Collections
expect(list, isEmpty);
expect(list, isNotEmpty);
expect(list, hasLength(3));
expect(list, contains('item'));
expect(list, containsAll(['a', 'b']));
// Strings
expect(str, startsWith('Hello'));
expect(str, endsWith('World'));
expect(str, contains('middle'));
expect(str, matches(RegExp(r'\d+')));
// Numbers
expect(num, greaterThan(5));
expect(num, lessThanOrEqualTo(10));
expect(num, closeTo(3.14, 0.01));
// Maps
expect(map, containsPair('key', 'value'));
expect(map, contains('key')); // Has key
// Exceptions
expect(() => fn(), throwsA(isA<Exception>()));
expect(() => fn(), throwsArgumentError);
expect(() => fn(), throwsStateError);
Custom Matchers
Matcher isValidProject = predicate<Project>(
(p) => p.name.isNotEmpty && p.type != null,
'is a valid project',
);
expect(project, isValidProject);
When the list IS the result: build the reason yourself
expect(collection, isEmpty) prints the actual value through the matcher's pretty-printer, which
truncates a long collection without saying so. A guard whose output is its product (a census, an index, a backlog of unaccounted files) must not rely on it. One such guard reported forty files and displayed twenty-four. A session fixed exactly those twenty-four and re-ran expecting green. The reason string is not truncated, so put the list there, sorted and counted:
final missing = findMissing().toList()..sort();
expect(
missing,
isEmpty,
reason: '${missing.length} file(s) have no counterpart, all of them:\n'
'${missing.join('\n')}',
);
A guard that can go standing-red needs a size ratchet as well. A failing test is as red with forty entries as with four, so a backlog can grow for weeks while its guard stays the same colour. Record the size the backlog had when it was last worked, and assert it in a SEPARATE case, so that growth fails that case even while the primary one is already red:
const _backlogHighWater = 20; // measured when last worked
test('the backlog is at its high-water mark', () {
final backlog = currentBacklog();
// Fails upward (growth, visible even while the primary case is red) and
// downward (lower the mark, so the ratchet keeps the ground).
expect(backlog.length, _backlogHighWater);
});
Raising the mark is how growth is accepted, and it becomes a visible, reviewable edit. The worked example is F-SCC6-2 / F-SCE238-1 in
tom_ai/d4rt/tom_d4rt_exec/test/conformance_drift_test.dart.
Async Testing
Testing Futures
test('should complete with value', () async {
final result = await asyncFunction();
expect(result, equals('value'));
});
test('should complete with error', () async {
expect(
asyncFunction(),
throwsA(isA<TimeoutException>()),
);
});
Testing Streams
test('should emit expected values', () {
final stream = createStream();
expect(stream, emitsInOrder([1, 2, 3, emitsDone]));
});
test('should emit error', () {
final stream = createStream();
expect(stream, emitsError(isA<FormatException>()));
});
Mocking
Using Mockito
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
@GenerateMocks([FileSystem, Logger])
void main() {
late MockFileSystem mockFs;
late MockLogger mockLogger;
setUp(() {
mockFs = MockFileSystem();
mockLogger = MockLogger();
});
test('should read file', () async {
// Arrange
when(mockFs.readFile('path')).thenAnswer((_) async => 'content');
final processor = FileProcessor(mockFs, mockLogger);
// Act
final result = await processor.process('path');
// Assert
expect(result, equals('content'));
verify(mockFs.readFile('path')).called(1);
});
}
Manual Mocks
class FakeFileSystem implements FileSystem {
final Map<String, String> files = {};
@override
Future<String> readFile(String path) async {
return files[path] ?? (throw FileNotFoundError(path));
}
}
Test Data
Fixtures
Create test fixtures in a shared location:
test/
├── fixtures/
│ ├── valid_config.yaml
│ ├── invalid_config.yaml
│ └── sample_project/
│ ├── pubspec.yaml
│ └── lib/
└── src/
Loading Fixtures
import 'dart:io';
import 'package:path/path.dart' as path;
String loadFixture(String name) {
final fixturePath = path.join('test', 'fixtures', name);
return File(fixturePath).readAsStringSync();
}
Coverage
Running with Coverage
dart test --coverage=coverage
dart pub global run coverage:format_coverage \
--lcov \
--in=coverage \
--out=coverage/lcov.info \
--report-on=lib
Coverage Targets
| Type | Minimum | Target |
|---|---|---|
| Core logic | 80% | 90%+ |
| Utilities | 70% | 85%+ |
| Generated code | N/A | N/A |
Best Practices
1. Independent Tests - Tests should not depend on each other 2. Fast Tests - Unit tests should run quickly (< 100ms each) 3. No External Dependencies - Mock file system, network, etc. 4. Clear Failure Messages - Use descriptive matchers and messages 5. Test Edge Cases - Empty inputs, null values, boundaries 6. Regression Tests - Add tests for fixed bugs
Anti-Patterns to Avoid
1. Test Pollution - Shared mutable state between tests 2. Brittle Tests - Tests that break with implementation changes 3. Slow Tests - Real network calls, file I/O in unit tests 4. Over-Mocking - Mocking everything instead of using real objects 5. Ignoring Test Failures - Skipping or commenting out failing tests