Skip to content

04 · Testing with the test package

Every lesson so far has been verified by eyeballing printed output. Real projects instead write automated tests: code that checks your code, runs in seconds, and catches regressions the moment you introduce them. Dart's official package:test is the standard tool for this, and it's simple enough to be worth using from day one of any real project.

Setting up

Add test as a dev dependency (it's only needed while developing, not at runtime) and create a test/ directory next to lib/:

dart pub add --dev test
my_project/
├── lib/
│   └── calculator.dart
├── test/
│   └── calculator_test.dart
└── pubspec.yaml

The code under test

// lib/calculator.dart
class Calculator {
  int add(int a, int b) => a + b;

  int divide(int a, int b) {
    if (b == 0) throw ArgumentError('Cannot divide by zero');
    return a ~/ b;
  }
}

Writing tests

// test/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_project/calculator.dart';

void main() {
  group('Calculator', () {
    late Calculator calc;

    // setUp runs before EVERY test in this group -- fresh state each time.
    setUp(() {
      calc = Calculator();
    });

    test('adds two positive numbers', () {
      expect(calc.add(2, 3), equals(5));
    });

    test('divides evenly', () {
      expect(calc.divide(10, 2), 5);
    });

    test('throws on division by zero', () {
      expect(() => calc.divide(10, 0), throwsArgumentError);
    });
  });
}

Run the whole suite with:

dart test
00:00 +0: loading test/calculator_test.dart
00:00 +1: Calculator adds two positive numbers
00:00 +2: Calculator divides evenly
00:00 +3: Calculator throws on division by zero
00:00 +3: All tests passed!

Reading a failure

Tests are only useful if failures are easy to diagnose. Here's what it looks like when an assertion doesn't hold — note the exact expected/actual values package:test prints:

test('intentionally wrong expectation', () {
  final calc = Calculator();
  expect(calc.add(2, 2), equals(5));
});
00:00 +0 -1: intentionally wrong expectation [E]
  Expected: <5>
    Actual: <4>

  package:matcher          expect
  test/fail_test.dart 7:5  main.<fn>

00:00 +0 -1: Some tests failed.

+0 -1 means zero passed, one failed — that count updates live as the suite runs, which is handy for spotting a hang or a slow test in a big suite.

group, setUp, and tearDown

group organizes related tests and gives them a shared label in output. setUp runs before each test in its group (and any nested groups); tearDown runs after each one, even if the test failed — the place to release resources like open files or database connections.

import 'package:test/test.dart';

void main() {
  group('Counter', () {
    late int counter;

    setUp(() {
      counter = 0; // fresh state for every single test
      print('setUp: counter reset');
    });

    tearDown(() {
      print('tearDown: cleaning up');
    });

    test('starts at zero', () {
      expect(counter, 0);
    });

    test('can be incremented', () {
      counter++;
      expect(counter, 1);
    });
  });
}

Each test gets its own setUp/tearDown pair — tests never leak state into one another, which is exactly what makes them trustworthy in any order.

Useful matchers

expect(actual, matcher) is the core assertion. Beyond plain equality, package:test ships a library of expressive matchers:

Matcher Checks
equals(x) Deep equality (same as the bare value for most types)
throwsArgumentError The callback throws an ArgumentError
throwsA(isA<MyException>()) The callback throws a specific custom type
isA<T>() The value's runtime type is (or extends) T
contains(x) A List/String/Map contains x
isEmpty / isNotEmpty Collection or string emptiness
closeTo(value, delta) A double is within delta of value
import 'package:test/test.dart';

class InvalidTemperatureException implements Exception {
  final String message;
  InvalidTemperatureException(this.message);
}

double toFahrenheit(double celsius) {
  if (celsius < -273.15) {
    throw InvalidTemperatureException('Below absolute zero');
  }
  return celsius * 9 / 5 + 32;
}

void main() {
  test('converts a normal temperature', () {
    expect(toFahrenheit(0), closeTo(32.0, 0.001));
  });

  test('rejects an impossible temperature', () {
    expect(
      () => toFahrenheit(-300),
      throwsA(isA<InvalidTemperatureException>()),
    );
  });
}

Testing async code

test() accepts an async callback, and expectLater awaits a matcher against a Future or Stream — no special ceremony needed beyond the async/await you already know from Async basics.

import 'package:test/test.dart';

Future<String> fetchGreeting() async {
  await Future.delayed(const Duration(milliseconds: 50));
  return 'Hello!';
}

void main() {
  test('fetchGreeting resolves with the right value', () async {
    final result = await fetchGreeting();
    expect(result, 'Hello!');
  });

  test('fetchGreeting completes as a Future<String>', () {
    expect(fetchGreeting(), completion('Hello!'));
  });
}

How It Actually Works

package:test runs each test inside its own error zone (a Dart Zone) — a mechanism the Dart runtime provides for intercepting uncaught errors, print calls, and scheduling within a delimited region of code. This is how the test runner can catch an exception thrown from deep inside an asynchronous callback that has nothing structurally to do with the test() function's own call stack (say, an error thrown inside a Timer callback started by the code under test) and correctly attribute it as that specific test's failure rather than crashing the whole process.

setUp/tearDown execute around every test in their enclosing group, run via the same zone-based scheduling — the test runner literally constructs a fresh execution context per test, which is why state declared at the top of a group and reassigned in setUp doesn't leak between tests even though it's "the same variable" textually: each test's setUp call reassigns it before that test's body runs, and Dart doesn't share mutable closures across tests unless you deliberately hoist them outside setUp.

Testing async code with expectLater/awaited expect calls works because the test framework registers the test function itself as a Future-returning callback with the runner — the runner awaits your test body's returned Future before considering the test finished, which is exactly why a test that fires off an unawaited async operation and returns can pass "green" while that operation's assertion failure surfaces later, sometimes attributed to the next test, unless you explicitly await it.

Exercise

Create a small lib/string_utils.dart with a function String truncate(String input, int maxLength) that returns input unchanged if it's maxLength or shorter, and otherwise returns the first maxLength characters followed by '...'. Write test/string_utils_test.dart with a group covering at least four cases: a string shorter than the limit, one exactly at the limit, one longer than the limit, and an empty string. Run dart test and confirm all cases pass; then temporarily break the function (e.g. off-by-one on the length check) and confirm the test output clearly shows which case failed and why.