Cubit en Práctica: Creando una Aplicación de Noticias Responsiva y con Soporte de Múltiples Idiomas. Parte 2

Esta es la segunda parte de esta serie de artículos, donde creamos una aplicación de noticias responsiva con soporte para tres idiomas: español, inglés y chino. Además, implementaremos una arquitectura escalable y añadiremos pruebas unitarias y de widgets.

Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Inyección de dependencias con GetIt

GetIt es un localizador de servicios que nos ayuda a cumplir con el Principio de Inversión de Dependencia, el cual tiene como objetivo reducir el acoplamiento entre los componentes de software y promover la flexibilidad y la facilidad de mantenimiento.

Vamos a crear un nuevo archivo llamado dependency_injection.dart, donde inicializaremos GetIt y crearemos una función de ayuda para inyectar NewsDataSource:

final getIt = GetIt.instance;

Future<void> injectDependencies() async {
  getIt.registerLazySingleton(() => NewsDataSource());
}

Luego, en la función main(), llamamos a injectDependencies() para registrar las dependencias:

void main() async {
 
  // Inyectamos las dependencias al iniciar la aplicación
  injectDependencies();

  runApp(const MyApp());
}

¿Por qué debemos usar inyección de dependencias en nuestra aplicación? Más adelante, cuando escribamos pruebas unitarias, podemos crear una clase "Mock", por ejemplo, MockNewsDataSource, e inyectarla en las pruebas, lo que nos permitirá simular diferentes escenarios.

La capa de datos

Repositorio: NewsRepository

En esta pequeña aplicación, el repositorio solo es un intermediario entre las fuentes de datos y los cubits.

Nota

Recuerda que, en aplicaciones más grandes, el repositorio tiene varias funciones como gestionar los datos que provienen de la fuente de datos, transformarlos, guardarlos en la memoria caché o en la base de datos local, entre otras funciones

Creamos la clase NewsRepository con las funciones fetchEverything y fetchTopHeadlines que llamaran a la fuente de datos para obtener los datos:

class NewsRepository {
  // Obtiene la fuente de datos que inyectamos anteriormente
  final NewsDataSource _dataSource = getIt();

  // Llama a la fuente de datos para obtener todas las noticias
  Future<List<Article>> fetchEverything({
    required Locale locale,
    String? search,
  }) =>
      _dataSource.fetchEverything(
        language: locale.languageCode,
        search: search,
      );

  // Llama a la fuente de datos para obtener los últimos encabezados
  Future<List<Article>> fetchTopHeadlines({
    required Locale locale,
  }) =>
      _dataSource.fetchTopHeadlines(
        country: locale.countryCode!,
      );
}

Por fin hemos terminado de codificar la capa de datos donde hemos creado la clase NewsDataSource, que actúa como la puerta de enlace a nuestras fuentes de datos externas, y hemos establecido un sólido conjunto de pruebas para garantizar su funcionamiento correcto en diversas situaciones. También a prendimos a utilizar la inyección de dependencias con GetIt, lo que nos brinda flexibilidad y facilidad de prueba.

La capa de la aplicación.

Esta capa es la que se encarga de manejar la lógica de negocio y la interacción directa con los usuarios. Esta capa interactúa directamente con la capa de datos para obtener y guardar información. Esta capa es una parte fundamental para escribir pruebas unitarias ya que la mayoría de la lógica de negocio se encuentra aquí.

Lógica de negocios: NewsCubit

La clase NewsCubit se encargará de mantener el estado de la interfaz de usuario, así como llamar al repositorio para obtener las noticias que se mostraran al usuario. Pero antes de comenzar a codificar el cubit, veamos los posibles estados de la aplicación:

Estados de la aplicación: de carga, exitoso y de error

Estados de la aplicación: de carga, exitoso y de error

  • Estado de carga: Durante este estado mostraremos una animación de carga en la interfaz de usuario. Durante este estado se está realizando una petición a la API. Lo representaremos con una clase llamada NewsLoadingState.

  • Estado exitoso: Cuando la respuesta de la API es exitosa, entramos al estado exitoso y mostraremos una lista de noticias en la interfaz de usuario. Lo representaremos con una clase llamada NewsSuccessState.

  • Estado error: Cuando la respuesta de la API es fallida, entramos al estado de error y mostramos el mensaje de error en la interfaz de usuario. Este estado lo representaremos con una clase llamada NewsErrorState.

Cuando creamos un cubit es necesario indicar el tipo del estado, por ejemplo, si el estado es un numero como en el ejemplo del contador el cubit se crea con el tipo int:

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
}

Pero nosotros tenemos tres estados que son: NewsLoadingState, NewsSuccessState y NewsErrorState ¿cómo podemos asignarle al cubit los tres?. La respuesta es usando herencia:


// Todos los estados heredan de la clase NewsState 
sealed class NewsState extends Equatable {
  @override
  List<Object> get props => [];
}

// Estado de carga
class NewsLoadingState extends NewsState {}

// Estado exitoso
class NewsSuccessState extends NewsState {

  // Contiene la lista de noticias
  final List<Article> news;

  NewsSuccessState(this.news);

  @override
  List<Object> get props => [news];
}

// Estado de error
class NewsErrorState extends NewsState {
  
  // Contiene el mensaje de error 
  final String message;

  NewsErrorState(this.message);

  @override
  List<Object> get props => [message];
}

Ya hemos definidos los estados, así que ahora podemos crear la clase NewsCubit de la siguiente manera:

class NewsCubit extends Cubit<NewsState> {
  // El cubit tiene una dependencia en el repositorio
  final NewsRepository _repository = getIt();

  NewsCubit() : super(NewsLoadingState());
}

Para completar la implementación del cubit NewsCubit debemos entender que tipo de interacciones puede tener el usuario con la aplicación. Veamos la siguiente imagen:

Puntos donde el usuario puede interactuar con la aplicación

Puntos donde el usuario puede interactuar con la aplicación

Al analizar la imagen anterior podemos ver que hay tres lugares donde el usuario puede interactuar con la aplicación:

  1. Barra de búsqueda: El usuario puede introducir texto para buscar noticias de su interés.

  2. Selección de idioma: Permite al usuario cambiar el idioma de la aplicación.

  3. Botón borrar: Borra el contenido de la barra de búsqueda y muestra en la aplicación las ultimas noticias.

Ahora podemos crear tres funciones en nuestro cubit para manejar los eventos de búsqueda, cambio de idioma y presionar el botón borrar.

class NewsCubit extends Cubit<NewsState> {
  final NewsRepository _repository = getIt();

  // Variable de ayuda que contiene el texto que estamos buscando
  String? _currentSearch;

  // Variable de ayuda que contiene el idioma y país de las noticias que estamos buscando
  Locale _currentLocale;

  // Inicializamos "locale" en el constructor
  NewsCubit(Locale locale)
      : _currentLocale = locale,
        super(NewsLoadingState());

  // Cuando el usuario introduce un término de búsqueda llamamos esta función
  Future<void> searchNews({
    String? search,
  }) async {
    _currentSearch = search;
    return _search();
  }


  // Cuando el usuario presiona el botón borrar llamamos esta función
  Future<void> clearSearch() async {
    _currentSearch = null;
    return _search();
  }

  // Cuando el usuario cambia el idioma de la aplicación llamamos esta función
  Future<void> setLocale(Locale newLocale) async {
    if (_currentLocale == newLocale) return;
    _currentLocale = newLocale;
    return _search();
  }

  // Esta función abstrae la lógica de buscar para que el código no se repita en 
  // las funciones [searchNews], [clearSearch] y [setLocale]
  Future<void> _search() async {
    
  }
}

Ya hemos creado las tres funciones necesarias para que la capa de presentación pueda enviar los eventos generados por el usuario al cubit NewsCubit. También podemos ver que las tres funciones tienen en común la lógica de buscar que vamos a crear en la función privada _search():

  Future<void> _search() async {
    // El código está dentro de un try-catch para capturar 
    // las excepciones lanzadas desde la capa de datos
    try {

      // Emitimos el estado de cargando para que la capa de presentación muestre la interfaz de carga
      emit(NewsLoadingState());

      // Usando un switch, si [_currentSearch] es nulo llamamos la API de las ultimas noticias
      // pero si no es nulo llamamos la API de buscar todas las noticias
      final news = await switch (_currentSearch) {
        null => _repository.fetchTopHeadlines(
            locale: _currentLocale,
          ),
        _ => _repository.fetchEverything(
            locale: _currentLocale,
            search: _currentSearch,
          )
      };

      // Emitimos el estado de éxito con la lista de noticias para que la capa de presentación muestre las noticias
      emit(NewsSuccessState(news));

    } on Exception catch (e) {
      
      // En caso de cualquier excepción la capturamos y emitimos estado de error para que la capa
      // de presentación muestre el error 
      if (e is MissingApiKeyException) {
        emit(NewsErrorState('Please check the API key'));
      } else if (e is ApiKeyInvalidException) {
        emit(NewsErrorState('The api key is not valid'));
      } else {
        emit(NewsErrorState('Unknown error'));
      }
    }
  }

Ya hemos completado todas las funciones, variables, etc. que conforman la clase NewsCubit. Si ponemos todo junto, el código será:

class NewsCubit extends Cubit<NewsState> {
  final NewsRepository _repository = getIt();
  String? _currentSearch;
  Locale _currentLocale;

  NewsCubit(Locale locale)
      : _currentLocale = locale,
        super(NewsLoadingState());

  Future<void> searchNews({
    String? search,
  }) async {
    _currentSearch = search;
    return _search();
  }

  Future<void> clearSearch() async {
    _currentSearch = null;
    return _search();
  }

  Future<void> setLocale(Locale newLocale) async {
    if (_currentLocale == newLocale) return;
    _currentLocale = newLocale;
    return _search();
  }

  Future<void> _search() async {
    try {
      emit(NewsLoadingState());

      final news = await switch (_currentSearch) {
        null => _repository.fetchTopHeadlines(
            locale: _currentLocale,
          ),
        _ => _repository.fetchEverything(
            locale: _currentLocale,
            search: _currentSearch,
          )
      };

      emit(NewsSuccessState(news));

    } on Exception catch (e) {

      if (e is MissingApiKeyException) {
        emit(NewsErrorState('Please check the API key'));
      } else if (e is ApiKeyInvalidException) {
        emit(NewsErrorState('The api key is not valid'));
      } else {
        emit(NewsErrorState('Unknown error'));
      }
    }
  }
}

Pruebas a la clase NewsCubit

Es hora de probar que la clase NewsCubit funciona como esperamos. Para hacer pruebas a un cubit podemos usar el paquete de ayuda bloc_test que facilita hacer pruebas en cubits y blocs. Creamos un archivo llamado news_cubit_test.dart dentro de la carpeta de test y establecemos la estructura básica:


// Mock nos permitirá regresar mock data al cubit desde el repositorio
class MockNewsRepository extends Mock implements NewsRepository {}


// Mock Locale que usaremos para pasar al cubit en el constructor y la función setLocale
const mockLocale = Locale('en', 'US');

// Llamar a la función fetchTopHeadlines regresara este articulo
const mockTopArticle = Article(title: "TopArticle", url: "someUrl");

// Llamar a la función fetchEverything regresara este articulo
const mockEverythingArticle = Article(title: "Everything", url: "someUrl");

void main() {
  late MockNewsRepository mockRepo;
  
  // setUp se llama antes de cada prueba.
  setUp(() async {
    mockRepo = MockNewsRepository();

    // Inyectamos MockNewsRepository
    getIt.registerSingleton<NewsRepository>(mockRepo);
  });

  // tearDown se llama después de cada prueba.
  tearDown(() async {
    // Reiniciamos getIt a su estado inicial
    await getIt.reset();
  });
}

Ahora tenemos que configurar MockNewsRepository para que las funciones fetchTopHeadlines y fetchEverything regresen un artículo cuando sean llamadas:

  setUp(() async {
    mockRepo = MockNewsRepository();
    getIt.registerSingleton<NewsRepository>(mockRepo);
    
    // Cuando la función fetchEverything es llamada con mockLocale y cualquier
    // termino de búsqueda regresa una lista con el articulo mockEverythingArticle
    when(() => mockRepo.fetchEverything(
          locale: mockLocale,
          search: any(named: 'search'),
        )).thenAnswer((_) async => [mockEverythingArticle]);

    // Cuando la función fetchTopHeadlines es llamada con mockLocale 
    // regresa una lista con el articulo mockTopArticle
    when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
        .thenAnswer((_) async => [mockTopArticle]);
  });

La primera prueba que vamos a hacer es verificar que llamar la función searchNews emita los estados correctos y llame a la función del repositorio fetchTopHeadlines correctamente:

  blocTest<NewsCubit, NewsState>(
      'When the search term is null '
      'fetchTopHeadlines will be called '
      'and the state will contain the mockTopArticle',
      
      // Creamos el cubit con el mockLocale
      build: () => NewsCubit(mockLocale),

      // Llamamos la función searchNews
      act: (cubit) async => cubit.searchNews(),

      // Los estados deben ser emitidos en orden correcto
      expect: () => [
            NewsLoadingState(),
            NewsSuccessState(const [mockTopArticle])
          ],

      // Verificamos que la función fetchTopHeadlines fuera llamada 1 vez con el
      // argumento mockLocale
      verify: (cubit) {
        verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
      });

En la segunda prueba a la función searchNew le vamos a pasar un texto de búsqueda por lo que la función del repositorio fetchEverything debe ser llamada y los estados deben ser emitidos correctamente:

  blocTest<NewsCubit, NewsState>(
      'When the search term is not null '
      'fetchEverything will be called '
      'and the state will contain the mockEverythingArticle',

      // Creamos el Cubit con el mockLocale
      build: () => NewsCubit(mockLocale),

      // Llamamos la función searchNews con un texto de búsqueda
      act: (cubit) async => cubit.searchNews(search: 'Hello world'),

      // Los estados deben ser emitidos en orden correcto
      expect: () => [
            NewsLoadingState(),
            NewsSuccessState(const [mockEverythingArticle])
          ],

      // Verificamos que la función fetchEverything fuera llamada 1 vez con el
      // argumento mockLocale y 'Hello world'
      verify: (cubit) {
        verify(
          () => mockRepo.fetchEverything(
            locale: mockLocale,
            search: 'Hello world',
          ),
        ).called(1);
      });

La tercera prueba vamos a llamar a la función searchNews con un texto de búsqueda y después llamaremos a las función clearSearch, entonces debemos verificar que las funciones del repositorio fetchEverything y fetchTopHeadlines sean llamadas correctamente y además que los estados de NewsCubit sean emitidos correctamente:

  blocTest<NewsCubit, NewsState>(
      'When the search term is not null '
      'fetchEverything will be called '
      'and then clearing the search will trigger a new search '
      'then fetchTopHeadlines will be called '
      'and states will be emitted correctly',

      // Creamos el cubit con el mockLocale
      build: () => NewsCubit(mockLocale),

      // Llamamos la función searchNews con un texto de búsqueda
      // y después llamamos la función clearSearch
      act: (cubit) async {
        await cubit.searchNews(search: 'Hello world');
        await cubit.clearSearch();
      },

      // Los estados deben ser emitidos en orden correcto
      expect: () => [
            NewsLoadingState(),
            NewsSuccessState(const [mockEverythingArticle]),
            NewsLoadingState(),
            NewsSuccessState(const [mockTopArticle])
          ],

      // Verificamos que la función fetchEverything y fetchTopHeadlines
      // fueran llamadas 1 vez con los argumentos correctos
      verify: (cubit) {
        verify(
          () => mockRepo.fetchEverything(
            locale: mockLocale,
            search: 'Hello world',
          ),
        ).called(1);
        verify(() => mockRepo.fetchTopHeadlines(locale: mockLocale)).called(1);
      });

Vamos a agregar una última prueba, en esta prueba queremos verificar que el estado de error sea emitido correctamente si el repositorio lanza una excepción:

  blocTest<NewsCubit, NewsState>(
    'When the Api key is not valid exception is handled correctly',
    
    build: () {

      // Configuramos el mockRepo para lanzar una excepción ApiKeyInvalidException
      // cuando la función fetchTopHeadlines sea llamada
      when(() => mockRepo.fetchTopHeadlines(locale: mockLocale))
          .thenAnswer((_) async => throw ApiKeyInvalidException());
      
      // Creamos el cubit con el mockLocale
      return NewsCubit(englishUs);
    },
    // Llamamos la función searchNews
    act: (cubit) async => cubit.searchNews(),
    
    // Los estados deben ser emitidos en orden correcto y 
    // el ultimo estado es el estado de error
    expect: () => [
      NewsLoadingState(),
      NewsErrorState('The api key is not valid'),
    ],
  );

Muy bien, ya hemos terminado la clase NewsCubit que se encargará de mantener el estado de la aplicación y también hemos agregado pruebas para asegurarnos de que funcione como esperamos.

En el siguiente artículo trabajaremos con la capa de presentación donde aprenderemos cómo crear una aplicación responsiva y con soporte de múltiples idiomas.

Recuerda que puedes encontrar el código fuente en GitHub. Y no olvides visitar los otros artículos de esta serie de tutoriales:

Comparte este artículo