Introduction
Test coverage is the backbone of maintainable Flutter applications, but writing realistic tests often means mocking dependencies. Mocking with mocktail: Flutter best practices explained is about stripping away complexity and focusing on what matters—verifying behavior without wrestling with code generation or verbose setup.
Mocktail is a lightweight mocking library built for Dart that lets you create mocks, stub return values, and verify interactions with minimal boilerplate. We've found it invaluable across e-commerce platforms, SaaS dashboards, and ride-hailing apps where services need thorough testing without slowing down the feedback loop.
Why mocktail over code generation
Before diving into patterns, let's acknowledge the choice. Mockito, the older alternative, relies on code generation: you write mocks, run build_runner, and manage generated files. Mocktail uses runtime metaprogramming instead. No build step, no generated code cluttering your directories, and faster iteration during development.
For teams moving fast or working on smaller packages, this matters. You write mocks directly in your test files, and they work immediately. The trade-off is slightly less type safety at compile time, but in practice, the convenience outweighs the risk.
Mock repositories and services
Repositories are the first line of abstraction in layered architectures. They abstract data sources—APIs, databases, caches—from business logic. When testing your BLoC or state manager, you mock the repository to control what data it returns and verify the state manager responds correctly.
Start with a repository interface:
abstract class UserRepository {
Future<User> getUser(String id);
Future<void> updateUser(User user);
Future<List<User>> listUsers();
}Then create a mock in your test file:
class MockUserRepository extends Mock implements UserRepository {}That's it. Mocktail extends Mock, giving you stubs, verification, and introspection. Now you can instantiate it and pass it to code under test:
void main() {
late MockUserRepository mockUserRepository;
late UserBloc userBloc;
setUp(() {
mockUserRepository = MockUserRepository();
userBloc = UserBloc(repository: mockUserRepository);
});
test('emits loading then success state', () async {
// We'll stub the mock next
expect(
userBloc.stream,
emitsInOrder([
isA<UserLoading>(),
isA<UserLoaded>(),
]),
);
userBloc.add(FetchUserEvent(id: '123'));
});
}Mock services the same way. If your app uses an analytics service, authentication service, or third-party API client, extract an interface and mock it in tests. This isolates the code under test and prevents side effects.
Using when() for stubs
Stubbing is the act of saying "when this method is called with these arguments, return this value." The when() function is your tool for that.
With our UserRepository mock, if the BLoC calls getUser('123'), we need to tell the mock what to return:
test('loads user successfully', () async {
final testUser = User(id: '123', name: 'Alice', email: 'alice@example.com');
when(() => mockUserRepository.getUser('123'))
.thenAnswer((_) async => testUser);
expect(
userBloc.stream,
emitsInOrder([
isA<UserLoading>(),
isA<UserLoaded>().having((state) => state.user, 'user', testUser),
]),
);
userBloc.add(FetchUserEvent(id: '123'));
});A few details:
when()takes a closure that calls the mocked method.thenAnswer()returns a future for async methods; usethenReturn()for synchronous ones.- The underscore in
(_) async =>captures the invocation context if you need it; usually you ignore it.
For methods that should throw, use thenThrow():
test('handles repository error gracefully', () async {
when(() => mockUserRepository.getUser('invalid'))
.thenThrow(RepositoryException('User not found'));
expect(
userBloc.stream,
emitsInOrder([
isA<UserLoading>(),
isA<UserError>(),
]),
);
userBloc.add(FetchUserEvent(id: 'invalid'));
});Stubbing before the test runs ensures your mock behaves predictably. It's the contract between test and code: the mock promises to respond a certain way, and the code under test must handle that response correctly.
Using verify() for interactions
Stubs control outputs. Verification checks that your code actually calls the dependencies the way you expect. This is critical for catching bugs like forgetting to call a method or calling it with the wrong arguments.
After running your test, use verify() to assert the mock was called:
test('calls repository getUser once', () async {
final testUser = User(id: '123', name: 'Alice', email: 'alice@example.com');
when(() => mockUserRepository.getUser('123'))
.thenAnswer((_) async => testUser);
userBloc.add(FetchUserEvent(id: '123'));
await Future.delayed(Duration(milliseconds: 100)); // Give BLoC time to process
verify(() => mockUserRepository.getUser('123')).called(1);
});The called() matcher ensures the method was invoked exactly once. You can also verify no calls happened:
verifyNever(() => mockUserRepository.updateUser(any()));Or check that certain calls happened in a specific order using verifyInOrder():
test('fetches then caches user', () async {
final testUser = User(id: '123', name: 'Alice', email: 'alice@example.com');
when(() => mockUserRepository.getUser('123'))
.thenAnswer((_) async => testUser);
// Simulate two identical requests
userBloc.add(FetchUserEvent(id: '123'));
await Future.delayed(Duration(milliseconds: 50));
userBloc.add(FetchUserEvent(id: '123'));
await Future.delayed(Duration(milliseconds: 100));
verifyInOrder([
() => mockUserRepository.getUser('123'),
() => mockUserRepository.getUser('123'),
]);
});Verification forces you to think about what your code should do. If you forget to call a repository method or call it with the wrong ID, the test fails immediately.
Registering fallback values for custom types
Mocktail excels at handling built-in types like strings, ints, and futures. But custom types can trip you up. If your repository expects a User object as a parameter and the mock doesn't know how to generate a default instance, mocktail will complain.
Fallback values solve this. Set them up in your test file before running tests:
void main() {
setUpAll(() {
registerFallbackValue(User(
id: '',
name: '',
email: '',
));
});
late MockUserRepository mockUserRepository;
late UserBloc userBloc;
setUp(() {
mockUserRepository = MockUserRepository();
userBloc = UserBloc(repository: mockUserRepository);
});
test('updates user', () async {
final updatedUser = User(id: '123', name: 'Bob', email: 'bob@example.com');
when(() => mockUserRepository.updateUser(any()))
.thenAnswer((_) async => updatedUser);
userBloc.add(UpdateUserEvent(user: updatedUser));
await Future.delayed(Duration(milliseconds: 100));
verify(() => mockUserRepository.updateUser(updatedUser)).called(1);
});
}The registerFallbackValue() call happens once in setUpAll(). Now when you use matchers like any(), mocktail knows how to handle User instances. This is essential for complex domain models that appear across multiple methods.
For enums, simple types, or complex objects, always register fallbacks. It saves debugging time and makes tests clearer.
Common mistakes
Overusing any(): It's tempting to stub with any() for every parameter, but this hides bugs. If your code should pass a specific ID, stub with that ID. The mock's job is to simulate real behavior, not be a catch-all.
Forgetting to await futures: Many repository methods return futures. If you don't await or add a delay in your test, verification might run before the mock is called. Use expect(stream, emitsInOrder(...)) for state managers, or add a small delay before verifying.
Not separating arrange, act, assert: Keep stubs at the top of your test, then call the code under test, then verify. Mixing these steps makes tests hard to read and debug.
Forgetting to register fallback values for new types: When you add a new custom class and pass it to a mocked method, register its fallback early. The compilation will catch many errors, but runtime issues are painful.
Key takeaways
- Mocktail eliminates code generation and lets you write mocks inline, speeding up test iteration.
- Mock repositories and services to isolate the code under test and prevent side effects.
- Use
when()to stub return values and control what your mocks return. - Use
verify()andverifyInOrder()to assert that dependencies were called correctly. - Register fallback values for custom types so mocktail knows how to handle them in matchers.
- Keep test structure clear: arrange (stub), act (call code), assert (verify).
- Test error paths with
thenThrow()to ensure your code handles failures gracefully.






Comments
Loading comments…