How to use test-driven development (TDD) with Flutter

by: | Nov 12, 2020

Although it’s relatively new, Flutter is already one of our top cross-platform app development tools. And QA testing is one of our favorite app development topics. So, let’s combine these. Specifically, what’s it like using test-driven development (TDD) with Flutter? This post provides step by step instructions on how to use TDD to QA test a basic note-taking app built in Flutter.

But first…

What is test-driven development?

Test-driven development is the practice of writing QA tests for each unit of the app before any code is implemented. At the start of development, since there’s no app written yet, each test fails. Each development cycle is then run with the idea of writing the minimum code needed to make a test pass. Then the code is analyzed and refactored if needed. Then the cycle starts again.

There are a lot of benefits to using TDD:

  • Improves code quality: TDD helps focus thinking about what needs to be done first.
  • Well-written tests serve as documentation: Developers read the tests to understand expected functionality.
  • Gives you more confidence when changing code: After a test has started passing you know quickly if refactoring has broken something when it starts failing.
  • Helps reproduce bugs: TDD helps resolve bugs. When a bug fix is needed, you first write a test that fails because of the bug. And when the test passes, you know it’s fixed.

Flutter testing

Flutter includes a testing framework that makes using TDD extremely easy. Flutter’s testing documentation provides an overview of the types of tests you can write:

  • A unit test of a single function, method, or class.
  • A widget test for a single widget.
  • An integration test for a complete app or a large part of an app.

Ideally, an app should have enough unit tests to cover the business logic, widget tests to cover how UI reacts to business logic state changes, and integration tests to cover higher-level use cases.

Using Test-Driven Development with Flutter

To show how to use Flutter and TDD, let’s build a simple note-taking app.

Our app user requirements are:

  • Users can see a list of the notes.
  • Users can create a note.
  • Users can edit a note.
  • Users can delete a note.

We won’t spend a lot of effort on the UI. Instead we will show step by step instructions to demonstrate the standard TDD cycle: test fails, test passes, refactor.

A link to the GitHub repo with the fully working example is at the bottom of this post. You can browse the commits to see the process with the refactorings.

Start by creating a new project. Use the “Flutter Create” feature or create the project through any IDE.

For this application, we use cubit to manage states and equatable as our equality methods. So, make sure to add flutter_bloc:^6.0.5 and equatable: ^1.2.5 dependencies to your pubspec.yml file.

Step 1: Write the first TDD tests using Flutter cubit

The core of our app is our Flutter cubit, The cubit needs a note model and its states. Thinking about our app requirements, we have a simple list of notes as our state, and use methods to create, edit, and delete notes — which is basically a CRUD. We could write one test at a time, but to keep these instructions simpler, let’s add all of then in one shot:

import 'package:test/test.dart';

void main() {
  group('Notes Cubit', () {
    test('default is empty', () {
      var cubit = NotesCubit();
      expect(cubit.state.notes, []);
    });

    test('add notes', () {
      var title = 'title';
      var body = 'body';
      var cubit = NotesCubit();
      cubit.createNote(title, body);
      expect(cubit.state.notes.length, 1);
      expect(cubit.state.notes.first, Note(1, title, body));
    });

    test('delete notes', () {
      var cubit = NotesCubit();
      cubit.createNote('title', 'body');
      cubit.createNote('another title', 'another body');
      cubit.deleteNote(1);
      expect(cubit.state.notes.length, 1);
      expect(cubit.state.notes.first.id, 2);
    });

    test('update notes', () {
      var cubit = NotesCubit();
      cubit.createNote('title', 'body');
      cubit.createNote('another title', 'another body');
      cubit.createNote('yet another title', 'yet another body');

      var newTitle = 'my cool note';
      var newBody = 'my cool note body';
      cubit.updateNote(2, newTitle, newBody);
      expect(cubit.state.notes.length, 3);
      expect(cubit.state.notes[1], Note(2, newTitle, newBody));
    });
  });
}

The test implementation is pretty straightforward: We create our cubit, call the method, and make sure that the state is as we would expect. Some people would write the interface before the test. That’s OK, but for this post, we’re going to do the test first.

Note that we are testing only the happy path of this class. Later on, we add tests for what happens if the user deviates from the happy path, such as trying to delete a note that doesn’t exist.

Step 2: Implement the cubit

Now that we have our tests, we implement the cubit to make them pass. We need to write a Note model class that has an ID, title, and body. And for our NotesCubit, we have a state with a list of notes and three methods to change the states. To change the state of a cubit, we call the emit method. So for each of our methods, we process our notes list and emit a new state with its contents:

// lib/mode/note.dart

class Note extends Equatable {
  final int id;
  final String title;
  final String body;

  Note(this.id, this.title, this.body);

  @override
  List<Object> get props => [id, title, body];
}

// lib/cubit/notes_cubit.dart

class NotesState {
  final UnmodifiableListView notes;
  NotesState(this.notes);
}

class NotesCubit extends Cubit<NotesState> {
  List _notes = [];
  int autoIncrementId = 0;

  NotesCubit() : super(NotesState(UnmodifiableListView([])));

  void createNote(String title, String body) {
    _notes.add(Note(++autoIncrementId, title, body));
    emit(NotesState(UnmodifiableListView(_notes)));
  }

  void deleteNote(int id) {
    _notes = _notes.where((element) => element.id != id).toList();
    emit(NotesState(UnmodifiableListView(_notes)));
  }

  void updateNote(int id, String title, String body) {
    var noteIndex = _notes.indexWhere((element) => element.id == id);
    _notes.replaceRange(noteIndex, noteIndex + 1, [Note(id, title, body)]);
    emit(NotesState(UnmodifiableListView(_notes)));
  }
}

If we run our tests again, all of them should pass. Now we can do some refactoring. You can improve the cubit code and move each class to its own file.

Step 3: Start working on the UI

Once the business logic is working and passing the tests, we move to the UI of the app. In Flutter, we can test widgets, so we continue our TDD process of writing tests first, and then implementing the code.

To test a widget, we provide the full context for its life cycle. Otherwise, our widgets wouldn’t be able to do things like receive events or perform layout. Flutter’s testing framework provides pretty much everything we need to set up a context and set up unit testing.

The page UI is a list of notes, so we implement this using a simple ListView and a ListTile to represent each note. We use the find.byType method to find our widgets in the test. This method will look within our widget tree for any widget that matches the type we defined. We can also take advantage of the find.text method to make sure our UI is displaying the note information correctly. As our notes are handled by our NotesCubit, we inject this dependency in our HomePage constructor:

// test/widget/home_page_test.dart

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('Home Page', () {
    _pumpTestWidget(WidgetTester tester, NotesCubit cubit) => tester.pumpWidget(
      MaterialApp(
        home: MyHomePage(
          title: 'Home',
          notesCubit: cubit,
        ),
      ),
    );

    testWidgets('empty state', (WidgetTester tester) async {
      await _pumpTestWidget(tester, NotesCubit());
      expect(find.byType(ListView), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });

    testWidgets('updates list when a note is added',
        (WidgetTester tester) async {
      var notesCubit = NotesCubit();
      await _pumpTestWidget(tester, notesCubit);
      var expectedTitle = 'note title';
      var expectedBody = 'note body';
      notesCubit.createNote(expectedTitle, expectedBody);
      notesCubit.createNote('another note', 'another note body');
      await tester.pump();

      expect(find.byType(ListView), findsOneWidget);
      expect(find.byType(ListTile), findsNWidgets(2));
      expect(find.text(expectedTitle), findsOneWidget);
      expect(find.text(expectedBody), findsOneWidget);
    });

    testWidgets('updates list when a note is deleted',
        (WidgetTester tester) async {
      var notesCubit = NotesCubit();
      await _pumpTestWidget(tester, notesCubit);
      var expectedTitle = 'note title';
      var expectedBody = 'note body';
      notesCubit.createNote(expectedTitle, expectedBody);
      notesCubit.createNote('another note', 'another note body');
      await tester.pump();

      notesCubit.deleteNote(1);
      await tester.pumpAndSettle();
      expect(find.byType(ListView), findsOneWidget);
      expect(find.byType(ListTile), findsOneWidget);
      expect(find.text(expectedTitle), findsNothing);
    });
  });
}

Our tests are failing, as expected. And that’s a good thing.

Step 4: Create the app home page

Next we start developing the home page. We can either change the one that comes with Flutter’s empty project or create a new file and update our MaterialApp:

// lib/home_page.dart

class MyHomePage extends StatelessWidget {
  final NotesCubit notesCubit;
  final String title;

  MyHomePage({Key key, this.title, this.notesCubit}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: BlocBuilder<NotesCubit, NotesState>(
        cubit: notesCubit,
        builder: (context, state) => ListView.builder(
          itemCount: state.notes.length,
          itemBuilder: (context, index) {
            var note = state.notes[index];
            return ListTile(
              title: Text(note.title),
              subtitle: Text(note.body),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        tooltip: 'Add',
        child: Icon(Icons.add),
      ),
    );
  }
}

The tests are now passing — great! If you didn’t create this page in its own file, it would be a good time to refactor and run the tests again. We can also remove everything from the default empty project that we won’t use.

Step 5: Create a new page

To add new notes, we need a new page. Let’s call it NotePage. It will be accessed by tapping the floating action button that comes with Flutter’s empty project:

// test/widget/home_page_test.dart

// ...
testWidgets('navigate to note page', (WidgetTester tester) async {
  var notesCubit = NotesCubit();
  await _pumpTestWidget(tester, notesCubit);
  await tester.tap(find.byType(FloatingActionButton));
  await tester.pumpAndSettle();
  expect(find.byType(NotePage), findsOneWidget);
});
// ...

For this test to pass, we have to implement navigation to a new NotePage. This page also receives our cubit in the constructor:

// lib/note_page.dart

class NotePage extends StatelessWidget {
  final NotesCubit notesCubit;

  MyHomePage({Key key, this.notesCubit}) : super(key: key);

  @override
  Widget build(BuildContext context) => Container();
}

// lib/home_page.dart
// Here we add a new method to our widget
// ...
_goToNotePage(BuildContext context) => Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => NotePage(
            notesCubit: notesCubit,
          ),
        ),
      );
// ...

// And update our floatingActionButton
floatingActionButton: FloatingActionButton(
        onPressed: () => _goToNotePage(context),
        tooltip: 'Add',
        child: Icon(Icons.add),
      ),

Step 6: Add a test for note creation

The NotePage handles creating, editing and deleting notes. The first thing we are going to support is note creation. The UI is as simple as possible: two TextField widgets for title and body respectively, and a button to confirm the action. After the user confirms, we close the page. We can write two tests for this: Assert the initial state of the page, and actually create a note. Our initial state is going to be two text fields with a hint text. The button should be disabled:

// test/widget/note_page_test.dart

void main() {
  group('Note Page', () {
    _pumpTestWidget(WidgetTester tester, NotesCubit cubit) =>
      tester.pumpWidget(
        MaterialApp(
          home: NotePage(
            notesCubit: cubit
          ),
        ),
      );

    testWidgets('empty state', (WidgetTester tester) async {
      await _pumpTestWidget(tester, NotesCubit());
      expect(find.text('Enter your text here...'), findsOneWidget);
      expect(find.text('Title'), findsOneWidget);
    });

    testWidgets('create note', (WidgetTester tester) async {
      var cubit = NotesCubit();
      await _pumpTestWidget(tester, cubit);
      await tester.enterText(find.byKey(ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(ValueKey('body')), 'there');
      await tester.tap(find.byType(RaisedButton));
      await tester.pumpAndSettle();
      expect(cubit.state.notes, isNotEmpty);
      var note = cubit.state.notes.first;
      expect(note.title, 'hi');
      expect(note.body, 'there');
      expect(find.byType(NotePage), findsNothing);
    });
  });
}

Note the ValueKey in the text fields — with this, we can easily insert values as needed.

Step 7: Write the NotePage UI

With those tests written, we have a better understanding of how the page should work, including the special behavior for the confirmation button. Now write the complete NotePage UI:

// lib/note_page.dart

class NotePage extends StatefulWidget {
  final NotesCubit notesCubit;

  const NotePage({Key key, this.notesCubit}) : super(key: key);

  @override
  _NotePageState createState() => _NotePageState();
}

class _NotePageState extends State {
  final _titleController = TextEditingController();
  final _bodyController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              key: ValueKey('title'),
              controller: _titleController,
              autofocus: true,
              decoration: InputDecoration(hintText: 'Title'),
            ),
            Expanded(
              child: TextField(
                key: ValueKey('body'),
                controller: _bodyController,
                keyboardType: TextInputType.multiline,
                maxLines: 500,
                decoration:
                    InputDecoration(hintText: 'Enter your text here...'),
              ),
            ),
            RaisedButton(
              child: Text('Ok'),
              onPressed: () => _finishEditing(),
            )
          ],
        ),
      ),
    );
  }

  _finishEditing() {
    widget.notesCubit.createNote(_titleController.text, _bodyController.text);
Navigator.pop(context);
  }

  @override
  void dispose() {
    super.dispose();
    _titleController.dispose();
    _bodyController.dispose();
  }
}

At this point, we have a simple note-taking app — not the most beautiful one, but it works. Still, we won’t run the full app just yet. We have two more steps to complete.

Step 8: Create tests for HomePage and NotePage

For the note creation feature, we should add tests for both HomePage and NotePage. These ensure that if the user taps on a list item, we send our note to the next page so it can be edited. For the NotePage, the tests are going to be pretty similar to what we already have. We assert the initial state when we receive a note as a parameter. In that case, both text fields should be pre-filled with the note title and body. The second test is to assert the note editing, similar to note creation:

// lib/test/widget/home_page_test.dart
// ...
// In our home page tests we can assert the list tapping action
testWidgets('navigate to note page in edit mode',
    (WidgetTester tester) async {
  var notesCubit = NotesCubit();
  await _pumpTestWidget(tester, notesCubit);
  var expectedTitle = 'note title';
  var expectedBody = 'note body';
  notesCubit.createNote(expectedTitle, expectedBody);
  await tester.pump();
  await tester.tap(find.byType(ListTile));
  await tester.pumpAndSettle();
  expect(find.byType(NotePage), findsOneWidget);
  expect(find.text(expectedTitle), findsOneWidget);
  expect(find.text(expectedBody), findsOneWidget);
});
// ...

// lib/test/widget/home_page_test.dart

void main() {
  group('Note Page', () {
    _pumpTestWidget(WidgetTester tester, NotesCubit cubit, {Note note}) =>
      tester.pumpWidget(
        MaterialApp(
          home: NotePage(
            notesCubit: cubit,
            note: note,
          ),
        ),
      );

    // ...

    testWidgets('create in edit mode', (WidgetTester tester) async {
      var note = Note(1, 'my note', 'note body');
      await _pumpTestWidget(tester, NotesCubit(), note: note);
      expect(find.text(note.title), findsOneWidget);
      expect(find.text(note.body), findsOneWidget);
    });

    testWidgets('edit note', (WidgetTester tester) async {
      var cubit = NotesCubit()..createNote('my note', 'note body');
      await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
      await tester.enterText(find.byKey(ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(ValueKey('body')), 'there');
      await tester.tap(find.byType(RaisedButton));
      await tester.pumpAndSettle();
      expect(cubit.state.notes, isNotEmpty);
      var note = cubit.state.notes.first;
      expect(note.title, 'hi');
      expect(note.body, 'there');
      expect(find.byType(NotePage), findsNothing);
    });
  });
}

We refactor the NotePage to receive a Note in its constructor so it will show up in edit mode. We set the text of our controllers in the initState method if our note is not null. And when the user confirms by tapping the button, we call the updateNote instead of the createNote. We add the ListTile tap action in our HomePage:

// lib/home_page.dart

class MyHomePage extends StatelessWidget {
  // ...
  // Adding the ListTile tap action:
            return ListTile(
              title: Text(note.title),
              subtitle: Text(note.body),
              onTap: () => _goToNotePage(context, note: note),
            );
  // ...
  // Sending the Note to the next page:
  _goToNotePage(BuildContext context, {Note note}) => Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => NotePage(
            notesCubit: notesCubit,
            note: note,
          ),
        ),
      );
}

// lib/note_page.dart

class NotePage extends StatefulWidget {
  final NotesCubit notesCubit;
  final Note note;

  const NotePage({Key key, this.notesCubit, this.note}) : super(key: key);

  @override
  _NotePageState createState() => _NotePageState();
}

class _NotePageState extends State {
  final _titleController = TextEditingController();
  final _bodyController = TextEditingController();

  @override
  void initState() {
    super.initState();
    if (widget.note == null) return;
    _titleController.text = widget.note.title;
    _bodyController.text = widget.note.body;
  }

  // ...

  _finishEditing() {
    if (widget.note != null) {
      widget.notesCubit.updateNote(
        widget.note.id, _titleController.text, _bodyController.text);
    } else {
      widget.notesCubit.createNote(_titleController.text, _bodyController.text);
    }
    Navigator.pop(context);
  }

  // ...
}

Step 9: Implement the delete note feature

With a small change, we can implement the delete note feature. On our NotePage, we add a delete icon to our AppBar. This icon should be disabled if we are creating a note. For simplicity, we are not going to ask for confirmation — just delete the note and go back to the home screen. Some tests have to be updated, so we assert that the icon is disabled if we are not actually editing a note. Here’s the full note_page_test.dart snippet:

// test/widget/note_page_test.dart

void main() {
  group('Note Page', () {
    _pumpTestWidget(WidgetTester tester, NotesCubit cubit, {Note note}) =>
      tester.pumpWidget(
        MaterialApp(
          home: NotePage(
            notesCubit: cubit,
            note: note,
          ),
        ),
      );

    testWidgets('empty state', (WidgetTester tester) async {
      await _pumpTestWidget(tester, NotesCubit());
      expect(find.text('Enter your text here...'), findsOneWidget);
      expect(find.text('Title'), findsOneWidget);
      var widgetFinder = find.widgetWithIcon(IconButton, Icons.delete);
      var deleteButton = widgetFinder.evaluate().single.widget as IconButton;
      expect(deleteButton.onPressed, isNull);
    });

    testWidgets('create note', (WidgetTester tester) async {
      var cubit = NotesCubit();
      await _pumpTestWidget(tester, cubit);
      await tester.enterText(find.byKey(ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(ValueKey('body')), 'there');
      await tester.tap(find.byType(RaisedButton));
      await tester.pumpAndSettle();
      expect(cubit.state.notes, isNotEmpty);
      var note = cubit.state.notes.first;
      expect(note.title, 'hi');
      expect(note.body, 'there');
      expect(find.byType(NotePage), findsNothing);
    });

    testWidgets('create in edit mode', (WidgetTester tester) async {
      var note = Note(1, 'my note', 'note body');
      await _pumpTestWidget(tester, NotesCubit(), note: note);
      expect(find.text(note.title), findsOneWidget);
      expect(find.text(note.body), findsOneWidget);
      var widgetFinder = find.widgetWithIcon(IconButton, Icons.delete);
      var deleteButton = widgetFinder.evaluate().single.widget as IconButton;
      expect(deleteButton.onPressed, isNotNull);
    });

    testWidgets('edit note', (WidgetTester tester) async {
      var cubit = NotesCubit()..createNote('my note', 'note body');
      await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
      await tester.enterText(find.byKey(ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(ValueKey('body')), 'there');
      await tester.tap(find.byType(RaisedButton));
      await tester.pumpAndSettle();
      expect(cubit.state.notes, isNotEmpty);
      var note = cubit.state.notes.first;
      expect(note.title, 'hi');
      expect(note.body, 'there');
      expect(find.byType(NotePage), findsNothing);
    });

    testWidgets('delete note', (WidgetTester tester) async {
      var cubit = NotesCubit()..createNote('my note', 'note body');
      await _pumpTestWidget(tester, cubit, note: cubit.state.notes.first);
      await tester.tap(find.byType(IconButton));
      await tester.pumpAndSettle();
      expect(cubit.state.notes, isEmpty);
      expect(find.byType(NotePage), findsNothing);
    });
  });
}

And here’s the change in the NotePage:

// lib/note_page.dart

  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        actions: [
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: widget.note != null ? _deleteNote : null,
          )
        ],
      ),
      // ...
  }

  // ...
  _deleteNote() {
    widget.notesCubit.deleteNote(widget.note.id);
    Navigator.pop(context);
  }
  // ...
}

And that is it!

Run the app and try it out. It should be working as expected because all the TDD tests have passed.

Your turn: Try using TDD with Flutter now

This isn’t the prettiest app in the world. But it helps you understand a little bit more about a test-first philosophy and how to use Flutter with test-driven development. Here is the full example in the GitHub repository. In the commits, you will see how the app was built, pretty much following the same step-by-step instructions in this post, plus the refactoring. There are no refactoring snippets because it is hard to properly show the differences.


Need Flutter app development help?

Since the dawn of the App Store, ArcTouch has helped companies build lovable apps and digital products. Our cross-platform app development team are experts in XamarinFlutter, and React. Contact us, and let’s build something lovable together.