Flutter TDD Clean Architecture Course [6] – Repository Implementation

25  comments

After the previous part, we now have all the contracts of the Repository's dependencies in place. Those dependencies are the local and remote Data Source and also the NetworkInfo class, for finding out if the user is online. Mocking these dependencies will allow us to implement the Repository class using test-driven development.

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!

Implementing the Repository

If you need a quick refresher regarding where we finished in the previous part, we currently have a test file with mocked dependencies...

number_trivia_repository_impl_test.dart

class MockRemoteDataSource extends Mock
    implements NumberTriviaRemoteDataSource {}

class MockLocalDataSource extends Mock implements NumberTriviaLocalDataSource {}

class MockNetworkInfo extends Mock implements NetworkInfo {}

void main() {
  NumberTriviaRepositoryImpl repository;
  MockRemoteDataSource mockRemoteDataSource;
  MockLocalDataSource mockLocalDataSource;
  MockNetworkInfo mockNetworkInfo;

  setUp(() {
    mockRemoteDataSource = MockRemoteDataSource();
    mockLocalDataSource = MockLocalDataSource();
    mockNetworkInfo = MockNetworkInfo();
    repository = NumberTriviaRepositoryImpl(
      remoteDataSource: mockRemoteDataSource,
      localDataSource: mockLocalDataSource,
      networkInfo: mockNetworkInfo,
    );
  });
}

...and also a plain, unimplemented Repository.

number_trivia_repository_impl.dart

class NumberTriviaRepositoryImpl implements NumberTriviaRepository {
  final NumberTriviaRemoteDataSource remoteDataSource;
  final NumberTriviaLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  NumberTriviaRepositoryImpl({
    @required this.remoteDataSource,
    @required this.localDataSource,
    @required this.networkInfo,
  });

  @override
  Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number) {
    // TODO: implement getConcreteNumberTrivia
    return null;
  }

  @override
  Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia() {
    // TODO: implement getRandomNumberTrivia
    return null;
  }
}

Let's start with implementing the getConcreteNumberTrivia method first.

getConcreteNumberTrivia

It's the job of the Repository to get fresh data from the API when there is an Internet connection (and then to cache it locally), or to get the cached data when the user is offline.

Getting to know the network status of the device is therefore the first thing that should happen inside this method. Let's write a test following the Arrange - Act - Assert flow. We're mocking the NetworkInfo and verifying if its isConnected property has been called.

number_trivia_repository_impl_test.dart

group('getConcreteNumberTrivia', () {
  // DATA FOR THE MOCKS AND ASSERTIONS
  // We'll use these three variables throughout all the tests
  final tNumber = 1;
  final tNumberTriviaModel =
      NumberTriviaModel(number: tNumber, text: 'test trivia');
  final NumberTrivia tNumberTrivia = tNumberTriviaModel;

  test('should check if the device is online', () {
    //arrange
    when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
    // act
    repository.getConcreteNumberTrivia(tNumber);
    // assert
    verify(mockNetworkInfo.isConnected);
  });
});

I don't think I need to write it anymore, but this test will fail. We have to implement the needed functionality. With the other tests in this part, I'll just show you the production code immediately after the test code.

number_trivia_repository_impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(int number) {
  networkInfo.isConnected;
  return null;
}
"But... the code above doesn't do anything useful with the value of isConnected!"
That's right! You should never write more production code than is sufficient for passing the test. We'll make this method do something useful after further testing.

As you already know, the functionality of the method will differ based on whether or not the user is online. We will therefore "branch" the tests into two categories - online and offline. Let's start of with the online branch.

Online Behavior

test.dart

group('device is online', () {
  // This setUp applies only to the 'device is online' group
  setUp(() {
    when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
  });

  test(
    'should return remote data when the call to remote data source is successful',
    () async {
      // arrange
      when(mockRemoteDataSource.getConcreteNumberTrivia(tNumber))
          .thenAnswer((_) async => tNumberTriviaModel);
      // act
      final result = await repository.getConcreteNumberTrivia(tNumber);
      // assert
      verify(mockRemoteDataSource.getConcreteNumberTrivia(tNumber));
      expect(result, equals(Right(tNumberTrivia)));
    },
  );
});

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
  int number,
) async {
  networkInfo.isConnected;
  return Right(await remoteDataSource.getConcreteNumberTrivia(number));
}

The Right side of the Either is the "success side" returning a NumberTrivia entity. The implementation still doesn't look like much but we're getting there.

Whenever trivia is successfully gotten from the API, we should cache it locally. That's what we're going to implement next (still within the online group) - you can always get the full project from GitHub, if you're lost, to see the code all in one place after going through the tutorial.

test.dart

test(
  'should cache the data locally when the call to remote data source is successful',
  () async {
    // arrange
    when(mockRemoteDataSource.getConcreteNumberTrivia(tNumber))
        .thenAnswer((_) async => tNumberTriviaModel);
    // act
    await repository.getConcreteNumberTrivia(tNumber);
    // assert
    verify(mockRemoteDataSource.getConcreteNumberTrivia(tNumber));
    verify(mockLocalDataSource.cacheNumberTrivia(tNumberTrivia));
  },
);

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
  int number,
) async {
  networkInfo.isConnected;
  final remoteTrivia = await remoteDataSource.getConcreteNumberTrivia(number);
  localDataSource.cacheNumberTrivia(remoteTrivia);
  return Right(remoteTrivia);
}

Finally when we're online and the remote Data Source throws a ServerException, we should convert it to a ServerFailure and return it from the method. In such case, nothing should be cached locally (thus verifyZeroInteractions).

test.dart

test(
  'should return server failure when the call to remote data source is unsuccessful',
  () async {
    // arrange
    when(mockRemoteDataSource.getConcreteNumberTrivia(tNumber))
        .thenThrow(ServerException());
    // act
    final result = await repository.getConcreteNumberTrivia(tNumber);
    // assert
    verify(mockRemoteDataSource.getConcreteNumberTrivia(tNumber));
    verifyZeroInteractions(mockLocalDataSource);
    expect(result, equals(Left(ServerFailure())));
  },
);

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
  int number,
) async {
  networkInfo.isConnected;
  try {
    final remoteTrivia =
        await remoteDataSource.getConcreteNumberTrivia(number);
    localDataSource.cacheNumberTrivia(remoteTrivia);
    return Right(remoteTrivia);
  } on ServerException {
    return Left(ServerFailure());
  }
}

Offline Behavior

The tests above are enough for when the device is online, now comes the time to implement the offline behavior. Let's again create a new test group along with the first testThe Repository should return the last locally cached trivia when not online.

test.dart

group('device is offline', () {
  setUp(() {
    when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
  });

  test(
    'should return last locally cached data when the cached data is present',
    () async {
      // arrange
      when(mockLocalDataSource.getLastNumberTrivia())
          .thenAnswer((_) async => tNumberTriviaModel);
      // act
      final result = await repository.getConcreteNumberTrivia(tNumber);
      // assert
      verifyZeroInteractions(mockRemoteDataSource);
      verify(mockLocalDataSource.getLastNumberTrivia());
      expect(result, equals(Right(tNumberTrivia)));
    },
  );
});

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
  int number,
) async {
  // Finally doing something with the value of isConnected ?
  if (await networkInfo.isConnected) {
    try {
      final remoteTrivia =
          await remoteDataSource.getConcreteNumberTrivia(number);
      localDataSource.cacheNumberTrivia(remoteTrivia);
      return Right(remoteTrivia);
    } on ServerException {
      return Left(ServerFailure());
    }
  } else {
    final localTrivia = await localDataSource.getLastNumberTrivia();
    return Right(localTrivia);
  }
}

We also have to handle the case when the local Data Source throws a CacheException by returning a CacheFailure through the Left "error" side of Either. As it writes in the documentation of the getLastNumberTrivia method, CacheException will happen whenever there is nothing present inside the cache.

test.dart

test(
  'should return CacheFailure when there is no cached data present',
  () async {
    // arrange
    when(mockLocalDataSource.getLastNumberTrivia())
        .thenThrow(CacheException());
    // act
    final result = await repository.getConcreteNumberTrivia(tNumber);
    // assert
    verifyZeroInteractions(mockRemoteDataSource);
    verify(mockLocalDataSource.getLastNumberTrivia());
    expect(result, equals(Left(CacheFailure())));
  },
);

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
  int number,
) async {
  if (await networkInfo.isConnected) {
    try {
      final remoteTrivia =
          await remoteDataSource.getConcreteNumberTrivia(number);
      localDataSource.cacheNumberTrivia(remoteTrivia);
      return Right(remoteTrivia);
    } on ServerException {
      return Left(ServerFailure());
    }
  } else {
    try {
      final localTrivia = await localDataSource.getLastNumberTrivia();
      return Right(localTrivia);
    } on CacheException {
      return Left(CacheFailure());
    }
  }
}

getRandomNumberTrivia

The way we will build getRandomNumberTrivia will be almost identical to getConcreteNumberTrivia. We can even refactor some parts of the tests into named methods - namely the online and offline groups. Refactored using the two new methods, the current test code will look like this (truncated for brevity, get the full code on GitHub).

test.dart

void main() {
  ...

  void runTestsOnline(Function body) {
    group('device is online', () {
      setUp(() {
        when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
      });

      body();
    });
  }

  void runTestsOffline(Function body) {
    group('device is offline', () {
      setUp(() {
        when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
      });

      body();
    });
  }

  group('getConcreteNumberTrivia', () {
    ...

    runTestsOnline(() {
      test(
        ...
    });

    runTestsOffline(() {
      test(
        ...
    });
  });
}

The semi-official laws of TDD say that you should always write and implement tests one by one. I, however, like to be practical, especially when it comes to coding in tutorials like this one.

Since the getRandomNumberTrivia method will differ only in a single call to the remote Data Source, we're going to copy all the tests we currently have for the concrete method and slightly modify them to work with the random method.

test.dart

group('getRandomNumberTrivia', () {
  final tNumberTriviaModel =
      NumberTriviaModel(number: 123, text: 'test trivia');
  final NumberTrivia tNumberTrivia = tNumberTriviaModel;

  test('should check if the device is online', () {
    //arrange
    when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
    // act
    repository.getRandomNumberTrivia();
    // assert
    verify(mockNetworkInfo.isConnected);
  });

  runTestsOnline(() {
    test(
      'should return remote data when the call to remote data source is successful',
      () async {
        // arrange
        when(mockRemoteDataSource.getRandomNumberTrivia())
            .thenAnswer((_) async => tNumberTriviaModel);
        // act
        final result = await repository.getRandomNumberTrivia();
        // assert
        verify(mockRemoteDataSource.getRandomNumberTrivia());
        expect(result, equals(Right(tNumberTrivia)));
      },
    );

    test(
      'should cache the data locally when the call to remote data source is successful',
      () async {
        // arrange
        when(mockRemoteDataSource.getRandomNumberTrivia())
            .thenAnswer((_) async => tNumberTriviaModel);
        // act
        await repository.getRandomNumberTrivia();
        // assert
        verify(mockRemoteDataSource.getRandomNumberTrivia());
        verify(mockLocalDataSource.cacheNumberTrivia(tNumberTrivia));
      },
    );

    test(
      'should return server failure when the call to remote data source is unsuccessful',
      () async {
        // arrange
        when(mockRemoteDataSource.getRandomNumberTrivia())
            .thenThrow(ServerException());
        // act
        final result = await repository.getRandomNumberTrivia();
        // assert
        verify(mockRemoteDataSource.getRandomNumberTrivia());
        verifyZeroInteractions(mockLocalDataSource);
        expect(result, equals(Left(ServerFailure())));
      },
    );
  });

  runTestsOffline(() {
    test(
      'should return last locally cached data when the cached data is present',
      () async {
        // arrange
        when(mockLocalDataSource.getLastNumberTrivia())
            .thenAnswer((_) async => tNumberTriviaModel);
        // act
        final result = await repository.getRandomNumberTrivia();
        // assert
        verifyZeroInteractions(mockRemoteDataSource);
        verify(mockLocalDataSource.getLastNumberTrivia());
        expect(result, equals(Right(tNumberTrivia)));
      },
    );

    test(
      'should return CacheFailure when there is no cached data present',
      () async {
        // arrange
        when(mockLocalDataSource.getLastNumberTrivia())
            .thenThrow(CacheException());
        // act
        final result = await repository.getRandomNumberTrivia();
        // assert
        verifyZeroInteractions(mockRemoteDataSource);
        verify(mockLocalDataSource.getLastNumberTrivia());
        expect(result, equals(Left(CacheFailure())));
      },
    );
  });
});

In the spirit of TDD, we won't do any premature refactoring. Let's first implement the code naively, although we already know it will be littered with duplication.

impl.dart

@override
Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia() async {
  if (await networkInfo.isConnected) {
    try {
      final remoteTrivia = await remoteDataSource.getRandomNumberTrivia();
      localDataSource.cacheNumberTrivia(remoteTrivia);
      return Right(remoteTrivia);
    } on ServerException {
      return Left(ServerFailure());
    }
  } else {
    try {
      final localTrivia = await localDataSource.getLastNumberTrivia();
      return Right(localTrivia);
    } on CacheException {
      return Left(CacheFailure());
    }
  }
}

The literal single difference between concrete and random is just calling for this code to be refactored. Most of the logic can be shared between the concrete and random methods and we'll handle the single different call to the local Data Source with a higher-order function. The final version of the NumberTriviaRepositoryImpl will look like the following:

impl.dart

typedef Future<NumberTrivia> _ConcreteOrRandomChooser();

class NumberTriviaRepositoryImpl implements NumberTriviaRepository {
  final NumberTriviaRemoteDataSource remoteDataSource;
  final NumberTriviaLocalDataSource localDataSource;
  final NetworkInfo networkInfo;

  NumberTriviaRepositoryImpl({
    @required this.remoteDataSource,
    @required this.localDataSource,
    @required this.networkInfo,
  });

  @override
  Future<Either<Failure, NumberTrivia>> getConcreteNumberTrivia(
    int number,
  ) async {
    return await _getTrivia(() {
      return remoteDataSource.getConcreteNumberTrivia(number);
    });
  }

  @override
  Future<Either<Failure, NumberTrivia>> getRandomNumberTrivia() async {
    return await _getTrivia(() {
      return remoteDataSource.getRandomNumberTrivia();
    });
  }

  Future<Either<Failure, NumberTrivia>> _getTrivia(
    _ConcreteOrRandomChooser getConcreteOrRandom,
  ) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteTrivia = await getConcreteOrRandom();
        localDataSource.cacheNumberTrivia(remoteTrivia);
        return Right(remoteTrivia);
      } on ServerException {
        return Left(ServerFailure());
      }
    } else {
      try {
        final localTrivia = await localDataSource.getLastNumberTrivia();
        return Right(localTrivia);
      } on CacheException {
        return Left(CacheFailure());
      }
    }
  }
}

If this isn't a thing of beauty, I don't know what is! All the tests are still passing, so we can be confident that this refactoring didn't break anything.

What's Next

Now that we have the Repository fully implemented, we are going start working on the low-level parts of the data layer by implementing the Data Sources and Network Info. Subscribe below, grow your Flutter skills and never miss a tutorial which will always make you more prepared for real app development.

About the author 

Matt Rešetár

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

