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 videotutorial es:
- Aprender cubit como gestor 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)
- Obtener una API key de https://newsapi.org/
Imágenes de la app
Inicio | Error |
---|---|
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.
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 inglés)
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';
()
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);
List<Object?> get props => [
title,
author,
description,
urlToImage,
content,
url,
];
}
Estamos utilizando el paquete JsonSerializable para generar el
código 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';
()
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 a 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 más 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();
Future<List<Article>> topHeadlines(String country) async {
return newsSource.topHeadlines(country);
}
}
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 a 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 a 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 {
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);
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);
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
([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 él 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 {
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);
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://');
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