Dart Unit Testing

Unit testing is a fundamental means of ensuring code quality.

It verifies whether the behavior of each smallest functional unit (usually a function or method) meets expectations.

This chapter introduces the usage of Dart's test package, writing test cases with test(), assertions with expect(), and grouping tests with group().


Installation and configuration of the test package

Dart officially provides the test package for writing and running tests.

First, add the dependency in pubspec.yaml:

# 文件路径:pubspec.yaml
dev_dependencies:
  test: ^1.24.0

Then run the following command to install:

$ dart pub get

Test files are usually placed in the project's test/ directory, with filenames ending in _test.dart.

A typical project structure:

my_project/
├── lib/
│   └── calculator.dart       # 被测试的代码
├── test/
│   └── calculator_test.dart  # 测试文件
└── pubspec.yaml

Writing test() test cases

The test() function is the basic unit for writing test cases.

It takes two parameters: a test description (string) and a test function body.

Example

The code under test (lib/calculator.dart):

// File path: lib/calculator.dart
class Calculator {
  int add(int a, int b) => a + b;
  int subtract(int a, int b) => a - b;
  int multiply(int a, int b) => a * b;

  double divide(int a, int b) {
    if (b == 0) {
      throw ArgumentError(Divisor cannot be 0);
    }
    return a / b;
  }

  bool isEven(int n) => n % 2 == 0;

  List<int> filterPositive(List<int> numbers) {
    return numbers.where((n) => n > 0).toList();
  }
}

Example

Test file (test/calculator_test.dart):

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

void main() {
  // Create a Calculator instance for testing
  var calculator = Calculator();

  // test() function: the first parameter is the test description, the second is the test function
  test('add() adds two positive numbers', () {
    var result = calculator.add(3, 5);
    // expect() assertion: verifies whether the actual result equals the expected value
    expect(result, equals(8));
  });

  test('add() adds negative numbers', () {
    expect(calculator.add(-3, 5), equals(2));
    expect(calculator.add(-3, -5), equals(-8));
  });

  test('subtract() subtraction operation', () {
    expect(calculator.subtract(10, 3), equals(7));
  });

  test('multiply() multiplication operation', () {
    expect(calculator.multiply(4, 5), equals(20));
    // Multiply by zero
    expect(calculator.multiply(100, 0), equals(0));
  });

  test(divide() normal division, () {
    expect(calculator.divide(10, 2), equals(5.0));
    expect(calculator.divide(7, 2), equals(3.5));
  });

  test(isEven() even number check, () {
    expect(calculator.isEven(4), isTrue);
    expect(calculator.isEven(5), isFalse);
  });
}

Run tests:

$ dart test
00:00 +6: All tests passed!

expect assertions

expect() is the most core function in testing; it verifies whether an actual value satisfies a condition.

Basic syntax: expect(actual, matcher).

Common Matchers

MatcherPurposeExample
equals(expected)Verify values are equalexpect(result, equals(42))
isTrue / isFalseVerify boolean valuesexpect(flag, isTrue)
isNull / isNotNullVerify nullexpect(value, isNull)
contains(value)Contains an element (list) or substring (string).expect(list, contains('a'))
isA()Verify typesexpect(obj, isA())
throws()Verify exception thrownexpect(() => f(), throwsException)
isNotEmptynon-nullexpect(list, isNotEmpty)
hasLength(n)Verify lengthexpect(list, hasLength(3))
greaterThan(n)greater thanexpect(score, greaterThan(60))
closeTo(value, delta)Floating-point numbers approximately equalexpect(3.14, closeTo(3.1, 0.1))

Example

Practical applications of various Matchers:

import 'package:test/test.dart';

void main() {
  test(EXAMPLE Various Assertion Examples, () {
    // Basic equality
    expect(2 + 2, equals(4));

    // Boolean values
    expect('hello'.contains('h'), isTrue);
    expect(''.isEmpty, isTrue);

    // null check
    String? name;
    expect(name, isNull);
    name = 'EXAMPLE';
    expect(name, isNotNull);

    // Type check
    expect('EXAMPLE', isA<String>());
    expect(42, isA<int>());

    // List/string contains
    expect([1, 2, 3], contains(2));
    expect('Hello, EXAMPLE!', contains('EXAMPLE'));

    // Length
    expect([1, 2, 3], hasLength(3));
    expect('Dart', hasLength(4));

    // Numeric comparison
    expect(100, greaterThan(50));
    expect(30, lessThan(60));
    expect(75, greaterThanOrEqualTo(60));

    // Floating-point comparison (avoid precision issues)
    expect(0.1 + 0.2, closeTo(0.3, 0.001));

    // Collection is not empty
    expect([1, 2], isNotEmpty);

    // List equality
    expect([1, 2, 3], equals([1, 2, 3]));

    // Map contains a key
    var user = {'name': 'example', 'age': 10};
    expect(user, containsPair('name', 'example'));
    expect(user.keys, contains('age'));
  });
}
00:00 +1: All tests passed!

