Skip to main content

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

Consider cloning the TapTest repository and opening the examples/firebase_riverpod project to explore the app and its tests. To run the app, you'll need Firebase Emulators installed and running:

cd examples/firebase_riverpod
firebase emulators:start

๐Ÿš€ 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 User and AppUser.

โœจ 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):

lib/main.dart
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.

test/e2e_test.dart
// 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 these files in the example app:

๐Ÿš€ Run widget testsโ€‹

Run the widget test suite:

cd examples/firebase_riverpod
flutter test test

This executes all_test.dart, which contains three tests covering different scenarios:

  • End-to-end user journey (registration โ†’ dashboard โ†’ memos add/delete โ†’ logout)
  • Deeplink navigation
  • Login journey
  • Starting with logged-in state

Each test creates a customized Config to simulate different initial states. For example, the E2E test uses the default configuration:

tapTest('E2E', createConfig(), (tt) async {
// test steps...
});

while the logged-in state test pre-configures MockAuthRepository with an existing user:

tapTest(
'Logged in user will immediately see Dashboard',
createConfig(
user: AppUser(id: '1', email: '[email protected]'),
),
(tt) async {
await tt.exists(DashboardKeys.screen);
},
);

This pattern lets you test any scenario by simply adjusting the initial state. See create_config.dart for the helper function implementation.

๐Ÿ“Š Code coverageโ€‹

The example includes a helper script to run tests with coverage and open the report:

./scripts/test_with_coverage.sh

โš ๏ธ Prerequisite: Requires genhtml (install via brew install lcov on macOS)

The widget tests achieve 75% code coverage of the app. Uncovered lines are primarily in the Firebase implementation classes (which are substituted with mocks in widget tests). For even higher coverage, consider running integration tests with coverage enabled to validate the real Firebase implementations.

๐Ÿ“ฑ 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:

integration_test/e2e_test.dart
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.

๐Ÿš€ Run integration testsโ€‹

โš ๏ธ Prerequisites:

  • Firebase Emulators must be running: firebase emulators:start
  • iPhone 17 Pro simulator must be running (snapshots were recorded on this device)
cd examples/firebase_riverpod
firebase emulators:start
flutter test integration_test

The integration tests in all_test.dart runs similar sets of tests as the widget test mentioned before.