Flutter Retrofit, implementation in Clean Architecture with unit tests

Introduction

We will see how to implement Retrofit in a clean architecture way. The implementation will be set in the data source part of the feature. To understand what is a feature or the data source see this schema below:

So our code will be in the remote data source part. Let's start!

The Remote data source

We assume that all code is just an example, so we will imagine a Home feature.

The remote data source contract will be:

abstract class HomeRemoteDataSource {
  Future<List<Article>> getArticles();
}
home_remote_data_source.dart

Then the implementation:

abstract class HomeRemoteDataSource {
  Future<List<Article>> getArticles();
}

class HomeRemoteDataSourceImpl implements HomeRemoteDataSource {
  HomeRemoteDataSourceImpl({
    required this.homeClient,
  });

  HomeClient homeClient;

  @override
  Future<List<Article>> getArticles() async{
	// TODO: implement getArticles
    throw UnimplementedError();
  }
}
home_remote_data_source.dart

We have not implemented the getArticles right now, but we will do in the next chapters.

The HomeClient, Retrofit part

Now we will implement the Retrofit, I assume here that you already know how to use retrofit but this is the HomeClient:

part 'home_client.g.dart';

@RestApi()
abstract class HomeClient {
  factory HomeClient(Dio dio) = _HomeClient;

  @GET('/articles')
  Future<List<Article>> getArticles();
}
home_client.dart

If you want to know more about Retrofit follow this link:

retrofit | Dart Package
retrofit.dart is an dio client generator using source_gen and inspired by Chopper and Retrofit.

Unit tests

Now we have all set up to begin the unit test and fill the getArticles() implementation. We will use the TDD approach to make the unit test.

Our first test will be to verify if we call the HomeClient, we have to set up some code before the test:

class MockHomeClient extends Mock implements HomeClient {}

void main() {
  late HomeRemoteDataSourceImpl dataSource;

  final Article tArticle = Article.fromJson('article.json'.toFixture());
  final List<Article> tArticles = [
    tArticle,
  ];

  late MockHomeClient homeClient;

  setUp(() async {
    registerFallbackValue(Uri());
    homeClient = MockHomeClient();
    dataSource = HomeRemoteDataSourceImpl(homeClient: homeClient);
  });

  group('get articles', () {
    test(
      'should perform a GET request on /articles',
      () async {
        // arrange
        when(
          () => homeClient.getArticles(),
        ).thenAnswer(
          (_) async => tArticles,
        );

        // act
        dataSource.getArticles();
        // assert
        verify(() => homeClient.getArticles());
        verifyNoMoreInteractions(homeClient);
      },
    );
  });
}
home_remote_data_source_test.dart

Like you can see here we just test if the homeClient.getArticles() is called. So now we can run the test and see:

➜  retrofit dart test
00:01 +0 -1: test/features/home/data/datasources/home_remote_data_source_test.dart: get articles should perform a GET request on /articles [E]                         
  UnimplementedError
  bin/features/home/data/datasources/home_remote_data_source.dart 21:5         HomeRemoteDataSourceImpl.getArticles
  test/features/home/data/datasources/home_remote_data_source_test.dart 50:20  main.<fn>.<fn>
  test/features/home/data/datasources/home_remote_data_source_test.dart 41:7   main.<fn>.<fn>
  
00:01 +0 -1: loading test/features/home/data/datasources/home_remote_data_source_test.dart                                                                             
Consider enabling the flag chain-stack-traces to receive more detailed exceptions.
For example, 'dart test --chain-stack-traces'.
00:01 +0 -1: Some tests failed.                                                                                                                                        
➜  retrofit 

Like excepted we have an error on the test (TDD), UnimplementedError. So now we have to pass the test. Back to the HomeRemoteDataSourceImpl:

  @override
  Future<List<Article>> getArticles() {
    homeClient.getArticles();
    return Future.value([Article(title: 'test')]);
  }
home_remote_data_source.dart

Now pass the test again:

➜  retrofit dart test
00:01 +1: All tests passed!                                                                                                                                            
➜  retrofit 

Yes like you see we return a fake Article, but we want to have the list returned by getArticles() in the return. Let's make a test for it!

    test(
      'should return List<Articles> when the response is 200 (success)',
      () async {
        // arrange
        when(
          () => homeClient.getArticles(),
        ).thenAnswer(
          (_) async => tArticles,
        );
        // act
        final response = await dataSource.getArticles();
        // assert
        expect(response, tArticles);
      },
    );
home_remote_data_source_test.dart

And we can again pass the tests:

00:00 +1 -1: test/features/home/data/datasources/home_remote_data_source_test.dart: get articles should return List<Articles> when the response is 200 (success) [E]   
  Expected: [Instance of 'Article']
    Actual: [Instance of 'Article']
     Which: at location [0] is <Instance of 'Article'> instead of <Instance of 'Article'>
  
  package:test_api                                                            expect
  test/features/home/data/datasources/home_remote_data_source_test.dart 69:9  main.<fn>.<fn>
  
00:00 +1 -1: loading test/features/home/data/datasources/home_remote_data_source_test.dart                                                                             
Consider enabling the flag chain-stack-traces to receive more detailed exceptions.
For example, 'dart test --chain-stack-traces'.
00:00 +1 -1: Some tests failed.                                                                                                                                        
➜  retrofit 

And the implementation:

  @override
  Future<List<Article>> getArticles() async {
    return await homeClient.getArticles();
  }

Now the test should pass because we return directly the list from the homeClient.

➜  retrofit dart test
00:00 +2: All tests passed!                                                                                                                                            
➜  retrofit 

Now we want to test if the call got a problem like an Exception. Retrofit returns a DioError when something gets wrong on a call. So we will mock that in the next test:

    test(
      'should throw a ServerException when the response code is 404 or other (unsuccess)',
      () async {
        // arrange
        when(() => homeClient.getArticles()).thenThrow(
          DioError(
            response: Response(
              data: 'Something went wrong',
              statusCode: 404,
              requestOptions: RequestOptions(path: ''),
            ),
            requestOptions: RequestOptions(path: ''),
          ),
        );
        // act
        final call = dataSource.getArticles;
        // assert
        expect(
          () => call(),
          throwsA(const TypeMatcher<ServerException>()),
        );
      },
    );
home_remote_data_source_test.dart

Here we mock the exception with a DioError because we know Retrofit returns a DioError. We can customize the Error with the status code for example. If you want to unit test a particular status code response you could.

Now again we pass the test:

➜  retrofit dart test
00:00 +2 -1: test/features/home/data/datasources/home_remote_data_source_test.dart: get articles should throw a ServerException when the response code is 404 or other (unsuccess) [E]
  Expected: throws <Instance of 'ServerException'>
    Actual: <Closure: () => Future<List<Article>>>
     Which: threw DioError:<DioError [DioErrorType.other]: >
            stack package:mocktail/src/mocktail.dart 249:7                                     When.thenThrow.<fn>
                  package:mocktail/src/mocktail.dart 132:37                                    Mock.noSuchMethod
                  bin/features/home/domain/entities/home_client.dart 12:25                     MockHomeClient.getArticles
                  bin/features/home/data/datasources/home_remote_data_source.dart 20:29        HomeRemoteDataSourceImpl.getArticles
                  test/features/home/data/datasources/home_remote_data_source_test.dart 82:21  main.<fn>.<fn>.<fn>
                  package:test_api                                                             expect
                  test/features/home/data/datasources/home_remote_data_source_test.dart 81:9   main.<fn>.<fn>
                  test/features/home/data/datasources/home_remote_data_source_test.dart 66:7   main.<fn>.<fn>
                  
            which is not an instance of 'ServerException'
  
  dart:async  _CustomZone.runUnary
  
00:00 +2 -1: loading test/features/home/data/datasources/home_remote_data_source_test.dart                                                                             
Consider enabling the flag chain-stack-traces to receive more detailed exceptions.
For example, 'dart test --chain-stack-traces'.
00:00 +2 -1: Some tests failed.                                                                                                                                        
➜  retrofit 

Ok, we got the error let's implement the code source:

  @override
  Future<List<Article>> getArticles() async {
    try {
      return await homeClient.getArticles();
    } on DioError catch (_) {
      throw ServerException();
    }
  }
home_remote_data_source.dart

And pass the test:

➜  retrofit dart test
00:00 +3: All tests passed!                                                                                                                                            
➜  retrofit 

Well done! Everything is tested now and you can replicate that for every future function you have to implement like a POST/PUT etc...

Conclusion

With this approach, we have a way to test Retrofit in a clean architecture project.

You can find all the codes/tests on my repo Github:

GitHub - Kiruel/retrofit_clean_architecture
Contribute to Kiruel/retrofit_clean_architecture development by creating an account on GitHub.

Subscribe to Etienne Théodore

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe