본문으로 건너뛰기

테스트

중, 대규모 애플리케이션에서 테스트는 중요한 작업입니다.

우리의 앱을 성공적으로 테스트하기 위해서 아래의 몇가지 포인트를 생각해 보아야합니다.

  • test/testWidgets간 상태를 공유하지 않습니다. 이것은 글로벌 상태를 가지지 않은 앱 또는 모든 글로벌 상태들이 테스트 후에 초기화됨을 의미합니다.

  • 프로바이더에 특정한 상태를 가질 수 있도록 할 수 있는데, mocking 또는 조작을 통해 원하는 상태로 만들어 줄 수 있습니다.

Riverpod의 기능을 어떻게 활용할 수 있는지 하나씩 확인해봅시다.

testtestWidgets간 상태를 공유(보존)하지 않음.

당신은 프로바이더가 전역(글로벌)변수로서 선언되어 사용되기 때문에 이 부분을 걱정할 수 도 있습니다. 어쨋든, 전역 상태를 만들어 테스팅하는것은 setUp/tearDown이 많이 요구되기 때문에 매우 까다롭습니다.

그러나 실제로는 프로바이더가 전역(상태변수)로서 선언되는 동안 프로바이더의 상태는 전역이아닙니다.

대신에, 상태들은 ProviderContainer의 객체 이름을 가지고 저장됩니다. Dart만 사용하는 경우(dart-only)의 샘플 코드에서 ProviderContainer객체를 보셨을 수 있습니다. 만약 모르고 있었다면 ProviderContainer 객체는 암묵적으로 우리의 프로젝트에서 Riverpod을 사용하기위해 사용하는 위젯 ProviderScope에 의해 생성됩니다.

구체적으로, 상태가 글로벌(전역)이 아니라는 것은 프로바이더를 사용하는 2개의 testWidgets 간 상태는 공유되지 않습니다. 엄밀히 말해서 setUp/tearDown을 모두 필요로 하지 않습니다.

길게 설정하는것보다 예제로 확인해보도록 합시다.


// 플러터를 사용해 구현된 카운터 앱 테스트 하기

// provider를 전역변수로 선언하합니다. 그리고 만약 테스트간 상태가 `0`으로 초기화 되는것을
// 확인하기 위한 2개의 테스트를 실행해볼겁니다.
final counterProvider = StateProvider((ref) => 0);

// 현재의 상태값과 버튼을 누르면 상태가 값이 증가하는 기능을 가지는 화면을 생성합니다.
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()));

// 프로바이더에서 선언한 그대로 기본값은 `0`입니다.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);

// ElevatedButton를 찾아 탭을 수행하여 상태를 증가시킵니다.
// 그리고 다시 랜더링을 실행합니다.
await tester.tap(find.byType(ElevatedButton));
await tester.pump();

// 상태가 적절하게 증가되어 있는지 확인합니다.
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()));

// 상태가 공유되어있지 않기떄문에 다시 `0`값을 가집니다.
// tearDown/setUp 작업이 필요없습니다.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}

보았듯이 counterProvider가 전역상태번수로 선언되어 있음에도 테스트 간 상태는 공유되지 않습니다. 그래서 우리는 각각의 테스트가 독립된 환경에서 실행되지 때문에 실행순서에 따라 테스트 결과가 달라지는 것을 걱정하지 않아도 됩니다.

테스트 하는동안 프로바이더의 동작을 오버라이딩 하는 경우.

통상적인 현실세계의 애플리케이션은 아래와 같은 객체를 가지고 있다고 생각합니다.

A common real-world application may have the following objects:

  • 타입세이프(a type-safe)와 HTTP 요청 수행 기능을 제공하는 Repository 클래스를 가지고 있습니다.

  • 앱 상태를 관리하고 Repository를 사용해 다양한 조건을 기반으로 한 HTTP 요청을 수행하는 객체를 가지고 있습니다. 이것은 아마도 ChangeNotifier, Bloc 또는 프로바이더 일것입니다.

Riverpod을 사용하면 아래와 같이 표현 할 수 있습니다.


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

// 프로바이더안에 레포지토리의 인스턴스를 노출합니다.
final repositoryProvider = Provider((ref) => Repository());

/// Todo의 목록
/// 여기 [Repository]를 사용하여 서버로부터 값을 가져오고 있습니다.
final todoListProvider = FutureProvider((ref) async {
// Repository 인스턴스를 생성합니다.
final repository = ref.watch(repositoryProvider);

// Todo 목록을 취득하고 UI에 노출시킵니다.
return repository.fetchTodos();
});

이 상황에서는 a unit/widget test를 만들때, Repository 인스턴스를 실제 HTTP요청 대신에 사전 정의된 응답을 반환하는 fake 구현 레포지토리로 대체합니다. todoListProvider 또는 Repository의 구현된 mock과 동등한 객체를 사용합니다.

이를 달성하기위해서, repositoryProvider의 행위를 오버라이드 하기위해 ProviderScope/ProviderContaineroverrides 파라미터를 사용할 수 있습니다.


testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// repositoryProvider의 행위를 오버라이드하여
// Repository 대신 FakeRepository를 반환합니다.
repositoryProvider.overrideWithValue(FakeRepository())
// 오버라이드된 repositoryProvider를 자동적으로 사용하기 때문에
// `todoListProvider`를 override하지 않아도 됩니다.
],
child: MyApp(),
),
);
});

하이라이트된 코드를 확인해보면, ProviderScope/ProviderContainer는 다른 동작을 하는 프로바이더의 구현체로 교체할 수 있는것을 허용합니다.

정보

프로바이더에 따라 프로바이더가 가지는 동작을 오버라이드하기 위해 간단한 방법을 노출합니다. 예를 들어 FutureProviderAsyncValue와 함께 프로바이더를 오버라이딩 할 수 있습니다.


final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// 고정된 값을 반환하기 위해 FutureProvider를 오버라이딩 합니다.
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
정보

프로바이더를 오버라이딩하기 위해 사용하는 family 수식자와 구문이 조금 차이가 있습니다.

만약 아래와 같이 프로바이더를 사용한다면

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

아래와 같이 프로바이더를 오버라이드할 수 있습니다.

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

모든 위젯 테스트 예제 Full widget test example

마지막으로 위의 내용을 정리한 모든 테스트 코드를 확인해봅시다.

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;
}

// Repository를 사용하는 프로바이더 인스턴스
final repositoryProvider = Provider((ref) => Repository());

/// 할일(Todo) 목록
/// [Repository]를 사용하여 서버로부터 값을 취득하는 FutureProvider 인스턴스
final todoListProvider = FutureProvider((ref) async {
// Repository 인스턴스를 취득합니다.
final repository = ref.read(repositoryProvider);

// Todo 목록을 가져오고 이를 UI에 노출시킵니다.
return repository.fetchTodos();
});

/// 레포지토리의 Mock 구현: 사전 정의된 할일 목록을 반환하는 역할
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())],
// todoListProvider로부터 상태 값을 읽어 todo 목록을 표시하는 앱
// MyApp 위젯으로도 가능.
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// 할일 목록이 로딩 중이거나 에러가 발생했을 때의 대응
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);

// 처음 프레임 상태가 로딩중임을 확인.
expect(find.byType(CircularProgressIndicator), findsOneWidget);

// 재 렌더링을 수행
// (TodoListProvider가 할일 목록을 가져오기를 끝냈을 것이라 예상)
await tester.pump();

// CircularProgressIndicator을 찾아 loading 상태인지 확인 .
expect(find.byType(CircularProgressIndicator), findsNothing);

// FakeRepository가 반환한 값이 1개의 TodoItem으로 렌더링되었는지 확인.
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),
]);
});
}