Test-Driven Development with Flutter [Part II]

Arnela Jasarevic
upday devs
Published in
6 min readJun 13, 2022

This article will show how to create the app using the Red-Green-Refactor method with a focus on writing unit and integration tests. In the previous article (part I), you can find an explanation for widget tests and an introduction to the Red-Green-Refactor rhythm.

Just as with widget tests, we need to follow 3 steps/colors:

  1. Write a failing test (Red phase)
  2. Make the test pass — Write just enough code to pass the test (Green phase)
  3. Improve the code — Clean up the mess (Refactoring phase).

Now, let’s remind ourselves of what we want: a simple screen with one button that will fetch data and show us the current weather in the dialog.

Since the UI part of this screen was implemented in the previous article, we will start with an integration test that presents the contract between requirements and features.

Integration test

The integration test covers the behavior of our app. So, we want to be sure that the message and button are visible. When the user taps on the button, the weather will be presented in the dialog. On close, the user will again see the previous state.

So, let’s go deep into the red zone!

void _testMain() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
late Dio dio;
late MyApp app;

Future<void> initializeApp() async {
dio = Dio()..httpClientAdapter = StubHttpClientAdapter(StubResponderImpl());

final moduleHandler =
ModuleHandler.initialize(location: location, dio: dio);

app = MyApp(moduleHandler: moduleHandler);
}

setUp(() async {
await initializeApp();
});

testWidgets('integration test', (WidgetTester tester) async {
runApp(app);
await tester.pumpAndSettle();

expect(find.text('Hello World!'), findsOneWidget);
expect(find.text('Weather today'), findsOneWidget);
expect(find.byKey(Key('icon_weather')), findsOneWidget);

await tester.tap(find.text('Weather today'));
await tester.pumpAndSettle(Duration(seconds: 3));

expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Clouds'), findsOneWidget);
expect(find.text('few clouds'), findsOneWidget);
expect(find.text('temp: 13.36° C'), findsOneWidget);
});
}

As you can see, we needed to implement the stub responder. It will intercept the API call and give us data that we mocked. Mocked data will provide us with a predictable response and we will avoid network calls.

class StubResponderImpl implements StubResponder {
const StubResponderImpl();

@override
Future<LocalResponse> respond(final Request request) async {
if (request.uri.host == 'api.openweathermap.org' &&
request.uri.path == '/data/2.5/weather') {
return LocalResponse.success(
'''
{
"weather": [
{
"main": "Clouds",
"description": "few clouds"
}
],
"main": {
"temp": 13.36
}
}
''',
headers: {'content-type': 'application/hal+json;charset=UTF-8'},
);
}

return LocalResponse(
'{"error": "Not found ${request.uri}"}',
statusCode: 404,
headers: {'content-type': 'application/hal+json;charset=UTF-8'},
);
}
}

This test won’t enter the green zone until we implement all the necessary features, but we have set the goal for ourselves. We can’t go wrong anymore as the desired behavior has been defined. Just to be sure, let’s run the test and see how it will fail.

flutter drive --driver=integration_test/driver.dart --target=integration_test/app_test.dart

There is nothing more to do for now regarding the integration test, so let’s start with the unit tests.

Unit tests

The API call will be verified first. We want to be sure that if we send the correct data, the URI will be correctly compounded. Let’s create an ‘api_test’ file and write our first unit test.

test(
'sends get request to valid endpoint',
() async {
builder.withSuccessfulGetResponse(
{
'weather': [
{
'main': "Clouds",
'description': "few clouds",
'icon': "02d",
}
],
'main': {
'temp': 13.36,
},
},
);

await api.getWeather(
lat: '1',
lon: '2',
);

verify(
() => dio.getUri<dynamic>(
Uri.parse(
'https://api.openweathermap.org/data/2.5/weather?lat=1'
'&lon=2&appid=YOURAPPID&units=metric',
),
),
);
},
);

A run of the flutter test is not yet needed, since we have a compiler error. There is no method getWeather and we will implement it now inside the new api.dartfile.

