The last remaining part of the data layer for which we currently have only a contract is the remote Data Source. This is where all the communication with the Numbers API will happen, for which we're going to use the http package. All of this will be done doing test-driven development, of course.
Setting Up the Test
The number_trivia_remote_data_source.dart file currently contains only the contract of the Data Source. We will put the implementation into the same file, below the abstract class. As usual, let's create a test file mirroring the location of the aforementioned file - test / features / number_trivia / data / datasources.
To set up the test file, we're going to create a mocked Client from the http package and pass it into the currently non-existent remote Data Source implementation.
number_trivia_remote_data_source_test.dart
import 'package:http/http.dart' as http;
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:matcher/matcher.dart';
import '../../../../fixtures/fixture_reader.dart';
class MockHttpClient extends Mock implements http.Client {}
void main() {
NumberTriviaRemoteDataSourceImpl dataSource;
MockHttpClient mockHttpClient;
setUp(() {
mockHttpClient = MockHttpClient();
dataSource = NumberTriviaRemoteDataSourceImpl(client: mockHttpClient);
});
}
To make the test file happy, we'll create a bare-bones implementation of the remote Data Source.
number_trivia_remote_data_source.dart
...
class NumberTriviaRemoteDataSourceImpl implements NumberTriviaRemoteDataSource {
final http.Client client;
NumberTriviaRemoteDataSourceImpl({@required this.client});
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) {
// TODO: implement getConcreteNumberTrivia
return null;
}
@override
Future<NumberTriviaModel> getRandomNumberTrivia() {
// TODO: implement getRandomNumberTrivia
return null;
}
}
Similar to how there was a lot of overlap between the concrete and random methods of the Repository implemented in the 6th part, the same will be true for the remote Data Source. Let's start off with the getConcreteNumberTrivia method, writing tests and implementing it bit by bit.
getConcreteNumberTrivia
Just to recap, we're working with the Numbers API, in this case the concrete endpoint. For the number 42, it looks like this: http://numbersapi.com/42. The issue is, performing a GET request on that kind of an URL gets us a plain text response containing only the trivia string. But we want to get a JSON reponse!
Getting the API to respond with an application/json header is possible in two ways:
- Append a query parameter to the URL, making it look like this: http://numbersapi.com/42?json
- Send a Content-Type: application/json header along with the GET request.
We're going to choose the second option. Since it's very important to get the URL and the header just right, we'll create a test just specifically for them.
test.dart
group('getConcreteNumberTrivia', () {
final tNumber = 1;
test(
'should preform a GET request on a URL with number being the endpoint and with application/json header',
() {
//arrange
when(mockHttpClient.get(any, headers: anyNamed('headers'))).thenAnswer(
(_) async => http.Response(fixture('trivia.json'), 200),
);
// act
dataSource.getConcreteNumberTrivia(tNumber);
// assert
verify(mockHttpClient.get(
'http://numbersapi.com/$tNumber',
headers: {'Content-Type': 'application/json'},
));
},
);
}
With the test failing, we'll write only as much production code as is sufficient for the test to pass.
implementation.dart
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) {
client.get(
'http://numbersapi.com/$number',
headers: {'Content-Type': 'application/json'},
);
}
We also need to test if the returned model will contain the proper data. When everything goes smoothly and the response code is 200 SUCCESS, the returned NumberTriviaModel should contain data converted from the JSON response.
test.dart
final tNumberTriviaModel =
NumberTriviaModel.fromJson(json.decode(fixture('trivia.json')));
...
test(
'should return NumberTrivia when the response code is 200 (success)',
() async {
// arrange
when(mockHttpClient.get(any, headers: anyNamed('headers'))).thenAnswer(
(_) async => http.Response(fixture('trivia.json'), 200),
);
// act
final result = await dataSource.getConcreteNumberTrivia(tNumber);
// assert
expect(result, equals(tNumberTriviaModel));
},
);
We test if the data in the result of the method call are what we expect by comparing it to a tNumberTriviaModel, which is an instance constructed from the same JSON fixture as is returned by the mockHttpClient.
implementation.dart
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) async {
final response = await client.get(
'http://numbersapi.com/$number',
headers: {'Content-Type': 'application/json'},
);
return NumberTriviaModel.fromJson(json.decode(response.body));
}
Finally, we mustn't forget to account for a case when something goes wrong. If the response code is 404 NOT FOUND or any other error code (basically anything other that 200), a ServerException should be thrown.
Similar to how we tested if the method throws an exception in the previous part, now we do the same. To keep the code clean we store the method inside a call variable and invoke it from within a higher-order function passed into the expect method.
test.dart
test(
'should throw a ServerException when the response code is 404 or other',
() async {
// arrange
when(mockHttpClient.get(any, headers: anyNamed('headers'))).thenAnswer(
(_) async => http.Response('Something went wrong', 404),
);
// act
final call = dataSource.getConcreteNumberTrivia;
// assert
expect(() => call(tNumber), throwsA(TypeMatcher<ServerException>()));
},
);
implementation.dart
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) async {
final response = await client.get(
'http://numbersapi.com/$number',
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return NumberTriviaModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
This exception gets handled in the Repository, of course. Now we have the getConcreteNumberTrivia method fully implemented.
DRY Even Inside Tests
The DRY (Don't Repeat Yourself) principle is the core of any kind of programming, whether you're doing TDD or not. Code duplication in tests is just as bad as duplication in production code. As you can see, the arrange part of the two first tests is completely identical - returning 200 SUCCESS. We cannot just move the setting up of the mock to the setUp method for the whole test group, because the last test's arrange part returns 404 NOT FOUND.
If we cannot utilize test-specific ways of fighting duplication, we'll have to take the old school way - creating methods. We'll create one even for the 404 error for the use in subsequent tests.
test.dart
void main() {
...
void setUpMockHttpClientSuccess200() {
when(mockHttpClient.get(any, headers: anyNamed('headers'))).thenAnswer(
(_) async => http.Response(fixture('trivia.json'), 200),
);
}
void setUpMockHttpClientFailure404() {
when(mockHttpClient.get(any, headers: anyNamed('headers'))).thenAnswer(
(_) async => http.Response('Something went wrong', 404),
);
}
// Change the code below to use these methods...
}
getRandomNumberTrivia
Similar to when we were implementing the Repository, the difference between the concrete and random methods will be minimal. In fact, all that's needed to get a random number is to change the url from http://numbersapi.com/some_number to http://numbersapi.com/random.
Because of this immense similarity, I hope you will forgive me once again for not fully following the canon of TDD. We're about to copy, paste and modify some code! The modifications are very straightforward - just rename any reference of "concrete" to "random" and you're set.
test.dart
group('getRandomNumberTrivia', () {
final tNumberTriviaModel =
NumberTriviaModel.fromJson(json.decode(fixture('trivia.json')));
test(
'should preform a GET request on a URL with *random* endpoint with application/json header',
() {
//arrange
setUpMockHttpClientSuccess200();
// act
dataSource.getRandomNumberTrivia();
// assert
verify(mockHttpClient.get(
'http://numbersapi.com/random',
headers: {'Content-Type': 'application/json'},
));
},
);
test(
'should return NumberTrivia when the response code is 200 (success)',
() async {
// arrange
setUpMockHttpClientSuccess200();
// act
final result = await dataSource.getRandomNumberTrivia();
// assert
expect(result, equals(tNumberTriviaModel));
},
);
test(
'should throw a ServerException when the response code is 404 or other',
() async {
// arrange
setUpMockHttpClientFailure404();
// act
final call = dataSource.getRandomNumberTrivia;
// assert
expect(() => call(), throwsA(TypeMatcher<ServerException>()));
},
);
});
As for the production code, we're literally going to copy & paste it from the getConcreteNumberTriviaMethod and just change the "$number" interpolated in the URL to "random".
implementation.dart
@override
Future<NumberTriviaModel> getRandomNumberTrivia() async {
final response = await client.get(
'http://numbersapi.com/random',
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return NumberTriviaModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
All of the tests are passing! Of course, this kind of a duplication is completely inexcusable. Let's create a helper method then! It will receive the URL to perform the GET request on as an argument.
implementation.dart
@override
Future<NumberTriviaModel> getConcreteNumberTrivia(int number) =>
_getTriviaFromUrl('http://numbersapi.com/$number');
@override
Future<NumberTriviaModel> getRandomNumberTrivia() =>
_getTriviaFromUrl('http://numbersapi.com/random');
Future<NumberTriviaModel> _getTriviaFromUrl(String url) async {
final response = await client.get(
url,
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
return NumberTriviaModel.fromJson(json.decode(response.body));
} else {
throw ServerException();
}
}
Although it's highly improbable, this refactoring could still have messed something up. Only after retesting the code and seeing it pass, can we sleep well at night.
What's next?
We're moving along pretty quickly. We've just finished the whole data layer and with the domain already implemented, only the presentation layer remains. We will again keep the tradition and start from the center of the layer, which is the presentation logic holder. In the case of the Number Trivia App, we're going to use a Bloc. Subscribe below to grow your Flutter coding skills by getting important Flutter news sent right into your inbox on a weekly basis.
Waiting for Presentation layer tutoriel
Pretty straightforward! Liked this step! I guess I catch a bit more about TDD applied to Flutter!
`expect(() => call(tToken, tPublicKey), throwsA(TypeMatcher()));
`
Im getting error – `
Expected: throws <Instance of 'TypeMatcher’>
Actual: Future>
Which: threw
`
help!!
Thanks in advance
You have to import ‘package:matcher/matcher.dart’; for TypeMatcher.
Actually cupertino or material are automaticly imported by VSC or Android Studio.
Thanks
use expetc(() => call(),throwsA(isA()));
[…] aclarar que el contenido original es de Resocoder, lo que he hecho es una traducción al español del contenido. Al final de este artículo está […]
how to test when response from API is invalid?
That is awesome, thanks for great instructions
this worked for me
void main() {
late NumberTriviaRemoteDataSourceImpl dataSource;
late MockHttpClient mockHttpClient;
setUp(() {
mockHttpClient = MockHttpClient();
dataSource = NumberTriviaRemoteDataSourceImpl(client: mockHttpClient);
});
void setUpConcreteMockHttpClientSuccess200() {
when(() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/1’),
headers: {‘Content-Type’: ‘application/json’},
)).thenAnswer(
(_) async => http.Response(fixture(‘trivia.json’), 200),
);
}
void setUpConcreteMockHttpClientFailure404() {
when(() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/1’),
headers: {‘Content-Type’: ‘application/json’},
)).thenAnswer(
(_) async => http.Response(‘Something went wrong’, 404),
);
}
void setUpRandomMockHttpClientSuccess200() {
when(() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/random’),
headers: {‘Content-Type’: ‘application/json’},
)).thenAnswer(
(_) async => http.Response(fixture(‘trivia.json’), 200),
);
}
void setUpRandomMockHttpClientFailure404() {
when(() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/random’),
headers: {‘Content-Type’: ‘application/json’},
)).thenAnswer(
(_) async => http.Response(‘Something went wrong’, 404),
);
}
group(‘getConcreteNumberTrivia’, () {
const tNumber = 1;
final tNumberTriviaModel =
NumberTriviaModel.fromJson(json.decode(fixture(‘trivia.json’)));
test(
‘should preform a GET request on a URL with number being the endpoint and with application/json header’,
() async {
//arrange
setUpConcreteMockHttpClientSuccess200();
// act
dataSource.getConcreteNumberTrivia(tNumber);
// assert
verify(
() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/$tNumber’),
headers: {‘Content-Type’: ‘application/json’},
),
);
},
);
test(
‘should return NumberTrivia when the response code is 200 (success)’,
() async {
// arrange
setUpConcreteMockHttpClientSuccess200();
// act
final result = await dataSource.getConcreteNumberTrivia(tNumber);
// assert
expect(
result,
equals(
tNumberTriviaModel,
),
);
},
);
test(
‘should throw a ServerException when the response code is 404 or other’,
() async {
// arrange
setUpConcreteMockHttpClientFailure404();
// act
final call = dataSource.getConcreteNumberTrivia;
// assert
expect(
() => call(tNumber),
throwsA(
const TypeMatcher(),
),
);
},
);
});
group(‘getRandomNumberTrivia’, () {
final tNumberTriviaModel =
NumberTriviaModel.fromJson(json.decode(fixture(‘trivia.json’)));
test(
‘should preform a GET request on a URL with *random* endpoint with application/json header’,
() async {
//arrange
setUpRandomMockHttpClientSuccess200();
// act
dataSource.getRandomNumberTrivia();
// assert
verify(
() => mockHttpClient.get(
Uri.parse(‘http://numbersapi.com/random’),
headers: {‘Content-Type’: ‘application/json’},
),
);
},
);
test(
‘should return NumberTrivia when the response code is 200 (success)’,
() async {
// arrange
setUpRandomMockHttpClientSuccess200();
// act
final result = await dataSource.getRandomNumberTrivia();
// assert
expect(
result,
equals(
tNumberTriviaModel,
),
);
},
);
test(
‘should throw a ServerException when the response code is 404 or other’,
() async {
// arrange
setUpRandomMockHttpClientFailure404();
// act
final call = dataSource.getRandomNumberTrivia();
// assert
expect(
() => call,
throwsA(
const TypeMatcher(),
),
);
},
);
});
}
Thank you for your sharing. I am worried that I lack creative ideas. It is your article that makes me full of hope. Thank you. But, I have a question, can you help me?
Your article helped me a lot, is there any more related content? Thanks!