Arquitectura y pruebas en Flutter usando cubit

Hola, este artículo es la introducción de una aplicación muy sencilla que realice en una presentación para la comunidad de Flutter Taiwán en Inglés. Pueden ver paso a paso la implementación en este video de youtube y el código fuente lo pueden descargar de github. Nota: El video está en Inglés

El propósito del video tutorial es:

  • Aprender cubit como manejador de estados
  • Crear una arquitectura sencilla que sea escalable y con la que podamos agregar pruebas unitarias, pruebas de widgets, etc.
  • Hacer peticiones a una Rest APi usando http
  • Inyección de dependencias con GetIt (no confundir con GetX)

Prerrequisitos (Opcional)

Imágenes de la app

InicioError

Arquitectura

La arquitectura es muy similar a la que se puede encontrar en la documentación de Flutter Bloc

Como se puede ver vamos a separar la implementación en 3 capas:

  • Capa de datos (Data Layer): Está a su vez se divide en dos que son:

    • Fuentes de datos (Data Sources): Se encarga de conectarse directamente con las fuentes de datos externas. Pueden ser una API o una base de datos.
    • Repositorios (Repository): Se encarga de combinar los datos que provengan de diferentes fuentes de datos y enviarlos a nuestro Cubit.
  • Bloc/Cubit: Aquí es donde vamos a escribir un poco de la lógica de negocios y también manejar el estado de nuestros widgets.

  • Capa de presentación (Presentation Layer): Aquí es donde están todos nuestros widgets y normalmente es lo que el usuario final puede ver.

Nota

En este tutorial no hacemos la parte en rojo, que es conectarnos con una base de datos local para guardar noticias y poderlas ver cuando no tengamos conexión

Video en YouTube (en ingles)

Fuentes de datos (newsapi.org)

Vamos a utilizar la API de Las últimas noticias de News API como fuentes de datos externa. En la documentación podemos ver que al llamar la API esta regresa un JSON como este:

{
  "status": "ok",
  "totalResults": 2,
  "articles": [
    {
      "source": {
        "id": "cbs-news",
        "name": "CBS News"
      },
      "author": "Sophie Lewis",
      "title": "\"Bachelor\" host Chris Harrison is temporarily \"stepping aside\" after \"excusing historical racism\" - CBS News",
      "description": "\"By excusing historical racism, I defended it,\" Harrison wrote, announcing he will not host the \"After the Final Rose\" live special.",
      "url": "https://www.cbsnews.com/news/chris-harrison-the-bachelor-stepping-aside-comments-rachel-lindsay-backlash/",
      "urlToImage": "https://cbsnews1.cbsistatic.com/hub/i/r/2021/02/12/0cf2794b-2b04-4ced-84ce-1d72e26ed2cb/thumbnail/1200x630/8d464cc0fdf23566bbea2e01c41401ab/gettyimages-1204035255.jpg",
      "publishedAt": "2021-02-14T12:18:00Z",
      "content": "Chris Harrison, the longtime host of \"The Bachelor\" and \"The Bachelorette,\" announced Saturday that he is \"stepping aside\" from the franchise for an undisclosed \"period of time.\" The bombshell announ… [+7754 chars]"
    },
    {
      "source": {
        "id": null,
        "name": "KOCO Oklahoma City"
      },
      "author": "KOCO Staff",
      "title": "Winter storm brings heavy snow, causing hazardous driving conditions across Oklahoma - KOCO Oklahoma City",
      "description": "A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions.",
      "url": "https://www.koco.com/article/winter-storm-brings-heavy-snow-causing-hazardous-driving-conditions-across-oklahoma/35500508",
      "urlToImage": "https://kubrick.htvapps.com/htv-prod-media.s3.amazonaws.com/images/poster-image-2021-02-14t060232-115-1613304161.jpg?crop=1.00xw:1.00xh;0,0&resize=1200:*",
      "publishedAt": "2021-02-14T12:17:00Z",
      "content": "OKLAHOMA CITY —A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions. \r\n[Check latest weather alerts in your area | Check live traffic condi… [+2381 chars]"
    }
  ]
}

Al analizar el JSON podemos notar que necesitamos dos clases para modelar la respuesta. La primera clase la llamaré Article y va a representar todo lo que está dentro de la lista articles:

import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';

part 'article.g.dart';

@JsonSerializable()
class Article extends Equatable {
  final String title;
  final String? author;
  final String? description;
  final String? urlToImage;
  final String? content;
  final String url;

  Article({
    required this.title,
    required this.url,
    this.author,
    this.urlToImage,
    this.description,
    this.content,
  });

  factory Article.fromJson(Map<String, dynamic> json) =>
      _$ArticleFromJson(json);

  Map<String, dynamic> toJson() => _$ArticleToJson(this);

  @override
  List<Object?> get props => [
        title,
        author,
        description,
        urlToImage,
        content,
        url,
      ];
}

Nota

Estamos utilizando el paquete JsonSerializable para generar el codigo que nos ayudara a convertir el JSON a un objeto de la clase Article

La segunda clase se va llamar ApiResponse y nos ayudará a representar el resto del JSON:

import 'package:flutter_bloc_architecture/src/model/article.dart';
import 'package:json_annotation/json_annotation.dart';

part 'api_response.g.dart';

@JsonSerializable()
class ApiResponse {
  late final String status;
  late final String? code;
  late final List<Article>? articles;

  ApiResponse();

  factory ApiResponse.fromJson(Map<String, dynamic> json) => _$ApiResponseFromJson(json);

  Map<String, dynamic> toJson() => _$ApiResponseToJson(this);
}

Una vez que definimos cómo vamos a representar el JSON en objetos de Dart podemos continuar con la clase que se va encargar de hacer las llamadas a la API.

Capa de datos: NewsSource y sus pruebas unitarias

En esta clase vamos a utilizar el paquete de http para hacer llamadas a la API.

import 'dart:convert';

import 'package:flutter_bloc_architecture/src/data_source/response/api_response.dart';
import 'package:flutter_bloc_architecture/src/model/article.dart';
import 'package:http/http.dart' as http;

class NewsSource {
  // Visita la pagina de https://newsapi.org/ para obtener tu propio API key
  static const String _apiKey = 'xxxxxxx';

  // Constantes para definir el enlace que vamos a llamar
  static const String _baseUrl = 'newsapi.org';
  static const String _topHeadlines = '/v2/top-headlines';

  // El cliente http que se encargará de llamar a la API. 
  final http.Client _httpClient;

  // También podemos pasar por el constructor un http.Client 
  // de esta forma podemos pasar un MockClient que nos 
  // ayudará a simular las API a la hora de hacer pruebas unitarias
  NewsSource({http.Client? httpClient})
      : _httpClient = httpClient ?? http.Client();

  // Función para llamar a la API de últimas noticias
  Future<List<Article>> topHeadlines(String country) async {
    final result = await _callGetApi(
      endpoint: _topHeadlines,
      params: {
        'country': country,
        'apiKey': _apiKey,
      },
    );
    return result.articles!;
  }

  // Función de ayuda para llamar las API's de news.org.
  Future<ApiResponse> _callGetApi({
    required String endpoint,
    required Map<String, String> params,
  }) async {
    var uri = Uri.https(_baseUrl, endpoint, params);

    // Llamamos a la API y convertimos el resultado a un objeto
    // de tipo ApiResponse
    final response = await _httpClient.get(uri);
    final result = ApiResponse.fromJson((json.decode(response.body)));
    
    // Si hay error lanzamos una excepción personalizada de lo contrario
    // solo regresamos el objeto de tipo ApiResponse
    if (result.status == 'error') {
      if (result.code == 'apiKeyMissing') throw MissingApiKeyException();
      if (result.code == 'apiKeyInvalid') throw ApiKeyInvalidException();
      throw Exception();
    }
    return result;
  }
}

// Definimos algunas excepciones personalizadas para demostrar
// como podemos manejarlas dentro de nuestro cubit
class MissingApiKeyException implements Exception {}
class ApiKeyInvalidException implements Exception {}

Para las pruebas unitarias vamos a crear tres archivos .json que van a contener respuestas simuladas de la API. Con estas respuestas simuladas vamos a probar que la respuesta sea exitosa y que las excepciones personalizadas sean lanzadas correctamente.

Los primeros dos simulan la respuesta cuando la API key no es válida o cuando no la enviamos en la petición.

{
  "status": "error",
  "code": "apiKeyInvalid",
  "message": "Your API key is invalid or incorrect. Check your key, or go to https://newsapi.org to create a free API key."
}
{
  "status": "error",
  "code": "apiKeyMissing",
  "message": "Your API key is missing. Append this to the URL with the apiKey param, or use the x-api-key HTTP header."
}

Y el tercero simula la respuesta exitosa de la API:

{
  "status": "ok",
  "totalResults": 2,
  "articles": [
    {
      "source": {
        "id": "cbs-news",
        "name": "CBS News"
      },
      "author": "Sophie Lewis",
      "title": "\"Bachelor\" host Chris Harrison is temporarily \"stepping aside\" after \"excusing historical racism\" - CBS News",
      "description": "\"By excusing historical racism, I defended it,\" Harrison wrote, announcing he will not host the \"After the Final Rose\" live special.",
      "url": "https://www.cbsnews.com/news/chris-harrison-the-bachelor-stepping-aside-comments-rachel-lindsay-backlash/",
      "urlToImage": "https://cbsnews1.cbsistatic.com/hub/i/r/2021/02/12/0cf2794b-2b04-4ced-84ce-1d72e26ed2cb/thumbnail/1200x630/8d464cc0fdf23566bbea2e01c41401ab/gettyimages-1204035255.jpg",
      "publishedAt": "2021-02-14T12:18:00Z",
      "content": "Chris Harrison, the longtime host of \"The Bachelor\" and \"The Bachelorette,\" announced Saturday that he is \"stepping aside\" from the franchise for an undisclosed \"period of time.\" The bombshell announ… [+7754 chars]"
    },
    {
      "source": {
        "id": null,
        "name": "KOCO Oklahoma City"
      },
      "author": "KOCO Staff",
      "title": "Winter storm brings heavy snow, causing hazardous driving conditions across Oklahoma - KOCO Oklahoma City",
      "description": "A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions.",
      "url": "https://www.koco.com/article/winter-storm-brings-heavy-snow-causing-hazardous-driving-conditions-across-oklahoma/35500508",
      "urlToImage": "https://kubrick.htvapps.com/htv-prod-media.s3.amazonaws.com/images/poster-image-2021-02-14t060232-115-1613304161.jpg?crop=1.00xw:1.00xh;0,0&resize=1200:*",
      "publishedAt": "2021-02-14T12:17:00Z",
      "content": "OKLAHOMA CITY —A winter storm moving across Oklahoma Sunday morning is dumping heavy snow, causing hazardous driving conditions. \r\n[Check latest weather alerts in your area | Check live traffic condi… [+2381 chars]"
    }
  ]
}

Ahora si podemos crear el archivo news_source_test.dart donde vamos a escribir las pruebas necesarias.

import 'dart:io';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc_architecture/src/data_source/news_source.dart';

final headers = {
  HttpHeaders.contentTypeHeader: 'application/json; charset=utf-8'
};

// Creamos un objeto NewsSource al que le inyectamos un MockClient
NewsSource _getDataSource(String filePath) =>
    NewsSource(httpClient: _getMockProvider(filePath));

// Al crear el MockClient le pasamos la ruta del archivo .json que vamos 
// a usar en nuestra prueba unitaria
MockClient _getMockProvider(String filePath) => MockClient((_) async =>
    Response(await File(filePath).readAsString(), 200, headers: headers));

void main() {
  test('Top headlines response is correct', () async {
    final newsSource =
        _getDataSource('test/data_source_test/top_headlines.json');
    final articles = await newsSource.topHeadlines('us');

    expect(articles.length, 2);
    expect(articles[0].author, 'Sophie Lewis');
    expect(articles[1].author, 'KOCO Staff');
  });

  test('Api key missing exception is thrown correctly', () async {
    final newsSource = _getDataSource('test/data_source_test/api_key_missing.json');
    expect(newsSource.topHeadlines('mx'), throwsA(predicate((exception) => exception is MissingApiKeyException)));
  });

  test('Invalid Api key exception is thrown correctly', () async {
    final newsSource = _getDataSource('test/data_source_test/api_key_invalid.json');
    expect(newsSource.topHeadlines('mx'), throwsA(predicate((exception) => exception is ApiKeyInvalidException)));
  });
}

Capa de datos: Repository

El repositorio se encarga de combinar los datos y enviarlos al cubit cuando tenemos mas de una fuente de datos. En este ejemplo solo tenemos una fuente de datos por lo que el repositorio solo llamara a la clase NewsSource.

Primero creamos una clase abstracta NewsRepository que sirve para no crear una dependencia con los detalles de una implementación en específico. Esto nos puede ayudar a crear pruebas unitarias creando una implementación con valores simulados.

abstract class NewsRepository {
  Future<List<Article>> topHeadlines(String country);
}

Ahora crearemos la clase NewsRepositoryImp que contiene los detalles específicos de la implementación y tiene dependencia en la clase NewsSource

class NewsRepositoryImp extends NewsRepository {

  // Obtiene el objeto NewsSource que hemos inyectado anteriormente
  final NewsSource newsSource = getIt.get();

  @override
  Future<List<Article>> topHeadlines(String country) async {
    return newsSource.topHeadlines(country);
  }
}

Nota

En el repositorio estamos utilizando el paquete GetIt para obtener una referencia a la clase NewsSource ¿Cómo funciona GetIt?

Inyección de dependencias con GetIt

Voy a crear un archivo dependency_injection.dart donde vamos inicializar GetIt y a crear una función de ayuda para inyectar NewsSource y NewsRepository:

final getIt = GetIt.instance;

Future<void> injectDependencies() async {

  // Data sources
  getIt.registerLazySingleton<NewsSource>(() => NewsSource());

  // Repositories
  getIt.registerLazySingleton<NewsRepository>(() => NewsRepositoryImp());
}

Ahora en la función main() inyectamos las dependencias así:

void main() async {

  // Inyectamos las dependencias
  await injectDependencies();

  runApp(
    MaterialApp(
      home: TopNews(),
    ),
  );
}

Cubit: NewsCubit y sus pruebas unitarias

La clase NewsCubit se va encargar de mantener el estado, llamar a la clase News Repository para obtener las últimas noticias, manejar los errores y emitir el estado correspondiente.

Comenzamos definiendo todos los estados que pueda tener nuestro cubit.

// Estado padre, todos los estados heredan de él
abstract class NewsState extends Equatable {
  @override
  List<Object> get props => [];
}

// Estado inicial: Todavía no se llama la API
class NewsInitialState extends NewsState {}

// Estado de carga: En este momento estamos llamado a la API
class NewsLoadingState extends NewsState {}

// Estado completo: La API respondió exitosamente con un listado de noticias
class NewsLoadCompleteState extends NewsState {
  final List<Article> news;

  NewsLoadCompleteState(this.news);

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

// Estado de error: Hubo un error y mostraremos el mensaje en la UI
class NewsErrorState extends NewsState {
  final String message;

  NewsErrorState(this.message);

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

Cuando ya definimos los estados posibles creamos la clase NewsCubit:

class NewsCubit extends Cubit<NewsState> {
  // Obtenemos el repositorio que inyectamos anteriormente
  final NewsRepository _repository = getIt.get();

  NewsCubit() : super(NewsInitialState());

  Future<void> loadTopNews({String country = 'tw'}) async {
    try {
      // Antes de llamar a la API ponemos el estado en cargando
      emit(NewsLoadingState());

      // Llamamos a la API
      final news = await _repository.topHeadlines(country);

      // Si no hubo ningún error emitimos el estado completo que contiene
      // la lista de noticias.
      emit(NewsLoadCompleteState(news));
    } on Exception catch (e) {
      // En caso de error, checamos el tipo y emitimos el estado de error
      // con el mensaje describiendo el error
      if (e is MissingApiKeyException) {
        emit(NewsErrorState('Please check the API key'));
      } else if (e is ApiKeyInvalidException) {
        emit(NewsErrorState('The API key is incorrect'));
      } else {
        emit(NewsErrorState('Unknown error'));
      }
    }
  }
}

Para las pruebas unitarias vamos a usar el paquete de Mockito para generar un mock de NewsRepository el que vamos a llamar MockNewsRepository y lo vamos a inyectar en lugar de la implementación real. El código sería:

// Usamos mockito para generar un mock del repositorio
@GenerateMocks([NewsRepository])
void main() {
  late MockNewsRepository mockRepo;
  setUp(() async {
    await getIt.reset();
    mockRepo = MockNewsRepository();
    // Inyectamos el mock
    getIt.registerLazySingleton<NewsRepository>(() => mockRepo);
  });

  // Este objeto vamos a regresar cuando la API de las últimas noticias 
  // sea llamada	
  final article = Article(title: "Tutorial", author: "Yayo", url: 'https://');

  blocTest<NewsCubit, NewsState>(
    'News will be loaded correctly',
    build: () {
      // Cuando llamamos la API de las últimas noticias regresamos una lista
      // que contiene el artículo que creamos anteriormente
      when(mockRepo.topHeadlines(any)).thenAnswer((_) async => [article]);
      return NewsCubit();
    },
    act: (cubit) async => cubit.loadTopNews(),
    expect: () => [
      NewsLoadingState(),
      NewsLoadCompleteState([article])
    ],
  );

  blocTest<NewsCubit, NewsState>(
    'When the Api key is not valid exception is handled correctly',
    build: () {
       // Cuando llamamos la API de las últimas noticias
      // lanzamos una excepción
      when(mockRepo.topHeadlines(any))
          .thenAnswer((_) async => throw ApiKeyInvalidException());
      return NewsCubit();
    },
    act: (cubit) async => cubit.loadTopNews(),
    expect: () => [
      NewsLoadingState(),
      NewsErrorState('The API key is incorrect'),
    ],
  );
}

Capa de presentación: TopNews y sus pruebas de widgets WidgetTest)

Ahora vamos a crear la interfaz de usuario donde utilizaremos las clases BlocProvider para inyectar el NewsCubit al árbol de widgets y la clase BlocBuilder para mostrar la interfaz de usuario dependiendo del estado actual del cubit:

class TopNews extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Inyectamos el NewsCubit al árbol de widgets y llamamos la API de las últimas noticias
    return BlocProvider(
      create: (context) => NewsCubit()..loadTopNews(),
      child: Scaffold(
        appBar: AppBar(
          title: Text('Top news'),
        ),
        body: BlocBuilder<NewsCubit, NewsState>(builder: (context, state) {
          if (state is NewsLoadCompleteState) {
            // Si el estado es complete dibujamos una lista de noticias
            return ListView.builder(
              itemCount: state.news.length,
              itemBuilder: (context, int index) {
                return _ListItem(article: state.news[index]);
              },
            );
          } else if (state is NewsErrorState) {
            // Si el estado es error mostramos el mensaje del error
            return Text(state.message);
          }
          // Cualquier otro estamo mostramos un ProgressIndicator
          return Center(child: CircularProgressIndicator());
        }),
      ),
    );
  }
}

class _ListItem extends StatelessWidget {
  final Article article;

  const _ListItem({
    Key? key,
    required this.article,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      // Al hacer tap abrimos la url de la noticia.
      onTap: () => launch(article.url),
      child: Card(
        margin: EdgeInsets.all(8),
        child: Column(
          children: [
            article.urlToImage == null
                ? Container(color: Colors.red, height: 250)
                : CachedNetworkImage(
                    imageUrl: article.urlToImage!,
                    placeholder: (context, url) => CircularProgressIndicator(),
                    errorWidget: (context, url, error) => Icon(Icons.error),
                  ),
            Text(
              '${article.title}',
              maxLines: 1,
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
            ),
            SizedBox(height: 8),
            Text('${article.description}', maxLines: 3),
            SizedBox(height: 16),
          ],
        ),
      ),
    );
  }
}

Como estamos trabajando con una aplicación muy sencilla, las pruebas de widgets también serán muy simples. Para demostrar cómo podemos crear nuestra propia clase simulada de NewsRepository en las pruebas de widgets no vamos a utilizar mockito.

Creamos la clase MyOwnMockRepository de la siguiente forma:

// Siempre que se llame las últimas noticias de esta clase va regresar una lista con 2 noticias
class MyOwnMockRepository extends NewsRepository {
  final article1 = Article(title: "Tutorial 1", author: "Yayo", url: 'https://');
  final article2 = Article(title: "Tutorial 2", author: "Carlos", url: 'https://');

  @override
  Future<List<Article>> topHeadlines(String country) async {
    return [article1, article2];
  }
}

Después inyectamos la clase MyOwnMockRepository y podemos crear un widget test para saber si se están mostrando correctamente los dos artículos en la interfaz de usuario:


void main() {
  setUp(() async {
    await getIt.reset();
    getIt.registerLazySingleton<NewsRepository>(() => MyOwnMockRepository());
  });

  testWidgets('News screens load correctly', (WidgetTester tester) async {
    // Agregamos el MaterialApp y el widget the TopNews
    await tester.pumpWidget(
      MaterialApp(
        home: TopNews(),
      ),
    );
    await tester.pumpAndSettle();
    
    // Como el repositorio regresa dos noticias, nos aseguramos que estas noticias se
    // muestren correctamente en la UI
    expect(find.text('Tutorial 1'), findsOneWidget);
    expect(find.text('Tutorial 2'), findsOneWidget);
  });
}

Otras pruebas que podemos hacer es que cuando se lance una excepción el mensaje se muestre correctamente. Pero esta prueba la pueden hacer ustedes como tarea.

Conclusión

En este artículo aprendimos muchas cosas sobre Flutter, Cubit, Pruebas de widgets, Pruebas unitarias, Inyección de dependencias, etc. Espero que les haya gustado el artículo y recuerden que el código fuente lo pueden descargar de github

Comparte este artículo