How to write unit tests with Firebase in Flutter

I've recently published a few packages to help write unit tests for Flutter apps that use Firebase. This includes Firestore, Firebase Storage, Firebase Authentication with Google Sign In.

Why use mocks?

If you try to run your app in a unit test, it'll refuse to run because Firebase can only run in integration tests in an emulator or an actual device. There is a complete guide on how to run integration tests at https://flutter.dev/docs/cookbook/testing/integration/introduction, and Firestore's own example comes with integration tests (see cloud_firestore/example/test_driver/cloud_firestore.dart). However, integration tests are slow. Also they'll use an actual database. So you'd have to set up a separate test database in order not to pollute your production one.

I find mocks much more convenient because they run much faster, and I can control the exact state of the database in code.

How to use it

First let's take a look at Firestore's demo app:

// Copyright 2017, the Chromium project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final FirebaseApp app = await FirebaseApp.configure(
    name: 'test',
    options: const FirebaseOptions(
      googleAppID: '1:79601577497:ios:5f2bcc6ba8cecddd',
      gcmSenderID: '79601577497',
      apiKey: 'AIzaSyArgmRGfB5kiQT6CunAOmKRVKEsxKmy6YI-G72PVU',
      projectID: 'flutter-firestore',
    ),
  );
  final Firestore firestore = Firestore(app: app);
  await firestore.settings(timestampsInSnapshotsEnabled: true);

  runApp(MaterialApp(
      title: 'Firestore Example', home: MyHomePage(firestore: firestore)));
}

class MessageList extends StatelessWidget {
  MessageList({this.firestore});

  final Firestore firestore;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: firestore.collection('messages').snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        if (!snapshot.hasData) return const Text('Loading...');
        final int messageCount = snapshot.data.documents.length;
        return ListView.builder(
          itemCount: messageCount,
          itemBuilder: (_, int index) {
            final DocumentSnapshot document = snapshot.data.documents[index];
            final dynamic message = document['message'];
            return ListTile(
              title: Text(
                message != null ? message.toString() : '<No message retrieved>',
              ),
              subtitle: Text('Message ${index + 1} of $messageCount'),
            );
          },
        );
      },
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({this.firestore});

  final Firestore firestore;

  CollectionReference get messages => firestore.collection('messages');

  Future<void> _addMessage() async {
    await messages.add(<String, dynamic>{
      'message': 'Hello world!',
      'created_at': FieldValue.serverTimestamp(),
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Firestore Example'),
      ),
      body: MessageList(firestore: firestore),
      floatingActionButton: FloatingActionButton(
        onPressed: _addMessage,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

As you can see, the main() function instantiates a MyHomePage Widget and passes an instance of Firestore to it. The UI is organized like so:

  • MyHomePage
    • MessageList
      • ListTile
      • ListTile
      • ...
    • FloatingActionButton

MessageList displays the messages stored in firestore.collection("messages"), and each tap to the ActionButton adds one "Hello world!" message to that same collection.

There are two things we can test. First, that the MessageList does render messages. Second, that a tap to the ActionButton does result in a message being added to the database, and thus, rendered.

In the tests, instead of the actual Firestore implementation, I pass a MockFirestoreInstance. If needed, I can also pre-populate the database with data using the regular Firebase API.

import 'dart:async';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:cloud_firestore_mocks/cloud_firestore_mocks.dart';
import 'package:firestore_example/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

const MessagesCollection = 'messages';

void main() {
  testWidgets('shows messages', (WidgetTester tester) async {
    // Populate the mock database.
    final firestore = MockFirestoreInstance();
    await firestore.collection(MessagesCollection).add({
      'message': 'Hello world!',
      'created_at': FieldValue.serverTimestamp(),
    });

    // Render the widget.
    await tester.pumpWidget(MaterialApp(
        title: 'Firestore Example', home: MyHomePage(firestore: firestore)));
    // Let the snapshots stream fire a snapshot.
    await tester.idle();
    // Re-render.
    await tester.pump();
    // // Verify the output.
    expect(find.text('Hello world!'), findsOneWidget);
    expect(find.text('Message 1 of 1'), findsOneWidget);
  });

  testWidgets('adds messages', (WidgetTester tester) async {
    // Instantiate the mock database.
    final firestore = MockFirestoreInstance();

    // Render the widget.
    await tester.pumpWidget(MaterialApp(
        title: 'Firestore Example', home: MyHomePage(firestore: firestore)));
    // Verify that there is no data.
    expect(find.text('Hello world!'), findsNothing);

    // Tap the Add button.
    await tester.tap(find.byType(FloatingActionButton));
    // Let the snapshots stream fire a snapshot.
    await tester.idle();
    // Re-render.
    await tester.pump();

    // Verify the output.
    expect(find.text('Hello world!'), findsOneWidget);
  });
}

To run the tests, run flutter test:

example % flutter test 
00:02 +2: All tests passed!                                                                                                    
example % 

What next?

This is a work in progress. Many features are still missing, so feature requests and pull requests are welcome!

I also plan to write examples for how to use firebase_auth_mocks and firebase_storage_mocks.