Flutter TDD Clean Architecture Course [9] – Remote Data Source

12  comments

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.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

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:

  1. Append a query parameter to the URL, making it look like this: http://numbersapi.com/42?json
  2. 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'},
  );
}
It may seem that the arrange part of this test is unnecessary. We aren't doing anything with the returned Response, after all.

While this reasoning is true for now, as we add functionality to the method implementation, not arranging the mockHttpClient to return a valid Response object would cause all kinds of unexpected errors. That's because a method on a mock which isn't previously set up returns null.

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.

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter Developer at LeanCode and a developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

Flutter UI Testing with Patrol

Flutter UI Testing with Patrol
  • `expect(() => call(tToken, tPublicKey), throwsA(TypeMatcher()));
    `
    Im getting error – `
    Expected: throws <Instance of 'TypeMatcher’>
    Actual: Future>
    Which: threw
    `
    help!!
    Thanks in advance

  • 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(),
    ),
    );
    },
    );
    });
    }

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >