Salta al contenuto principale

Testing

Per qualsiasi applicazione di media-grande scala è fondamentale testare l'applicazione.

Per testare con successo la nostra applicazione vorremo le seguenti cose:

  • Nessuno stato dovrebbe essere preservato tra test/testWidgets.
    Ciò significa che nessuno stato globale nell'applicazione o tutti gli stati globali devono essere ripristinati dopo ogni test.

  • Essere in grado di forzare i nostri provider ad avere uno stato specifico, mediante mocking o manipolandoli fino a raggiungere lo stato desiderato.

Vediamo caso per caso come Riverpod ti aiuta per i test.

Nessuno stato dovrebbe essere preservato tra test/testWidgets.

I provider sono generalmente dichiarati come variabili globali, questo potrebbe preoccuparti ed è comprensibile. Dopotutto, lo stato globale rende i test molto difficili in quanto può richiedere lunghe funzioni setUp/tearDown.

Ma la realtà è diversa: mentre i provider sono dichiarati come globali, lo stato di un provider non è globale.

Infatti, lo stato è salvato in un oggetto chiamato ProviderContainer, che potresti aver notato se hai visto gli esempi dart-only. Se non è così, ti basta sapere che questo oggetto è creato implicitamente da ProviderScope, il widget che abilita Riverpod nel nostro progetto.

Concretamente, ciò significa che due testWidgets che usano i provider non condividono nessuno stato. In quanto tale, non c'è bisogno di nessun metodo setUp/tearDown.

Ma un esempio è sempre meglio di lunghe spiegazioni:


// Un contatore implementato e testato usando Flutter

// Abbiamo dichiarato un provider globalmente e lo useremo in due test
// per vedere se lo stato si resetta correttamente a `0` tra i test.

final counterProvider = StateProvider((ref) => 0);

// Renderizza lo stato corrente e un bottone che incrementa lo stato
class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: Consumer(builder: (context, ref, _) {
final counter = ref.watch(counterProvider);
return ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('$counter'),
);
}),
);
}
}

void main() {
testWidgets('update the UI when incrementing the state', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));

// Il valore di default è `0`, come dichiarato nel provider
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// Incrementa lo stato e ri-renderizza
await tester.tap(find.byType(ElevatedButton));
await tester.pump();

// Lo stato si è incrementato correttamente
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
});

testWidgets('the counter state is not shared between tests', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));

// Lo stato è `0` di nuovo, con nessun metodo tearDown/setUp necessario
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}

Come puoi vedere, counterProvider è stato dichiarato come globale ma nessuno stato è stato condiviso tra i test. In quanto tale, non ci dobbiamo preoccuppare che i nostri test possano comportarsi in modo differente se eseguiti in una cartella diversa, poichè funzionano completamente isolati.

Sovrascrivere il comportamento di un provider durante i test.

Una comune applicazione reale può avere i seguenti oggetti:

  • Una classe Repository che fornisce una semplice e 'type-safe' API per effettuare richieste HTTP.

  • Un oggetto che gestisce lo stato dell'applicazione che usa Repository per effettuare richieste HTTP basate su diversi fattori. Questo oggetto potrebbe essere un ChangeNotifier, Bloc o anche un provider.

Usando Riverpod, può essere rappresentato in questo modo:


class Repository {
Future<List<Todo>> fetchTodos() async => [];
}

// Esponiamo la nostra istanza di Repository in un provider
final repositoryProvider = Provider((ref) => Repository());

/// La lista dei todo. Qua stiamo semplicemente richiedendo i todo dal server
/// usando [Repository] e non facendo altro.
final todoListProvider = FutureProvider((ref) async {
// Ottiene l'istanza di Repository
final repository = ref.watch(repositoryProvider);

// Otteniamo i todo e li esponiamo alla UI.
return repository.fetchTodos();
});

In questa situazione, quando facciamo un unit/widget test, tipicamente vogliamo sostituire la nostra istanza Repository con una falsa implementazione che ritorna una risposta pre-definita al posto di fare una reale richiesta HTTP.

Vogliamo poi che il nostro todoListProvider o l'equivalente utilizzi l'implementazione emulata (mocked) di Repository.

Per ottenere questo, possiamo usare il parametro overrides di ProviderScope/ProviderContainer per sovrascrivere il comportamento di repositoryProvider:


testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Sovrascrive il comportamento di repositoryProvider per restituire
// FakeRepository al posto di Repository.
repositoryProvider.overrideWithValue(FakeRepository())
// Non dobbiamo sovrascrivere `todoListProvider`,
// utilizzerà automaticamente il repositoryProvider sovrascritto
],
child: MyApp(),
),
);
});

Come puoi vedere dal codice evidenziato, ProviderScope/ProviderContainer permette di sostituire l'implementazione di un provider con un comportamento diverso.

info

Alcuni provider espongono modi semplificati per sovrascrivere il loro comportamento. Per esempio, FutureProvider permette di sovrascrivere il provider con un AsyncValue:


final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// Consente di sovrascrivere un FutureProvider per restituire un valore fisso
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
info

La sintassi per sovrascrivere un provider con il modificatore family è leggermente diversa.

Se hai usato un provider in questo modo:

final response = ref.watch(myProvider('12345'));

Puoi sovrascrivere il provider come:

myProvider('12345').overrideWithValue(...));

Esempio completo di full widget test

Riassumendo, di seguito l'intero codice per il nostro Flutter test.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

class Repository {
Future<List<Todo>> fetchTodos() async => [];
}

class Todo {
Todo({
required this.id,
required this.label,
required this.completed,
});

final String id;
final String label;
final bool completed;
}

// Esponiamo la nostra istanza di Repository in un provider
final repositoryProvider = Provider((ref) => Repository());

/// La lista dei todo. Qua stiamo semplicemente richiedendo i todo dal server
/// usando [Repository] e non facendo altro.
final todoListProvider = FutureProvider((ref) async {
// Ottiene l'istanza di Repository
final repository = ref.read(repositoryProvider);

// Otteniamo i todo e li esponiamo alla UI.
return repository.fetchTodos();
});

/// Un'implementazione simulata di Repository che restituisce una pre-definita
/// lista di todo.
class FakeRepository implements Repository {

Future<List<Todo>> fetchTodos() async {
return [
Todo(id: '42', label: 'Hello world', completed: false),
];
}
}

class TodoItem extends StatelessWidget {
const TodoItem({super.key, required this.todo});
final Todo todo;

Widget build(BuildContext context) {
return Text(todo.label);
}
}

void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [repositoryProvider.overrideWithValue(FakeRepository())],
// La nostra applicazione che leggerà da todoListProvider per mostrare la lista dei todo
// Puoi estrarre questo in un widget MyApp
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// La lista dei todo è in caricamento o in errore
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [for (final todo in todos.asData!.value) TodoItem(todo: todo)],
);
}),
),
),
),
);

// Il primo frame è uno stato di caricamento (loading).
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// Ri-renderizza. TodoListProvider dovrebbe aver finito di ottenere i todo in questo momento
await tester.pump();

// Fase di loading finita
expect(find.byType(CircularProgressIndicator), findsNothing);

// Renderizzato un TodoItem con il dato restituito da FakeRepository
expect(tester.widgetList(find.byType(TodoItem)), [
isA<TodoItem>()
.having((s) => s.todo.id, 'todo.id', '42')
.having((s) => s.todo.label, 'todo.label', 'Hello world')
.having((s) => s.todo.completed, 'todo.completed', false),
]);
});
}