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

ElementConventionExample
Test file <source_file>_test.dart my_class_test.dart
Test groupDescribes the class/functiongroup('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

TypeMinimumTarget
Core logic80%90%+
Utilities70%85%+
Generated codeN/AN/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