E2E tests with Firebase (Riverpod)
Learn how to write lightning-fast end-to-end tests for Firebase apps using TapTest! This guide (and example app) demonstrates mocking Firebase Auth and Firestore for widget tests, while keeping integration tests flexible with real services or emulators.
โฑ๏ธ Time to read: 15 minutes
๐ฏ State management: Riverpod (patterns apply to any architecture)
๐ฆ Example app: taptest/examples/firebase_riverpod
๐ Comprehensive E2E testโ
The Widget Test in example app completes in โฐ 3 seconds and verifies complete user journeys, including:
โจ Pixel-perfect design (light & dark themes)
- Registration screen
- About screen
- Dashboard (empty state and with data)
๐ก๏ธ Error handling
- Empty form validation
- Invalid password confirmation
๐ญ User flows
- Registration
- Login
- Start app with logged-in state
- Logout
- Saving and deleting memos
- Memos sorting logic
- Form behavior (clearing fields on submit)
๐งญ Navigation
- Deeplinks
- Route guards (protecting authenticated routes)
๐ฏ Testing strategyโ
- Widget tests: You have to use mocks for Firebase services. This gives you blazing-fast tests with complete control over data and edge cases.
- Integration tests: Your choice, use mocks, real Firebase project or Firebase Emulators.
๐๏ธ Dependency Inversionโ
To enable fast, mockable tests, we'll apply the Dependency Inversion Principle (one of the SOLID principles). Instead of tightly coupling your app to the Firebase SDK, we'll depend on abstractions. This approach works with any architecture - MVVM, Clean Architecture, or none at all.
โ Before: Tight couplingโ
Your code directly depends on Firebase SDK:
Future<void> onFormSubmitted() async {
final firebaseAuth = FirebaseAuth.instance;
// try/catch ...
await firebaseAuth.createUserWithEmailAndPassword(
email: emailTextEditingController.text,
password: passwordTextEditingController.text
);
// route to next screen etc.
}
Problems:
- Hard to test (requires real Firebase)
- Hard to swap implementations
- Firebase details leak throughout your app
โ After: Dependency inversionโ
Create an interface and Firebase implementation:
abstract class AuthRepository {
Future<void> register({required String email, required String password});
// other methods and accessors for current user
}
final class FirebaseAuthRepository {
Future<void> register({required String email, required String password}) {
return FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: password
);
}
}
๐ Wire it up with Riverpodโ
Create a provider that returns your Firebase implementation:
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_repository_provider.g.dart';
(keepAlive: true)
AuthRepository authRepository(Ref ref) {
return FirebaseAuthRepository();
}
๐ฏ Use the abstractionโ
Now your code depends on the interface, not Firebase directly:
Future<void> onFormSubmitted() async {
final authRepository = ref.read(authRepositoryProvider);
// try/catch ...
await authRepository.register(
email: emailTextEditingController.text,
password: passwordTextEditingController.text
);
// route to next screen etc.
}
Benefits:
- โ Easy to mock in tests
- โ Can swap Firebase for another service
- โ Firebase details contained in one place
๐ญ Create mock implementationโ
Now that we have an interface, let's create a mock implementation for widget tests. Create this in test/mocks folder:
final class MockAuthRepository implements AuthRepository {
final StreamStore<AppUser?> _store;
AppUser? get user => _store.value;
Stream<AppUser?> get userStream => _store.stream;
MockAuthRepository({AppUser? user}) : _store = StreamStore<AppUser?>(user);
Future<void> register({required String email, required String password}) {
_store.value = AppUser(
id: Uuid().v4(),
email: email,
);
return Future<void>.value();
}
}
๐ก See the complete implementation: Check mock_auth_repository.dart in the example app for all auth methods (login, logout, etc.)
๐ StreamStore helperโ
Both MockAuthRepository and MockMemosRepository (source) use a simple StreamStore - a lightweight stream-backed value holder that mimics Firebase's reactive behavior. This lets your tests observe and react to data changes just like the real Firebase SDK:
final class StreamStore<T> {
final BehaviorSubject<T> _subject;
Stream<T> get stream => _subject.stream;
T get value => _subject.value;
set value(T value) => _subject.add(value);
void close() => _subject.close();
StreamStore(T value) : _subject = BehaviorSubject<T>.seeded(value);
}
๐ฏ Custom user modelโ
Following the dependency inversion principle, we don't depend on Firebase's User type directly. Instead, we define our own AppUser model:
final class AppUser {
final String id;
final String email;
const AppUser({
required this.id,
required this.email
});
}
๐ก See FirebaseAuthRepository in the example for how to convert between
UserandAppUser.
โจ Flexible initializationโ
You can initialize MockAuthRepository with or without a user to test different scenarios:
// Test logged-out state
MockAuthRepository()
// Test logged-in state
MockAuthRepository(user: AppUser(id: '123', email: '[email protected]'))
Your mocks, your control! You decide the initial state, data, and behavior.
โ๏ธ Configure TapTestโ
Now let's wire everything together! We'll configure TapTest to use our mocks instead of real Firebase.
๐ฑ Production setupโ
Your production main.dart typically initializes Firebase and wraps the app in ProviderScope (Riverpod):
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: ...);
runApp(
ProviderScope(
child: const YourApp(),
),
);
}
๐งช Test setupโ
Tests run their own main() function. That means we may need to repeat some setup and can also swap in test-specific services here - like our MockAuthRepository.
// imports ...
void main() {
final config = Config(
builder: (params) {
final providerContainer = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(
MockAuthRepository(), // ๐ here
),
],
);
return UncontrolledProviderScope(
container: providerContainer,
child: YourApp(params: params), // ๐ also RuntimeParams
);
},
);
tapTest('E2E', config, (tt) async {
await tt.exists(RegisterKeys.screen);
// Let's register
await tt.type(RegisterKeys.emailField, '[email protected]');
await tt.type(RegisterKeys.passwordField, 'password123');
await tt.type(RegisterKeys.confirmPasswordField, 'password123', submit: true);
// Let's verify we are on Dashboard with correct user
await tt.exists(DashboardKeys.screen);
await tt.expectText(DashboardKeys.email, '[email protected]');
// Let's logout
await tt.tap(DashboardKeys.logoutButton);
await tt.exists(RegisterKeys.screen);
});
}
That's it! Your app now uses MockAuthRepository instead of Firebase. Test any auth feature without touching Firebase servers.
๐๏ธ Firestore mockingโ
The same pattern applies to Firestore collections. Create repository interfaces for your collections (e.g., MemosRepository), implement them with Firebase in production, and provide mock implementations in tests. The mock can use the same StreamStore pattern to simulate real-time listeners.
See the complete Firestore mock implementation: mock_memos_repository.dart
๐ Integration testsโ
Integration tests have no restrictions on Firebase usage - you can use real Firebase, emulators, or mocks. The choice is yours!
The example app uses Firebase Emulators for integration tests, giving you an almost-real Firebase experience without needing to maintain a live project for the sake of this demo.
โ ๏ธ Handling stateful dataโ
Unlike widget tests (which recreate mocks for each test), integration tests using real Firebase or emulators maintain state. Once you register a user, that user exists for subsequent operations.
Solutions for registration tests:
Option 1: Generate unique emails
final randomEmail = '${Uuid().v4()}@example.com';
await tt.type(RegisterKeys.emailField, randomEmail);
...
await tt.expectText(DashboardKeys.email, randomEmail);
Option 2: Clean up between tests (use with caution)
await FirebaseAuth.instance.currentUser?.delete();
await tt.tap(DashboardKeys.logoutButton);
๐ง Pre-configuring test dataโ
For testing login flows with real Firebase or emulators, prepare user accounts in the builder:
final config = Config(
builder: (params) async {
await Firebase.initializeApp(options: ...);
// prepare user
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: '[email protected]',
password: 'password'
);
// logout (if testing login flow, or skip it if testing already logged in flow)
await FirebaseAuth.instance.signOut();
return ProviderScope(
child: YourApp(params: params),
);
},
);
This approach lets you test login screens with known credentials while maintaining realistic Firebase behavior.