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 structuretest/src/domain/x_test.dart for lib/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 than isA<Exception>().
  • setUp for per-test initialization, setUpAll for expensive shared setup across a group (database connections, large files).
  • Stream matchers (emitsInOrder, emitsThrough, emitsDone) enable expressive Stream testing without needing toList() 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 analyze and dart format for consistent code quality.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact