Test

Como reutilizar tests en Flutter usando composición

Como reutilizar tests en Flutter usando composición
En: Test, Flutter, Buenas prácticas

Es habitual ver que en el código de test no seguimos las mismas buenas prácticas que seguimos en el código de producción.

Una práctica que se ve muchas veces, es tener muchos test repetidos. La acción de copiar y pegar un fichero de test existente, cambiarle el fixture o algún detalle más es bastante frecuente.

El problema surje cuando en esos test hay que modificar algo porque el comportamiento cambia y tenemos que hacer el mismo cambio en 10 o 15 ficheros de test.

En este artículo vamos a ver un caso muy concreto de test en Flutter que normalmente podría conllevar código duplicado y vamos a ver como utilizando composición evitamos el código duplicado de una forma bastante sencilla.

Nuestro escenario

Nuestro escenario consiste en un caso muy típico del desarrollo mobile donde queremos que nuestra aplicación sea offline first.

Debido a esto, los repositorios van a intentar primero obtener la información de cache y si no existe o es inválida, se obtiene la información de la API a través de la red y se actualiza la cache.

Test duplicados

Esta lógica se repite a lo largo de todos los repositorios. Lo lógico es evitar tener código duplicado en los repositorios utilizando herencia o composición, pero esta parte no toca en este artículo.

Vamos a centrarnos en los test, donde también vamos a tener código duplicado y vamos a ver la forma de evitar esto en flutter.

Para este tipo de tests, como los repositorios reciben los data sources en el constructor, utilizaremos una librería de mocking para simular diferentes escenarios:

List<SocialNews> givenThereAreValidDataInCache() {
  final socialNews = _localNews();

  when(_cacheDataSource.getAll()).thenAnswer((_) => Future.value(socialNews));
  when(_cacheDataSource.areValidValues()).thenAnswer((_) => Future.value(true));

  return socialNews;
}

List<SocialNews> givenThereAreInvalidDataInCache() {
  final socialNews =  _localNews();

  when(_cacheDataSource.getAll()).thenAnswer((_) => Future.value(socialNews));
  when(_cacheDataSource.areValidValues())
      .thenAnswer((_) => Future.value(false));

  return socialNews;
}

List<SocialNews> givenThereAreSomeDataInRemote() {
  final socialNews =  _remoteNews();

  when(_remoteDataSource.getAll()).thenAnswer((_) => Future.value(socialNews));

  return socialNews;
}

void givenThereAreNotDataInCache() {
  when(_cacheDataSource.getAll()).thenAnswer((_) => Future.value([]));
}

void givenThatRemoteThrowException() {
  when(_remoteDataSource.getAll()).thenThrow(NetworkException());
}

void givenThatCacheThrowException() {
  when(_cacheDataSource.getAll()).thenThrow(NetworkException());
}

List<SocialNews> _localNews() {
  return [SocialNewsMother.countDownMadrid2018()];
}

List<SocialNews> _remoteNews() {
  return [
    SocialNewsMother.countDownMadrid2018(),
    SocialNewsMother.newVideoInKarateStars()
  ];
}

Y este sería un ejemplo de test de un repositorio:

class MockCacheDataSource<T> extends Mock implements CacheableDataSource<T> {}

class MockRemoteDataSource<T> extends Mock implements ReadableDataSource<T> {}

final _cacheDataSource = MockCacheDataSource();

final _remoteDataSource = MockRemoteDataSource();

SocialNewsRepository _repository;

void main() {
  setUp(() async {
    _repository =
        SocialNewsCachedRepository(_cacheDataSource, _remoteDataSource);
  });

  group('SocialNewsRepository', () {
    test('return data from cache if cache is valid', () async {
      final expectedNews = givenThereAreValidDataInCache();

      givenThereAreSomeDataInRemote();

      final socialNews =
          await _repository.getSocialNews(ReadPolicy.cache_first);

      expect(socialNews, expectedNews);
    });
    test('return data from remote if cache is invalid', () async {
      givenThereAreInvalidDataInCache();
      final expectedNews = givenThereAreSomeDataInRemote();

      final socialNews =
          await _repository.getSocialNews(ReadPolicy.cache_first);

      expect(socialNews, expectedNews);
    });
    test('return data from remote if cache has no data', () async {
      givenThereAreNotDataInCache();
      final expectedNews = givenThereAreSomeDataInRemote();

      final socialNews =
          await _repository.getSocialNews(ReadPolicy.cache_first);

      expect(socialNews, expectedNews);
    });
    test('return data from cache if cache is invalid and remote throw error',
        () async {
      final expectedNews = givenThereAreInvalidDataInCache();

      givenThatRemoteThrowException();

      final socialNews =
          await _repository.getSocialNews(ReadPolicy.cache_first);

      expect(socialNews, expectedNews);
    });
    test('throw exception if cache has not data and remote throw exception',
        () async {
      givenThereAreNotDataInCache();
      givenThatRemoteThrowException();

      expect(() => _repository.getSocialNews(ReadPolicy.cache_first),
          throwsA(isInstanceOf<NetworkException>()));
    });
    test('throw exception if cache and remote throw exception', () async {
      givenThatCacheThrowException();
      givenThatRemoteThrowException();

      expect(() => _repository.getSocialNews(ReadPolicy.cache_first),
          throwsA(isInstanceOf<NetworkException>()));
    });
  });
}

Como podemos ver la lógica que verificamos en los test no van a ser específicos para un repositorio, sino que son tests que debemos realizar para cada repositorio.

Composición

¿Por que utilizar comosición para evitar código duplicado en los tests en Flutter?. Porque en flutter las funciones son cuidadano de primer orden, los test están basados en funciones y no en clases.

Un test de Flutter sigue esta estructura:

void main() {
  test('Test description', () {
  });
}

Como se puede apreciar no hay clases por ningun lado, son solo funciones.

Lo que podemos hacer es creanos una función común que ejecuta los test que son comunes y que reciba por parámetro los datos que necesita para ejecutar los tests.

Vamos a ver como sería la estructura de los tests de forma sencilla antes de visualizarlo con nuestro ejemplo más complejo.

Por un lado tenemos los test comunes en un fichero separado

//common_test.dart
void executeCommonTest(String text) {
  test('Test 1', () {
    expect(text.isEmpty, false);
  });
  test('Test 2', () {
    expect(text.contains('_'), true);
  });
}

// To avoid method main not found
void main() {}

Este fichero es necesario que tenga un método main aunque sea vacío para evitar un warning al ejecutar los test.

Y posteriormente tenemos nuestro fichero de test normal donde invocamos a los test comunes desde el método main.

//normal_test.dart
void main() {
  executeCommonTest('test_example')
}

Refactorizando los test

Vamos a ver como aplicamos esto a nuestro ejemplo. Por un lado tendríamos los test comunes, donde recebiríamos por parámetro los datos necesarios.

Necesitamos pasar como parámetro una función para crear el repositorio desde cada fichero de test especifico, porque cada repositorio sera diferente. También necesitamos los datos locales y remotos fake con los que trabajar.

La función debe ser genérica ya que los data sources lo son y cada uno trabajará con una entidad diferente.

//common_cached_repository_test.dart

class MockCacheDataSource<T> extends Mock implements CacheableDataSource<T> {}

class MockRemoteDataSource<T> extends Mock implements ReadableDataSource<T> {}

void executeRepositoryTests<T>(
    Function(CacheableDataSource<T>, ReadableDataSource<T>) repositoryFactory,
    List<T> localData,
    List<T> remoteData) {
  late CachedRepository<T> _repository;
  final _cacheDataSource = MockCacheDataSource<T>();

  final _remoteDataSource = MockRemoteDataSource<T>();

  List<T> givenThereAreValidDataInCache() {
    when(() => _cacheDataSource.getAll())
        .thenAnswer((_) => Future.value(localData));
    when(() => _cacheDataSource.areValidValues())
        .thenAnswer((_) => Future.value(true));

    return localData;
  }

  List<T> givenThereAreInvalidDataInCache() {
    when(() => _cacheDataSource.getAll())
        .thenAnswer((_) => Future.value(localData));
    when(() => _cacheDataSource.areValidValues())
        .thenAnswer((_) => Future.value(false));
    when(() => _cacheDataSource.invalidate()).thenAnswer((_) => Future.value());
    when(() => _cacheDataSource.save(any())).thenAnswer((_) => Future.value());

    return localData;
  }

  List<T> givenThereAreSomeDataInRemote() {
    when(() => _remoteDataSource.getAll())
        .thenAnswer((_) => Future.value(remoteData));

    return remoteData;
  }

  void givenThereAreNotDataInCache() {
    when(() => _cacheDataSource.getAll()).thenAnswer((_) => Future.value([]));
  }

  void givenThatRemoteThrowException() {
    when(() => _remoteDataSource.getAll()).thenThrow(NetworkException());
  }

  void givenThatCacheThrowException() {
    when(() => _cacheDataSource.getAll()).thenThrow(NetworkException());
  }

  setUp(() async {
    _repository = repositoryFactory(_cacheDataSource, _remoteDataSource);
  });

  group('Repository', () {
    group('for cache first should', () {
      test('return data from cache if cache is valid', () async {
        final expectedData = givenThereAreValidDataInCache();

        givenThereAreSomeDataInRemote();

        final currentData = await _repository.getAll(ReadPolicy.cache_first);

        expect(currentData, expectedData);
      });
      test('return data from remote if cache is invalid', () async {
        givenThereAreInvalidDataInCache();
        final expectedData = givenThereAreSomeDataInRemote();

        final currentData = await _repository.getAll(ReadPolicy.cache_first);

        expect(currentData, expectedData);
      });
      test('return data from remote if cache has no data', () async {
        givenThereAreNotDataInCache();
        final expectedData = givenThereAreSomeDataInRemote();

        final currentData = await _repository.getAll(ReadPolicy.cache_first);

        expect(currentData, expectedData);
      });
      test('return data from cache if cache is invalid and remote throw error',
          () async {
        final expectedData = givenThereAreInvalidDataInCache();

        givenThatRemoteThrowException();

        final currentData = await _repository.getAll(ReadPolicy.cache_first);

        expect(currentData, expectedData);
      });
      test('throw exception if cache has not data and remote throw exception',
          () async {
        givenThereAreNotDataInCache();
        givenThatRemoteThrowException();

        expect(() => _repository.getAll(ReadPolicy.cache_first),
            throwsA(isInstanceOf<NetworkException>()));
      });
      test('throw exception if cache and remote throw exception', () async {
        givenThatCacheThrowException();
        givenThatRemoteThrowException();

        expect(() => _repository.getAll(ReadPolicy.cache_first),
            throwsA(isInstanceOf<NetworkException>()));
      });
    });
    group('for network first should', () {
      test('return data from remote even cache is valid', () async {
        givenThereAreValidDataInCache();

        final expectedData = givenThereAreSomeDataInRemote();

        final currentData = await _repository.getAll(ReadPolicy.network_first);

        expect(currentData, expectedData);
      });
      test('return data from cache if cache is invalid and remote throw error',
          () async {
        final expectedData = givenThereAreValidDataInCache();
        givenThatRemoteThrowException();

        final currentData = await _repository.getAll(ReadPolicy.network_first);

        expect(currentData, expectedData);
      });
      test('throw exception if cache has not data and remote throw exception',
          () async {
        givenThereAreNotDataInCache();
        givenThatRemoteThrowException();

        expect(() => _repository.getAll(ReadPolicy.network_first),
            throwsA(isInstanceOf<NetworkException>()));
      });
      test('throw exception if cache has not data and remote throw exception',
          () async {
        givenThatCacheThrowException();
        givenThatRemoteThrowException();

        expect(() => _repository.getAll(ReadPolicy.network_first),
            throwsA(isInstanceOf<NetworkException>()));
      });
    });
  });
}

// To avoid method main not found
void main() {}

La parte que menos me convence es tener los métodos de factoria de escenarios encima de los test porque me gusta que lo primero que se ve sean los test.

Pero en Dart los test al ser funciones dentro de una función, no es posible utilizar funciones antes de declararlas.

Posteriormente tendríamos los test de cada uno de los repositorios ejecutando a los test comunes, vamos a ver como sería uno de ellos.

//social_news_cached_repository_test.dart

List<SocialNews> _localData() {
  return [countDownMadrid2018()];
}

List<SocialNews> _remoteData() {
  return [countDownMadrid2018(), newVideoInKarateStars()];
}

SocialNewsCachedRepository repositoryFactory(
    CacheableDataSource<SocialNews> cache,
    ReadableDataSource<SocialNews> remote) {
  return SocialNewsCachedRepository(cache, remote);
}

void main() {
  executeRepositoryTests(repositoryFactory, _localData(), _remoteData());
}

En este fichero podríamos tener dentro del método main, test específicos a este repositorio si fuera el caso, y que no aplica tenerlo dentro de los test comunes.

Conclusiones

Hemos visto en este artículo como a través de la composición, en el caso de Flutter, podemos reutilizar tests que prueban una misma lógica para cada tipo de objetos similares.

Más de xurxodev | Desarrollador Freelance
¡Genial! Te has inscrito con éxito.
Bienvenido de nuevo! Has iniciado sesión correctamente.
Te has suscrito correctamente a xurxodev | Desarrollador Freelance.
Su enlace ha caducado.
¡Éxito! Comprueba en tu correo electrónico el enlace mágico para iniciar sesión.
Éxito! Su información de facturación ha sido actualizada.
Su facturación no se actualizó.