En este artículo vamos a crear una aplicación extremadamente simple para android devices pero siguiendo la estrategia de diseño guiado por el comportamiento Behavior Driven Development
Tomaremos la aplicación por defecto que nos proporciona flutter (push counter) modificando su comportamiento para que el contador consuma un servicio de conteo centralizado. Por simplicidad solo vamos a trabajar en la capa cliente de la aplicación (sin entrar en detalles de la construcción del servicio).
- En el ejemplo iremos tomando algunas decisiones que por motivos de practicidad no aclaramos el fundamento detrás de tal decisión. En tal caso cualquier comentario, pregunta o recomendación será muy bien bienvenida 😁
- Instalar flutter
- Vamos a utilizar Android Studio como entorno de desarrollo integrado.
mkdir flutter-bdd
cd flutter-bdd
flutter create --template=app --platforms android --project-name flutter_bdd --org com.example .
flutter emulators
flutter emulators --launch Pixel_4_API_30
flutter run
- Agregamos el paquete bdd_widget_test
flutter pub add bdd_widget_test --dev
- Agregamos el paquete build_runner como dependencia de desarrollo
flutter pub add build_runner --dev
- Eliminamos el test por defecto test/widget_test.dart
En BDD se abordan los casos de uso de la aplicación o interacciones del usuario con la aplicación con ejemplos que se denominan escenarios. Las características y escenarios se describen mediante el uso de un lenguaje natural de especificación denominado Gherkin
A continuación vamos a crear la característica "Contador" con el escenario inicial de la aplicación.
test/counter.feature:
Feature: Counter
Scenario: Show initial counter value
Given the app is running
Given counter value is {10}
Then I see {10} value
En lenguaje coloquial estamos diciendo que cuando ejecutemos la aplicación y el servicio contador tenga un valor 10 entonces mostraremos este valor en la aplicación.
Ahora que tenemos especificada la primer iteración de nuestra aplicación. Vamos a generar el test de integración asociado que nos "guíe" en la construcción.
- Generamos el test (y pasos) asociados a nuestro primer escenario mediante la ejecución del siguiente comando
flutter pub run build_runner build --delete-conflicting-outputs
Lo anterior nos genera un test de widget test/counter_test.dart con los pasos asociados a la ejecución del test. Los pasos de la ejecución del test son generados en la carpeta test/step/
test/step/the_app_is_running.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bdd/main.dart';
Future<void> theAppIsRunning(WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
}
test/step/counter_value_is.dart
import 'package:flutter_test/flutter_test.dart';
Future<void> counterValueIs(WidgetTester tester, int counterValue) async {}
test/step/i_see_value.dart
import 'package:flutter_test/flutter_test.dart';
Future<void> iSeeValue(WidgetTester tester, int counterValue) async {
await tester.pump();
expect(find.text(counterValue.toString()), findsOneWidget);
}
flutter test
Veremos que el test falla porque no encuentra el valor '10'
00:02 +0: Counter Show initial counter value
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching node in the widget tree
Actual: _TextFinder:<zero widgets with text "10" (ignoring offstage widgets)>
Which: means none were found but one was expected
Modificamos el valor inicial de _counter en el estado del widget MyHomePage
lib/main.dart
class _MyHomePageState extends State<MyHomePage> {
int _counter = 10;
Y veremos que ahora el test pasa
- Vamos a separar los tres componentes principales cada uno en su archivo
lib/my_home_page.dart
import 'package:flutter/material.dart';
class _MyHomePageState extends State<MyHomePage> {
final int _counter = 10;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Como paso siguiente vamos a eliminar el código duro del valor inicial 10 en nuestro HomeState. Para ello necesitaremos de un colaborador que nos indique cúal es el valor inicial del contador.
La comunicación entre vistas y modelo la vamos a efectuar siguiendo el patrón MVVM, motivo por el que vamos a crear un view model asociado a la vista.
Para poder acceder a la instancia del view model vamos a utilizar IoC (Inversión de control) mediante el paquete qinject
flutter pub add qinject
lib/my_home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bdd/my_home_page_view_model.dart';
import 'package:provider/provider.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late MyHomePageViewModel _viewModel;
@override
void initState() {
_viewModel = Provider.of<MyHomePageViewModel>(context);
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'${_viewModel.counterValue}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
lib/my_home_page_view_model.dart
class MyHomePageViewModel {
int get counterValue => 0;
}
En BDD vamos identificando necesidades del usuario de la aplicación, automatizamos este escenario y luego construímos la lógica asociada utilizando TDD. Esto puede ser visto como dos ciclos de desarrollo bien definidos como se muestra en la siguiente imagen:
En nuestro ejemplo luego de haber implementado el primer escenario deberíamos continuar implementando el view model, servicios, repositorios, servidor, etc.
Por simplicidad solo nos vamos a limitar a mostrar el ciclo de BDD y vamos a dejar de lado la implementación end to end (de extremo a extremo) de la aplicación.
Incluso vale aclarar que en general la técnica BDD se utiliza con test de aceptación (pruebas sobre la aplicación real) y en nuestro caso lo hemos aplicado con test unitarios de widgets.
Dicho lo anterior vamos a continuar con nuestro segundo escenario de uso lo cual nos permitirá seguir identificando necesidades de nuestra aplicación.
test/counter.feature
Feature: Counter
Scenario: Show initial counter value
Given counter value is {10}
Given the app is running
Then I see {10} value
Scenario: Tap add button
Given counter value is {5}
When tap add button
Then I see {6} value
flutter pub run build_runner build --delete-conflicting-outputs
flutter test
test/step/tap_add_button.dart
En el paso "tap add button" tendremos que verificar que se informe al view model que se ha tapeado el botón de incremento. Simular el valor de retorno del valor del contador (incrementado en 1). Por último la vista deberá ser informada de que el valor del contador ha cambiado para poder redibujarse.
import 'package:flutter_bdd/my_home_page_view_model.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:qinject/qinject.dart';
Future<void> tapAddButton(WidgetTester tester) async {
await tester.tap(find.byTooltip('Increment'));
var mockViewModel = Qinject.use<dynamic, MyHomePageViewModel>();
verify(mockViewModel.onAddButtonTapped()).called(1);
mockViewModel.counterValue.value = mockViewModel.counterValue.value + 1;
mockViewModel.counterValue.notifyListeners();
await tester.pumpAndSettle();
}
Para poder efectuar lo anterior tendremos que modificar "counterValue" en el view model para que pueda informar sus cambios de valores a la vista.
import 'package:flutter/foundation.dart';
class MyHomePageViewModel {
ValueNotifier<int> get counterValue => ValueNotifier(0);
onAddButtonTapped() {}
}
Finalmente modificamos la vista para poder responder a los cambios de valor en el contador e informar al view model cuando se presione el botón "Increment"
class _MyHomePageState extends State<MyHomePage> {
late MyHomePageViewModel _viewModel;
@override
void initState() {
_viewModel = qinjector.use();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
_counterValue(context),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _viewModel.onAddButtonTapped,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
Widget _counterValue(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: _viewModel.counterValue,
builder: (context, value, _) => Text(
'$value',
style: Theme.of(context).textTheme.headline4,
),
);
}
}