Flutter TDD Clean Architecture Course [6] – Repository Implementation

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.

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

>