Unit Testing #
Unit tests are an investment — not a burden. Well-written tests enable refactoring without fear, document expected behavior, and catch regressions before they reach users. Dart provides a complete testing ecosystem: the test package for the testing framework, mockito for mocking, and built-in tooling for measuring code coverage. This article covers how to write tests that are genuinely useful — not just tests that pass, but tests that fail clearly when something’s wrong and give confidence when everything is green.
Testing Philosophy — FIRST #
Good tests follow the FIRST principles:
Fast — every test runs in milliseconds, not seconds
Isolated — one test doesn't depend on state from another test
Repeatable — the same result wherever it runs (local, CI, different OS)
Self-validating — pass or fail automatically, no manual interpretation needed
Timely — written before or alongside production code
Project Setup and Structure #
dart pub add dev:test dev:mockito dev:build_runner
# pubspec.yaml
dev_dependencies:
test: ^1.25.0
mockito: ^5.4.0
build_runner: ^2.4.0
The recommended test directory structure mirrors the lib structure:
lib/
├── src/
│ ├── domain/
│ │ ├── entitas/produk.dart
│ │ └── layanan/hitung_diskon.dart
│ └── data/
│ └── repository/produk_repository.dart
test/
├── src/
│ ├── domain/
│ │ ├── entitas/produk_test.dart
│ │ └── layanan/hitung_diskon_test.dart
│ └── data/
│ └── repository/produk_repository_test.dart
└── helpers/
└── test_fixtures.dart ← shared dummy data
Test Anatomy #
import 'package:test/test.dart';
import 'package:aplikasi_toko/src/domain/layanan/hitung_diskon.dart';
void main() {
// group — groups related tests
group('HitungDiskon', () {
late HitungDiskon service;
// setUp — runs before EVERY test in this group
setUp(() {
service = HitungDiskon();
});
// tearDown — runs after EVERY test in this group
tearDown(() {
// clean up resources if needed
});
// setUpAll — runs ONCE before all tests in the group
setUpAll(() async {
// expensive initialization: open DB connections, load large files
});
// tearDownAll — runs ONCE after all tests in the group
tearDownAll(() async {
// close connections, delete temp files
});
// individual test — one specific behavior per test
test('returns 0 when the price is 0', () {
expect(service.hitung(harga: 0, persen: 10), equals(0));
});
test('calculates a 10% discount correctly', () {
expect(service.hitung(harga: 100000, persen: 10), equals(10000));
});
test('throws ArgumentError when persen is negative', () {
expect(
() => service.hitung(harga: 100000, persen: -5),
throwsA(isA<ArgumentError>()),
);
});
});
}
Matchers — Everything You Need to Know #
The test package provides dozens of matchers. Choosing the right matcher produces far more informative error messages when a test fails.
Equality and Comparison #
expect(hasil, equals(42)); // exactly equal (== operator)
expect(nilai, same(referensi)); // the same object reference (identical)
expect(angka, isNot(equals(0))); // negation of another matcher
expect(teks, isNull); // null
expect(teks, isNotNull); // not null
expect(daftar, isEmpty); // empty
expect(daftar, isNotEmpty); // not empty
// Numeric comparison
expect(nilai, greaterThan(10));
expect(nilai, lessThan(100));
expect(nilai, greaterThanOrEqualTo(10));
expect(nilai, closeTo(3.14, 0.01)); // tolerance ±0.01
// Types
expect(objek, isA<String>()); // an instance of a specific type
expect(objek, isA<List<int>>()); // with generics
String Matchers #
expect(teks, contains('Dart'));
expect(teks, startsWith('Halo'));
expect(teks, endsWith('!'));
expect(teks, matches(RegExp(r'^\d{5}$'))); // matches a regex
expect(teks, hasLength(10));
expect(teks, equalsIgnoringCase('DART')); // case-insensitive
Collection Matchers #
expect(list, contains(42));
expect(list, containsAll([1, 2, 3]));
expect(list, containsAllInOrder([1, 2, 3])); // order matters
expect(list, hasLength(5));
expect(list, everyElement(greaterThan(0))); // every element satisfies the condition
expect(list, anyElement(greaterThan(100))); // at least one element satisfies it
// Map
expect(map, containsPair('kunci', 'nilai'));
expect(map, containsValue('nilai'));
Exception Matchers #
// Specific exception
expect(() => bagi(10, 0), throwsA(isA<ArgumentError>()));
// With a specific message
expect(
() => bagi(10, 0),
throwsA(
isA<ArgumentError>()
.having((e) => e.message, 'message', contains('nol')),
),
);
// Shortcuts for common exceptions
expect(() => null!.toString(), throwsA(isA<TypeError>()));
expect(() => list[100], throwsRangeError);
expect(() => throw Exception(), throwsException);
// Ensuring something does NOT throw
expect(() => fungsiAman(), returnsNormally);
Custom Matchers with predicate
#
// Custom matcher with a predicate
final adalahBilanganPrima = predicate<int>(
(n) {
if (n < 2) return false;
for (int i = 2; i <= n ~/ 2; i++) {
if (n % i == 0) return false;
}
return true;
},
'is a prime number',
);
expect(7, adalahBilanganPrima);
expect(4, isNot(adalahBilanganPrima));
Asynchronous Tests #
import 'package:test/test.dart';
Future<String> ambilData(String id) async {
await Future.delayed(Duration(milliseconds: 100));
if (id.isEmpty) throw ArgumentError('ID cannot be empty');
return 'Data-$id';
}
void main() {
group('ambilData', () {
// Async test — mark the test with async
test('returns data for a valid ID', () async {
final hasil = await ambilData('U001');
expect(hasil, equals('Data-U001'));
});
// Testing an exception from an async function
test('throws ArgumentError for an empty ID', () async {
expect(
() async => await ambilData(''),
throwsA(isA<ArgumentError>()),
);
});
// Test with a timeout — fails if it doesn't finish within 2 seconds
test('finishes within the time limit', () async {
final hasil = await ambilData('U002')
.timeout(Duration(seconds: 2));
expect(hasil, isNotNull);
}, timeout: Timeout(Duration(seconds: 5)));
});
}
Stream Matchers #
import 'package:test/test.dart';
Stream<int> hitungMundur(int dari) async* {
for (int i = dari; i >= 0; i--) {
await Future.delayed(Duration(milliseconds: 10));
yield i;
}
}
void main() {
group('hitungMundur', () {
test('emits the correct values', () {
expect(
hitungMundur(3),
emitsInOrder([3, 2, 1, 0, emitsDone]), // event order + done
);
});
test('emits all elements', () {
expect(
hitungMundur(2),
emitsInAnyOrder([0, 1, 2]), // order doesn't matter
);
});
test('contains a specific value', () {
expect(
hitungMundur(5),
emitsThrough(3), // the stream eventually emits 3
);
});
test('stream errors are caught', () {
final errorStream = Stream<int>.error(StateError('test error'));
expect(
errorStream,
emitsError(isA<StateError>()),
);
});
});
}
Tagging and Filtering Tests #
Tags let you run a specific subset of tests — useful for separating fast tests from slow ones, or unit tests from integration tests:
import 'package:test/test.dart';
void main() {
// Tag a single test
test('fast test', () {
expect(1 + 1, 2);
}, tags: 'unit');
test('test with a database', () async {
// a test that needs a database connection
}, tags: ['integration', 'slow']);
// Tag an entire group
group('API tests', () {
test('GET /produk', () async { /* ... */ });
test('POST /produk', () async { /* ... */ });
}, tags: 'integration');
}
# Run only tests tagged 'unit'
dart test --tags unit
# Skip tests tagged 'slow'
dart test --exclude-tags slow
# Run tests by name
dart test --name "menghitung diskon"
# Run a specific test file
dart test test/domain/hitung_diskon_test.dart
# Run tests in parallel (faster)
dart test --concurrency 4
Good Tests — the AAA Structure #
Every test follows the Arrange-Act-Assert (AAA) pattern — clearly separating setup, execution, and verification:
test('the service calculates the shopping total correctly', () {
// Arrange — prepare data and dependencies
final keranjang = Keranjang();
keranjang.tambah(Produk(id: 'P1', nama: 'Laptop', harga: 15_000_000), qty: 1);
keranjang.tambah(Produk(id: 'P2', nama: 'Mouse', harga: 250_000), qty: 2);
final layanan = LayananBelanja(diskon: 0.10);
// Act — run the code under test
final total = layanan.hitungTotal(keranjang);
// Assert — verify the result
expect(total, equals(13_950_000)); // (15_000_000 + 500_000) * 0.9
});
// ANTI-PATTERN: one test testing many things
test('all calculator functions', () {
final calc = Kalkulator();
expect(calc.tambah(2, 3), 5); // ✗ if this fails...
expect(calc.kurangi(5, 3), 2); // ...this never runs
expect(calc.kali(4, 5), 20);
expect(calc.bagi(10, 2), 5.0);
// One test, four assertions — hard to tell which one failed
});
// CORRECT: one test, one behavior
test('tambah returns the sum of two numbers', () {
expect(Kalkulator().tambah(2, 3), equals(5));
});
test('kurangi returns the difference of two numbers', () {
expect(Kalkulator().kurangi(5, 3), equals(2));
});
test('bagi throws ArgumentError if the divisor is zero', () {
expect(() => Kalkulator().bagi(10, 0), throwsA(isA<ArgumentError>()));
});
Test Data — Fixtures and Builders #
For data used across many tests, create centralized fixtures:
// test/helpers/fixtures.dart
import 'package:aplikasi_toko/src/domain/entitas/produk.dart';
import 'package:aplikasi_toko/src/domain/entitas/pengguna.dart';
class Fixtures {
static Produk produkLaptop() => Produk(
id: 'P001',
nama: 'Laptop Gaming',
harga: 15_000_000,
stok: 10,
);
static Produk produkMouse() => Produk(
id: 'P002',
nama: 'Gaming Mouse',
harga: 250_000,
stok: 50,
);
static Pengguna penggunaPremium() => Pengguna(
id: 'U001',
nama: 'Budi Santoso',
email: '[email protected]',
level: LevelPengguna.premium,
);
}
// Usage in tests
test('premium users get a 20% discount', () {
final pengguna = Fixtures.penggunaPremium();
final produk = Fixtures.produkLaptop();
final layanan = LayananDiskon();
final diskon = layanan.hitungDiskon(pengguna, produk);
expect(diskon, equals(3_000_000)); // 20% of 15.000.000
});
Code Coverage #
Code coverage measures the percentage of code lines executed by tests. A common target is 80%+ for business logic, not for all code.
# Run tests with coverage collection
dart test --coverage coverage/
# Convert to LCOV format (needs the coverage package)
dart pub global activate coverage
dart pub global run coverage:format_coverage \
--packages=.dart_tool/package_config.json \
--report-on=lib \
--lcov \
--in=coverage \
--out=coverage/lcov.info
# Generate an HTML report (needs lcov installed on the system)
genhtml coverage/lcov.info -o coverage/html
# Open the report
open coverage/html/index.html # macOS
xdg-open coverage/html/index.html # Linux
# analysis_options.yaml — linter configuration for tests
analyzer:
exclude:
- test/** # test files don't need some lint rules
linter:
rules:
- test_types_in_equals # make sure matchers are used correctly
100% code coverage is not the goal. High coverage with bad tests is more dangerous than low coverage with good tests — because it gives false confidence. Prioritize coverage on complex business logic and edge cases, not on simple getters or boilerplate.
Running Tests in CI #
# .github/workflows/test.yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Install dependencies
run: dart pub get
- name: Verify formatting
run: dart format --output=none --set-exit-if-changed .
- name: Analyze code
run: dart analyze --fatal-infos
- name: Run tests
run: dart test --coverage coverage/
- name: Check coverage
run: |
dart pub global activate coverage
dart pub global run coverage:format_coverage \
--packages=.dart_tool/package_config.json \
--report-on=lib \
--lcov \
--in=coverage \
--out=coverage/lcov.info
Unit Test Anti-Patterns #
Tests That Depend on Order #
// ANTI-PATTERN: this test depends on state from a previous test
List<String> daftarGlobal = [];
test('add to the list', () {
daftarGlobal.add('item'); // ✗ this state leaks into the next test
expect(daftarGlobal, hasLength(1));
});
test('the list still contains the item', () {
expect(daftarGlobal, hasLength(1)); // ✗ depends on the previous test!
});
// CORRECT: each test is self-contained — initializes its own state
test('add to the list', () {
final daftar = <String>[]; // local state
daftar.add('item');
expect(daftar, hasLength(1));
});
Tests That Don’t Verify Real Behavior #
// ANTI-PATTERN: a test that only verifies implementation details
test('the service calls the repository', () {
final mockRepo = MockProdukRepository();
final service = ProdukService(mockRepo);
service.ambilProduk('P001');
// ✗ only verifies the method was called, not the result
verify(mockRepo.ambilById('P001')).called(1);
// This test passes even if the service returns wrong data!
});
// CORRECT: verify behavior that's meaningful to the user
test('the service returns the correct product', () async {
final mockRepo = MockProdukRepository();
final produkDiharapkan = Fixtures.produkLaptop();
when(mockRepo.ambilById('P001'))
.thenAnswer((_) async => produkDiharapkan);
final service = ProdukService(mockRepo);
final hasil = await service.ambilProduk('P001');
// ✓ verify the RESULT that's relevant to the user
expect(hasil?.nama, equals('Laptop Gaming'));
expect(hasil?.harga, equals(15_000_000));
});
Summary #
- Test structure mirrors the lib structure —
test/src/domain/x_test.dartforlib/src/domain/x.dart. This makes navigation easy and ensures every module has tests.- One test, one behavior — tests that check many things at once are hard to debug when they fail. Create a separate test for each scenario.
- The AAA pattern — Arrange (prepare), Act (run), Assert (verify). Visually separate these three parts for readability.
- Specific matchers produce more informative error messages —
isA<ArgumentError>().having(...)is far more useful thanisA<Exception>().setUpfor per-test initialization,setUpAllfor expensive shared setup across a group (database connections, large files).- Stream matchers (
emitsInOrder,emitsThrough,emitsDone) enable expressive Stream testing without needingtoList()first.- Tagging (
tags: 'unit') lets you run a subset of tests — separate unit tests (fast) from integration tests (slow) to keep the feedback loop quick.- Centralized fixtures prevent test data duplication — one place to create consistent, easily updatable test objects.
- Code coverage is a tool, not a goal — target 80%+ for business logic, but don’t chase 100% by writing trivial tests just for the numbers.
- Tests in CI ensure no code is merged without passing the test suite — add
dart analyzeanddart formatfor consistent code quality.