Testing exceptions

Example

import 'package:test/test.dart';

int divide(int a, int b) {
  if (b == 0) throw ArgumentError(Divisor cannot be 0);
  return a ~/ b;
}

void main() {
  test(divide() should throw an exception when dividing by zero, () {
    // Verify that any exception is thrown
    expect(() => divide(10, 0), throwsException);

    // Verify that a specific type of exception is thrown
    expect(() => divide(10, 0), throwsArgumentError);

    // Verify exception message
    expect(
      () => divide(10, 0),
      throwsA(predicate((e) =>
          e is ArgumentError &&
          e.message.contains(divisor))),
    );
  });

  test(divide() does not throw an exception in normal cases, () {
    expect(() => divide(10, 2), returnsNormally);
    expect(divide(10, 2), equals(5));
  });
}
00:00 +2: All tests passed!

When testing exceptions, you need to wrap the code that may throw an exception in an anonymous function (() => code), rather than calling it directly. If you write expect(divide(10, 0), throwsException) directly, divide will throw an exception immediately, and expect never gets a chance to execute.


Grouping tests with group()

group() is used to organize related test cases together, making the test structure clearer.

Example

import 'package:test/test.dart';

// Function under test
class StringUtils {
  static String capitalize(String s) {
    if (s.isEmpty) return s;
    return s[0].toUpperCase() + s.substring(1);
  }

  static String reverse(String s) {
    return s.split('').reversed.join('');
  }

  static bool isPalindrome(String s) {
    var clean = s.toLowerCase().replaceAll(' ', '');
    return clean == reverse(clean);
  }

  static int countWords(String s) {
    if (s.trim().isEmpty) return 0;
    return s.trim().split(RegExp(r'\s+')).length;
  }
}

void main() {
  // group() nested test organization
  group('StringUtils', () {
    // Subgroup
    group('capitalize()', () {
      test(Capitalizes the first letter of a normal word, () {
        expect(StringUtils.capitalize('hello'), equals('Hello'));
      });

      test(An already capitalized word remains unchanged, () {
        expect(StringUtils.capitalize('Hello'), equals('Hello'));
      });

      test(An empty string returns an empty string, () {
        expect(StringUtils.capitalize(''), equals(''));
      });

      test(Single character, () {
        expect(StringUtils.capitalize('a'), equals('A'));
      });
    });

    group('reverse()', () {
      test(Reverses a normal string, () {
        expect(StringUtils.reverse('EXAMPLE'), equals('BOONUR'));
      });

      test(Reversing a palindrome string leaves it unchanged, () {
        expect(StringUtils.reverse('aba'), equals('aba'));
      });

      test(Empty string, () {
        expect(StringUtils.reverse(''), equals(''));
      });
    });

    group('isPalindrome()', () {
      test(Palindrome string returns true, () {
        expect(StringUtils.isPalindrome('racecar'), isTrue);
        expect(StringUtils.isPalindrome('A man a plan a canal Panama'), isTrue);
      });

      test(Non-palindrome string returns false, () {
        expect(StringUtils.isPalindrome('hello'), isFalse);
      });

      test(An empty string is considered a palindrome, () {
        expect(StringUtils.isPalindrome(''), isTrue);
      });
    });

    group('countWords()', () {
      test(Normal sentence, () {
        expect(StringUtils.countWords('Hello World Dart'), equals(3));
      });

      test(Extra spaces, () {
        expect(StringUtils.countWords('  Hello   World  '), equals(2));
      });

      test(Empty string, () {
        expect(StringUtils.countWords(''), equals(0));
        expect(StringUtils.countWords('   '), equals(0));
      });
    });
  });
}
00:00 +12: All tests passed!

group() can be nested to create a hierarchical test structure.

This makes test reports easier to read and also helps quickly locate failing tests.

A good grouping strategy is to organize by the "module/class/method under test". This way, when a test fails, you can immediately know which functionality has a problem.

setUp and tearDown

setUp runs before each test, tearDown runs after each test.

They are used to prepare the test environment and clean up resources.

Example

import 'package:test/test.dart';

// Simulate a class that needs initialization and cleanup
class Database {
  bool isConnected = false;

  void connect() {
    isConnected = true;
    print(Database connected);
  }

  void disconnect() {
    isConnected = false;
    print(Database disconnected);
  }

  String query(String sql) {
    if (!isConnected) throw Exception(Database not connected);
    return Query result: $sql;
  }
}

void main() {
  group(Database test, () {
    late Database db;  // late lazy initialization

    // Execute before each test
    setUp(() {
      db = Database();
      db.connect();
      print([setUp] Prepare test environment);
    });

    // Execute after each test
    tearDown(() {
      db.disconnect();
      print([tearDown] Clean up test environment);
    });

    test(query() normal query, () {
      var result = db.query('SELECT * FROM users');
      expect(result, contains('EXAMPLE') ? result.contains('users') : result.contains('users'));
      // Simplify assertions
      expect(result, isNotEmpty);
    });

    test(query() can execute multiple queries after connecting, () {
      var r1 = db.query('SELECT 1');
      var r2 = db.query('SELECT 2');
      expect(r1, isNotEmpty);
      expect(r2, isNotEmpty);
    });

    test(Query throws an exception when not connected, () {
      db.disconnect();  // Manually disconnect
      expect(() => db.query('SELECT 1'), throwsException);
    });
  });
}
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
  数据库已连接
  [setUp] 准备测试环境
  数据库已断开
  [tearDown] 清理测试环境
00:00 +3: All tests passed!

Note the execution order of setUp and tearDown in the output—they execute once before and after each test.

setUp and tearDown ensure that each test starts from a clean state and is not affected by other tests. This is a key guarantee of test independence.


Asynchronous test

The Dart test package natively supports asynchronous testing.

A test function can return a Future, and the framework will automatically wait for the Future to complete.

Example

import 'package:test/test.dart';

// Asynchronous function: simulate network request
Future<String> fetchUserData(int userId) async {
  await Future.delayed(Duration(milliseconds: 100));

  if (userId <= 0) {
    throw Exception(Invalid user ID);
  }

  return Data of user $userId;
}

Future<List<int>> fetchNumbers() async {
  await Future.delayed(Duration(milliseconds: 50));
  return [1, 2, 3, 4, 5];
}

void main() {
  group(Asynchronous test, () {
    test(fetchUserData() fetches data normally, () async {
      var data = await fetchUserData(1);
      expect(data, equals(Data of user 1));
    });

    test(fetchUserData() throws an exception for invalid ID, () async {
      expect(
        () => fetchUserData(-1),
        throwsA(isA<Exception>()),
      );
    });

    test('fetchNumbers() returns the correct list', () async {
      var numbers = await fetchNumbers();
      expect(numbers, hasLength(5));
      expect(numbers, contains(3));
      expect(numbers.first, equals(1));
    });

    // Testing multiple asynchronous operations
    test('Sequential asynchronous operations', () async {
      var data1 = await fetchUserData(1);
      var data2 = await fetchUserData(2);

      expect(data1, isNot(equals(data2)));
      expect(data1, contains('1'));
      expect(data2, contains('2'));
    });
  });
}
00:00 +4: All tests passed!

Common commands for running tests

CommandFeatures
dart testRun all tests
dart test test/calculator_test.dartRun a specified test file
dart test --name="add"Only run tests whose names contain "add"
dart test --concurrency=4Run tests concurrently (speed up)
dart test --reporter=expandedVerbose output mode
dart test --coverage=coverageGenerate test coverage data

Testing best practices

PracticeDescription
Each test should verify only one thingOne test() corresponds to one behavior, making failure location faster.
Test names describe behavior, not implementation"add() adds two positive numbers" is better than "add() test".
AAA PatternArrange (Set up) → Act (Execute) → Assert (Verify)
Write failing tests firstConfirm that the test can catch errors, then write code to make it pass.
Boundary condition testingNull, zero, negative numbers, very large values, very small values.
Keep tests independent of each otherThe result of one test should not affect another test

Tests are not a burden but insurance. The more tests you write, the more confident you are when refactoring. When a test fails, you don't need to guess what went wrong—the test will tell you precisely.


Chapter summary

This chapter introduces the complete workflow of Dart unit testing: installing the test package, writing test() test cases, using expect() assertions, organizing with group(), managing the environment with setUp/tearDown, and asynchronous testing.

Unit testing is a required course for professional developers; developing the habit of writing tests will continuously improve your code quality.


Full Tutorial Summary

Congratulations on completing your introductory learning of the Dart programming language!

Let's review the learning path of these 21 chapters:

PhaseChapterCore Content
1. Getting Started BasicsChapters 1-4Dart overview, environment setup, first program, basic syntax.
2. Core Types and Control FlowChapters 5-8Variables and data types, operators, control flow, collections.
3. Functions and Object-Oriented ProgrammingChapters 9-13Functions, classes and objects, inheritance and polymorphism, interfaces and Mixin, generics.
4. Advanced FeaturesChapters 14-17Enums and symbols, exception handling, package and library management, typedef.
5. Asynchronous Programming and TestingChapters 18-21Async programming, Streams, concurrency and Isolate, unit testing.

Having mastered all this content, you already have the ability to use Dart for daily development.

Next step: you can dive deeper.Flutter FrameworkApply Dart knowledge to UI development on mobile and web.

other extensions