Skip to content

Testing

Test ViewModels as plain Dart classes, without Flutter.

dart
final scope = TestScope();
final vm = scope.create(() => CounterViewModel());

vm.increment();
expect(vm.count.value, 1);

scope.dispose();

TestScope

dart
late TestScope scope;

setUp(() => scope = TestScope());
tearDown(() => scope.dispose());

test('counter increments', () {
  final vm = scope.create(() => CounterViewModel());
  expect(vm.count.value, 0);
  vm.increment();
  expect(vm.count.value, 1);
});

Mocking Dependencies

dart
class MockUserRepo extends Mock implements UserRepository {}

late MockUserRepo repo;
late UserViewModel vm;

setUp(() {
  scope = TestScope();
  repo = MockUserRepo();
  vm = scope.create(() => UserViewModel(repo));
});

test('loads user', () async {
  when(() => repo.getUser('1')).thenAnswer((_) async => user);

  vm.loadUser('1');
  await Future.delayed(Duration.zero);

  expect(vm.user.hasData, isTrue);
  expect(vm.user.dataOrNull, user);
});

Async State Transitions

dart
test('loading then data', () async {
  when(() => repo.getData()).thenAnswer((_) async => data);

  expect(vm.items.isEmpty, isTrue);
  vm.loadItems();
  expect(vm.items.isLoading, isTrue);
  await Future.delayed(Duration.zero);
  expect(vm.items.hasData, isTrue);
});

test('error on failure', () async {
  when(() => repo.getData()).thenThrow(Exception('Network'));

  vm.loadItems();
  await Future.delayed(Duration.zero);

  expect(vm.items.hasError, isTrue);
});

Stream Bindings

dart
test('updates on emit', () async {
  final controller = StreamController<User>();
  when(() => repo.userStream).thenAnswer((_) => controller.stream);

  final vm = scope.create(() => AuthViewModel(repo));
  expect(vm.user.value, isNull);

  controller.add(User(name: 'Alice'));
  await Future.delayed(Duration.zero);

  expect(vm.user.value?.name, 'Alice');
  controller.close();
});

Task Cancellation

dart
test('cancels previous search', () async {
  var calls = 0;
  when(() => repo.search(any())).thenAnswer((_) async {
    calls++;
    await Future.delayed(Duration(milliseconds: 100));
    return ['result'];
  });

  vm.search('a');
  vm.search('ab');   // Cancels first
  vm.search('abc');  // Cancels second

  await Future.delayed(Duration(milliseconds: 150));

  expect(vm.results.dataOrNull, ['result']);
  expect(calls, 3);  // All called, only last updates state
});

Widget Tests

dart
testWidgets('shows user name', (tester) async {
  final repo = MockUserRepo();
  when(() => repo.userStream).thenAnswer((_) => Stream.value(User(name: 'Alice')));

  await tester.pumpWidget(
    ViewModelScope(
      create: [() => AuthViewModel(repo)],
      child: MaterialApp(home: ProfilePage()),
    ),
  );
  await tester.pumpAndSettle();

  expect(find.text('Alice'), findsOneWidget);
});

Tips

TipWhy
Use TestScopeEnsures disposal in tearDown
Mock repositoriesNot ViewModels
Future.delayed(Duration.zero)Lets async complete
Test state, not implementationCheck hasData, not internals

Released under the MIT License.