Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@brianegan
Created August 16, 2019 13:37
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save brianegan/414f6b369c534a0e5f20bff377823414 to your computer and use it in GitHub Desktop.
Save brianegan/414f6b369c534a0e5f20bff377823414 to your computer and use it in GitHub Desktop.
Demonstrates how to Mock Futures in Widiget tests to check the various expected ouputs depending on the loading / success / error state of the Future
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility that Flutter provides. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import 'package:future_testing/main.dart';
class MockApiClient extends Mock implements ApiClient {}
void main() {
group('PostScreen', () {
testWidgets('starts with a loading spinner', (tester) async {
final client = MockApiClient();
final widget = TestWidget(client: client);
// Mock out the Future<Post> with an async function that returns a
// Dummy Post for testing
when(client.fetchPost()).thenAnswer((_) async => Post(title: 'A'));
// Mock out a Future<void> by answering with an async function that
// does not return anything
when(client.savePost()).thenAnswer((_) async {});
await tester.pumpWidget(widget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('loads and shows a post', (tester) async {
final client = MockApiClient();
final widget = TestWidget(client: client);
when(client.fetchPost()).thenAnswer((_) async => Post(title: 'P'));
when(client.savePost()).thenAnswer((_) async {});
// First pump builds the Widget with the future uncompleted
await tester.pumpWidget(widget);
// Second pump builds the Widget after the future returns
await tester.pumpWidget(widget);
expect(find.text('P'), findsOneWidget);
});
testWidgets('loads and shows an error', (tester) async {
final client = MockApiClient();
final widget = TestWidget(client: client);
// Use an async function that throws to simulate a future error
when(client.fetchPost()).thenAnswer((_) async => throw 'E');
when(client.savePost()).thenAnswer((_) async {});
// First pump builds the Widget with the future uncompleted
await tester.pumpWidget(widget);
// Second pump builds the Widget after the future throws an error
await tester.pumpWidget(widget);
expect(find.text('E'), findsOneWidget);
});
testWidgets('calls savePost when the button is tappepd', (tester) async {
final client = MockApiClient();
final widget = TestWidget(client: client);
// Use an async function that throws to simulate a future error
when(client.fetchPost()).thenAnswer((_) async => Post(title: 'A'));
when(client.savePost()).thenAnswer((_) async {});
// First pump builds the Widget with the future uncompleted
await tester.pumpWidget(widget);
await tester.tap(find.byKey(Key('save_button')));
// Use mockito to verify savePost has been called twice: Once when the
// Widget is first shown and a second time when the save button is pressed
verify(client.savePost()).called(2);
});
});
}
// A Widget to setup up the Provider and Directionality widgets for the tests in
// this file
class TestWidget extends StatelessWidget {
final ApiClient client;
const TestWidget({Key key, this.client}) : super(key: key);
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Provider<ApiClient>(
builder: (BuildContext context) => client,
child: PostScreen(),
),
);
}
}
class PostScreen extends StatefulWidget {
@override
_PostScreenState createState() => _PostScreenState();
}
class _PostScreenState extends State<PostScreen> {
Future<Post> _postFuture;
@override
void initState() {
// Get the ApiClient from the Provider.
//
// In your real app, you'd Provide the normal ApiClient. In tests,
// provide a MockApiClient.
//
// If you aren't using Provider, you can also supply the ApiClient directly
// to the Widget for testing and use `widget.client.fetchPost`.
final client = Provider.of<ApiClient>(context, listen: false);
// Fetch the post
_postFuture = client.fetchPost();
// Do some work that doesn't return anything useful (Future<void>).
client.savePost();
super.initState();
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
FutureBuilder<Post>(
future: _postFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.data.title);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
FlatButton(
key: Key('save_button'),
onPressed: () {
Provider.of<ApiClient>(context, listen: false).savePost();
},
child: Text('Save Post'),
)
],
);
}
}
class ApiClient {
Future<void> savePost() async {}
Future<Post> fetchPost() async {
final response =
await http.get('https://jsonplaceholder.typicode.com/posts/1');
if (response.statusCode == 200) {
// If the call to the server was successful, parse the JSON.
return Post.fromJson(json.decode(response.body));
} else {
// If that call was not successful, throw an error.
throw Exception('Failed to load post');
}
}
}
class Post {
final int userId;
final int id;
final String title;
final String body;
Post({this.userId, this.id, this.title, this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
userId: json['userId'],
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
@terenceponce
Copy link

Hi @brianegan, I have a question regarding how the future is called - Specifically, inside _PostScreenState. Why wasn't async/await used when calling _postFuture = client.fetchPost(); and client.savePost();?

@brianegan
Copy link
Author

brianegan commented Aug 24, 2019

Hey @terenceponce -- in this case, you don't want to await the result of the Future in the initState function since FutureBuilder will do that for you and then call the builder function once again when the Future completes / errors. You also don't want to call client.fetchPost inside your build method, since that would mean any time the Widget is rebuilt it would call your backend service again (which can happen quite often -- a parent was rebuilt, on screen rotation, navigating to a new route, etc). You only want to call your backend service once when the Widget is first inserted into the tree. So we do that in initState and assign the Future to a variable, then pass that variable to the FutureBuilder so it can do its thing.

Does that help / make sense?

@terenceponce
Copy link

Yes, I understand now. Thanks!

@yvonmanzi
Copy link

Hi @brianegan, Thanks for putting this together. Using an async function that throws to simulate a future error, when(client.fetchPost()).thenAnswer((_) async => throw 'E'); is not working for me. I instead used when(client.fetchPost()).thenThrow('E'); How are these two approaches different, you think?

@yvonmanzi
Copy link

Oh never mind, I got what was going on.

@davenotdavid
Copy link

davenotdavid commented Dec 20, 2020

Thanks @brianegan for this helpful gist!

For those also fairly new to using providers and deciding to take that route to access the injected API client instance for both running the app as well as testing, using provider v2.0.xx for this sample did it for me FYI

@OsamaAldawoody
Copy link

image
Is this true?

@fredgrott
Copy link

Hey @brianegan I came upwith an idea to use eBay's Golden Toolkit to write all the widget tests in form of Golden Test Driven Development, this ideaof a TestWidget to supply state and dapinjection I am borrowing as it's a good idea and implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment