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

๐Ÿš€ 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 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:

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.