Future<Forecast> getWeather({
required String lat,
required String lon,
}) async {
final response = await dio.getUri<dynamic>(
Uri.parse('$_hostName?lat=$lat&lon=$lon&appid=$_apiKey&units=metric'),
);

return Forecast.fromJson(response.data);
}

Now is the perfect time for flutter test and we can enjoy our time in the green zone.

The next task is to get the user's location. Actually, no! The next task is to write a test for fetching a user’s location.

test(
'get location',
() async {
final currentLocation = LocationData.fromMap({
'latitude': 1.2,
'longitude': 3.4,
});
builder
..withServiceEnabled()
..withPermission()
..withLocation(currentLocation);

final _location = await register.getLocation();

expect(_location, currentLocation);
},
);

Since there is no getLocation method implemented yet and we want to move from the red to the green zone, let’s implement it.

Future<LocationData> getLocation() async {
bool _serviceEnabled;
PermissionStatus _permissionGranted;
LocationData _locationData;

_serviceEnabled = await location.serviceEnabled();
if (!_serviceEnabled) {
_serviceEnabled = await location.requestService();
if (!_serviceEnabled) {
throw Exception('Service is not enabled');
}
}

_permissionGranted = await location.hasPermission();
if (_permissionGranted == PermissionStatus.denied) {
_permissionGranted = await location.requestPermission();
if (_permissionGranted != PermissionStatus.granted) {
throw Exception('Permission is not granted');
}
}

_locationData = await location.getLocation();

return _locationData;
}

As you can see in the code above, we first need permission from the user to fetch the location and that’s the reason why we need to override location permission inside the integration test. Let’s open our driver file and grant permission for testing purposes.

Future<void> main() async {
integrationDriver();
await Process.run(
'adb',
[
'shell',
'pm',
'grant',
'com.example.flutter_tdd',
'android.permission.ACCESS_FINE_LOCATION'
],
);
}

If there is anything left that you need to refactor, now is the perfect time.

flutter test and we finished our second Red-Green-Refactor cycle! That powerful force of rhythm!

I will go through one more cycle here and that cycle will be for fetching weather based on location data.

So, as always, tests first!

test(
'get weather by location',
() async {
final currentLocation = LocationData.fromMap({
'latitude': 1.2,
'longitude': 3.4,
});
builder
..withServiceEnabled()
..withPermission()
..withLocation(currentLocation)
..withForecast(
{
'weather': [
{
'main': "Clouds",
'description': "A lot of clouds",
}
],
'main': {
'temp': 12.1,
},
},
);

final weather = await register.getWeather(currentLocation);

expect(
weather,
Forecast(
main: Temperature(temp: 12.1),
weather: [
Weather(
main: 'Clouds',
description: 'A lot of clouds',
)
],
),
);
},
);

getWeather is red because we didn’t implement that method inside our weather register. Green zone, here we are!

Future<Forecast> getWeather(LocationData locationData) {
return Api(dio).getWeather(
lat: locationData.latitude.toString(),
lon: locationData.longitude.toString(),
);
}

flutter test and our test has passed!

NOTE: For the sake of simplicity and focus on TDD rhythm, some parts of the implementation code have been omitted. Inside the project is a test for showing dialog when data is fetched and many more things that we need to make this app work(such as models, implementation code for weather dialog, etc). The link can be found here.

Back to the integration test

Once we have implemented the logic, connected our button with fetching location and weather data, then created a dialog for showing that data to the user, let’s go back to our integration test. Run the following command and check the magic on your phone.

flutter drive --driver=integration_test/driver.dart --target=integration_test/app_test.dart

Nice, we did it. Our weather app is fully developed following the TDD Red-Green-Refactor rhythm.

Conclusion

Uncle Bob said: ‘Unit tests are documents for programmers’ and I totally agree. There have been numerous occasions when it was easier for me to understand a feature by reading tests instead of following the implementation code throughout the project. I have only included tests for the happy path in this article, but in general, tests should cover every possible scenario that we can think of and every line of code that we have written. This should be an opportunity to challenge ourselves and our code by following a simple TDD rhythm. In the end, writing more tests guarantees us the freedom to change our code later. And who doesn’t like freedom?

Until the next article! Thanks!

The project can be found on Github here.

--

--