You may also like

  • This part was hard to keep up! I made it to the end but I am still not feeling confident! I noticed that I really need to go deeper into TDD. Can you recommend any specific stuff to dive into this content with focus on Flutter?

    • Agree, he keep giving us test code then impl code without asking us to run the test.

      I will try re read this with different approach to make me more confident.

      write one test,
      run test and see how the test fail
      write impl
      run test, see how the test fail and write impl until it pass

      repeat step above for each test

      • Here is my strategy

        write a test as indicated by Reso Coder.
        run the test and see how it fails.
        Debug the error with the AI or by looking at the Mockito updates.
        Edit repository_impl until it passes

  • Hey,
    Thanks for the great tutorial. I’m facing this small issue.
    Test is not passing for server failure.
    ERROR: Expected: Left:
    Actual: Left:

    Hope you can help me.

    Regards

  • Hi Matej,
    Very nice tutorials. I am following one by one.
    During my learning Things got more clear when I set yhe NumberTrivia to NumberTriviaEntiry. It have made more clear in the code when I was using one or another.

  • For the purists, the return type of ‘_ConcreteOrRandomChooser’ should be NumberTriviaModel not NumberTrivia, same as the return type of data source get* methods. Just a thought.

    • I thought the compiler would complain there, but it seems to be pretty clever.
      It certainly breaks if I put a “real” NumberTrivia into localDataSource.cacheNumberTrivia(remoteTrivia)

      • it gave me an error lol but i solved it by
        localDataSource.cacheNumberTrivia
        (remoteTrivia as NumberTriviaModel)

    • I thought the same thing. I didn’t see the point of using the entity, let’s remember that it is numbertriviamodel that extends from the entity, also that with that object they will do procedures for the repo such as saving it and I didn’t see the point of using the commercial object.

  • Hey there! Really loving your advanced Flutter videos. They really help a lot.
    How can I do TDD on something like Flutter Local Notifications? Would I have to use Mockito, or leave it altogether?

  • Very good tutorial!

    One question: how to handle the case of the repository returning a Stream instead of a Future? What would the tests be like? Thanks in advance!

  • Hi,
    the github code and the one is written here does not match, like test groups. And then you change the file names to so it makes it difficult to follow up. Anyway I already donated because of your work. Thanks.

  • Hi – I’ve been following these tutorials and they’ve been great so far. Thank you so much for the effort of making these.

    This one was tricker and harder to follow. Specifically, I don’t understand how the test ‘should check if the device is online’ is passing, if you have not stubbed mockRemoteDataSource.getConcreteNumberTrivia after adding it to the repository implementation of getConcreteNumberTrivia. I am getting an error that it is not stubbed and the test fails.

    • I’m also wondering the same thing. When a NumberTriviaModel extends NumberTrivia, do we still need to cast it?

  • I fetched error in ‘should check if the device is online’
    The error was – MissingStubError

    I searched in google for solution but I didn’t get right solution.
    After researching my code I got the problem.

    test(
    ‘should check if the device is online’,
    () async {
    // arrange
    when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
    // Add this line
    when(mockRemoteDataSource.getConcreteNumberTrivia(any))
    .thenAnswer((_) async => tNumberTriviaModel);
    // act
    await repository.getConcreteNumberTrivia(tNumber);
    // assert
    verify(mockNetworkInfo.isConnected);
    },
    );

    • Thanks a lot for your solution.
      I also added a similar line to the getRandomTrivia online test.

      … when(mockRemoteDataSource.getRandomNumberTrivia())
      .thenAnswer((realInvocation) async => tNumberTrivia);

    • great answer, thank you!

      also i think no need to await the repository.getConcreteNumberTrivia(tNumber)

  • This part was a bit hard to follow! I got to the end and I really feel like I’ve learnt something new! Thank you very much. I’m already trying to execute all this in a travel App with Flutter. With the usecase getAvialableFlightOffer(). Here is my strategy

    write a test as indicated by Reso Coder.
    run the test and see how it fails.
    Debug the error with the AI or by looking at the Mockito updates.
    Edit repository_impl until it passes

    At the end of each chapter I feel more confident. I can only recommend this site.

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