Writing Widget Test In Flutter

Writing Widget Test In Flutter

In flutter everything is a widget, and an app is simply a widget made up of other widgets that come together to form a widget tree. Well, these individual widgets can be tested to ensure they behave as expected.

My previous article covered how to write unit test in flutter using the mockito package. In this article we are going to pick up right where we left off and add widget test to our flutter app.

The Setup

To get started, simply clone the example project from github here and run flutter pub get.

git clone https://github.com/o-ifeanyi/test-sample

flutter pub get

A few changes would be made to our main.dart. Instead of creating and instantiating the Api class within the MyHomePage widget it will be passed in through the constructor. That way it can be mocked and its behaviour controlled.

So instead of this

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String _joke = '';
  bool _isLoading = false;
  Api _api = Api(client: http.Client());

  // ...
}

We'll do this instead

class MyHomePage extends StatefulWidget {
  final Api api;

  MyHomePage({@required this.api});
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String _joke = '';
  bool _isLoading = false;
  Api _api;

  @override
  void initState() {
    super.initState();
    _api = widget.api;
  }

  //...
}

A better way to handle all the passing around is with the help of Service locators. A good example would be the Get_it package which allows you to register classes and access them from anywhere in your app. But to keep this example really basic we'll pass things around ourselves. And in a later article we can clean up using Get_it.

For now this is what the main.dart file looks like

import 'package:example/api.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(api: Api(client: http.Client())),
    );
  }
}

class MyHomePage extends StatefulWidget {
  final Api api;

  MyHomePage({@required this.api});
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String _joke = '';
  bool _isLoading = false;
  Api _api;

  @override
  void initState() {
    super.initState();
    _api = widget.api;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Flutter test example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_joke, textAlign: TextAlign.center),
            _isLoading
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: _getJoke,
                    child: Text('Get Joke'),
                  )
          ],
        ),
      ),
    );
  }

  void _getJoke() async {
    setState(() {
      _isLoading = true;
    });
    _joke = await _api.getRandomJoke();
    setState(() {
      _isLoading = false;
    });
  }
}

Writing The Test

Now to write the test, navigate to the test folder and create a new file called "home_page_test.dart". This is going to hold the test for the MyHomePage widget.

First we'll create a mock of our Api class and instantiate it in the setUp method within main.

class MockApi extends Mock implements Api {}

void main() {
  MockApi mockApi;

  setUp(() {
    mockApi = MockApi();
  });
}

Then we'll create a group to hold the different scenarios we wish to test (mainly the success and error case).

group('MyHomeScreen test', () {
  //...
});

Unlike unit test where we made use of the 'test' function, widget test makes use of 'testWidgets' which automatically creates a new WidgetTester for each test case. The WidgetTester enables us build and interact with widgets in a test environment.

testWidgets('Description of the test', (WidgetTester tester) async {
      //...
});

Within the test enviroment we can search our widget tree using the find method. This is useful for finding widgets we wish to interact with (e.g. the 'Get Joke' button) or finding widgets to confirm / verify thats it's being displayed properly. For example, to find the Get Joke button we simply do this.

final getJokeButton = find.byKey(ValueKey('getJokeButton'));

And for this to work we assign a value key with same value to the button in our widget tree. So find the button in the main.dart and add the key.

ElevatedButton(
    key: ValueKey('getJokeButton'),
    onPressed: _getJoke,
    child: Text('Get Joke'),
)

The success case should go like this. The Get joke button is tapped, the API class getRandomJoke method is called and it returns a random joke which gets displayed on the screen. And this is how we test that

testWidgets('Should display a random joke when getJoke button is pressed',
        (tester) async {
      final getJokeButton = find.byKey(ValueKey('getJokeButton'));

      when(mockApi.getRandomJoke()).thenAnswer(
        (_) => Future.value('Programming is too easy'),
      );

      await tester.pumpWidget(MaterialApp(home: MyHomePage(api: mockApi)));
      await tester.tap(getJokeButton);
      await tester.pump();

      expect(find.text('Programming is too easy'), findsOneWidget);
    });

What's going on here is we

  • Located the Get Joke button.

  • Defined what our Api class should return when the button is pressed. Which is possible because it is a mock.

  • Rendered the UI from the MyHomePage widget using tester.pumpWidget (this calls runApp with the widget we wish to test as it's argument).

  • Ensured the UI would be rendered properly by wrapping the widget we wish to test in a MaterialApp which provides relevant information like Position and Directionality of the widget. Without this there would be an error and the test would fail.

  • Tapped on the Get Joke button we located earlier.

  • Triggered a rebuild of the widget using tester.pump (this is basically setState in a test enviroment).

  • Expect to find one text on the screen (findsOneWidget) that says 'Programing is too easy'.

Seeing as, we also display the error message on the screen, in the case of an error. The test for our error case would only differ from the above by the message being displayed.

The complete home_page_test.dart file

import 'package:example/api.dart';
import 'package:example/main.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockApi extends Mock implements Api {}

void main() {
  MockApi mockApi;

  setUp(() {
    mockApi = MockApi();
  });

  group('MyHomePage test', () {
    testWidgets('Should display a random joke when getJoke button is pressed',
        (tester) async {
      final getJokeButton = find.byKey(ValueKey('getJokeButton'));

      when(mockApi.getRandomJoke()).thenAnswer(
        (_) => Future.value('Programming is too easy'),
      );

      await tester.pumpWidget(MaterialApp(home: MyHomePage(api: mockApi)));
      await tester.tap(getJokeButton);
      await tester.pump();

      expect(find.text('Programming is too easy'), findsOneWidget);
    });

    testWidgets('Should display error message returned from api',
        (tester) async {
      final getJokeButton = find.byKey(ValueKey('getJokeButton'));

      when(mockApi.getRandomJoke()).thenAnswer(
        (_) => Future.value('An error occured'),
      );

      await tester.pumpWidget(MaterialApp(home: MyHomePage(api: mockApi)));
      await tester.tap(getJokeButton);
      await tester.pump();

      expect(find.text('An error occured'), findsOneWidget);
    });
  });
}

Running the test

To run all the test, you can either click the "Run" above the main method in your home_page_test.dart file or from the command line, simply make sure you're within the app's directory (e.g. C:\Users\ifeanyi\FlutterProjects\example>) and run flutter test.

flutter test
00:01 +5: All tests passed!

That'll be all for now. In the next article we'll add integration test for our very awesome app before we clean up. Stay tuned.

Thanks for reading

For more information on how to use mockito, see the documentation provided by the Mockito package.

The flutter cookbook on widget test is also a good reference

Check out the full code here