[Flutter] Testing with Riverpod

leeeeeoy·2021년 12월 27일
5

Testing with Flutter

어쩌면 개발하는데 가장 중요한 부분 중에 하나가 바로 Testing이 아닐까 싶다. Flutter에서 Testing은 크게 3개 종류로 나뉘는데, 단위 테스트, 위젯 테스트, 통합 테스트가 있다. 각각의 테스트를 하는 방법과 실제 기기를 이용한 테스트까지 정리해보았다.

그동안 진행했던 것들을 되돌아보면 사실 제대로 된 테스트를 작성한 적이 없었다. 시간에 쫒기거나 너무 복잡한 위젯 트리 때문에 생각조차 잘 들지 않았던 것 같다(반성하자 정말로...). 위젯, 통합 테스트는 물론이고, 단위 테스트 조차 몇 번 작성하다가 말았다. 정리한 김에 테스트 작성하는 방법을 다시 익히려고 한다.

Package 설정

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod:
  freezed_annotation:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
    
  mockito:
  freezed:
  build_runner:
  • intergration_test: 통합 테스트를 진행하는데 필요하다
  • mockito: test를 지원해주는 package 중 하나로, 이 글에서는 listener 구현에 사용했다.

Flutter Testings

Flutter의 테스트는 크게 3가지가 있다. 단위, 위젯, 통합 테스트인데 각각 테스트 단위에 따라 조금씩 차이가 있다.

1. Unit Test

주로 함수나 클래스 단위를 테스트 하는 방법이다. 테스트의 단위가 작은 만큼 실행속도가 빠르고 각 테스트 별 종속성이 낮다는 장점이 있지만 그만큼 신뢰성은 조금 떨어진다.

2. Widget Test

위젯 단위로 테스트 하는 방법이다. 작성한 위젯이 화면에 제대로 보여지는지, 특정 동작을 했을 때 서로 상호작용을 하는지 확인한다. Unit Test에 비해 의존성이 높지만 그만큼 신뢰성이 높다. 아마 가장 많이 사용하게 될 Test 단위이지 않나 싶다.

3. Intergration Test

전체 앱을 테스트 하는 방법이다. 통합 테스트에서는 단위 테스트, 위젯 테스트를 포함한 앱의 모든 동작이 원하는대로 작동하는지를 목표로 한다. 실제 앱이 동작하면서 테스트가 진행되기 때문에 성능 역시 확인 할 수 있다. 당연히 가장 신뢰성이 높은 테스트이다.


Test 코드 작성해보기

역시 이해는 코드를 작성해야 빠르기 때문에, 간단한 CRUD 앱을 통해 테스트를 작성해보겠다.
Riverpod와 Freezed를 사용하여 상태관리를 진행했다.


1. Unit Test 작성

CRUD 앱이기 때문에 당연하게 CRUD가 잘 되는지 확인을 해야된다. 이를 위해서 먼저 StateNotifier와 모델을 작성한다.

note.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'note.freezed.dart';


class Note with _$Note {
  factory Note({
    required int id,
    required String title,
    required String body,
  }) = _Note;
}


class NoteList with _$NoteList {
  factory NoteList({required List<Note> notes}) = _NoteList;
}

freezed를 이용하여 Note와 NoteList를 작성해주었다.

note_state.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_test/app/model/note.dart';

class NoteState extends StateNotifier<NoteList> {
  NoteState() : super(NoteList(notes: []));

  int _autoIncrementId = 0;

  void createNote({required String title, required String body}) {
    state = state.copyWith(
      notes: [
        ...state.notes,
        Note(
          id: _autoIncrementId++,
          title: title,
          body: body,
        ),
      ],
    );
  }

  void deleteNote(int id) {
    state = state.copyWith(
        notes: state.notes.where((element) => element.id != id).toList());
  }

  void updateNote({required int id, String? title, String? body}) {
    state = state.copyWith(
        notes: state.notes
            .map((e) => e.id == id
                ? e.copyWith(title: title ?? e.title, body: body ?? e.body)
                : e)
            .toList());
  }
}

CRUD기능을 담당할 StateNotifier다. 처음 생성 시 빈 목록을 생성하고, 그 이후 생성, 삭제, 수정이 가능하도록 각 메서드를 작성했다. 바로 테스트를 작성해보자!


Riverpod에서의 Test

riverpod의 경우 Flutter에 의존하고 있지 않기 때문에 dart 언어 수준에서 테스트가 가능하다!
riverpod에서 Provider는 보통 전역변수로 선언하게 된다. 테스트를 할 때 중요한 점 중 하나는 각 테스트 간에 Provider는 공유되지 않는다는 것이다. 즉 1번 테스트에서 사용한 Provider는 2번 테스트에서 공유가 되지 않는다. 때문에 각각의 테스트는 독립적으로 테스트가 가능하다!

다만 Unit Test에서는 ProviderContainer를 이용해서 Provider에 접근이 가능한데, 이 ProviderContainer는 각 테스트 별로 따로 생성을 해줘야 한다. 즉 테스트마다 다른 Container를 사용하여 각각 독립적으로 Provider에 접근이 가능하다

crud_test.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:riverpod_test/app/model/note.dart';
import 'package:riverpod_test/app/model/note_state.dart';

final noteListStateProvider =
    StateNotifierProvider<NoteState, NoteList>((ref) => NoteState());

class Listener extends Mock {
  void call(NoteList? previous, NoteList value);
}

void main() {
  group('노트 앱 Provider 기능 테스트', () {
    const title = 'test Title';
    const body = 'test Body';

    const updatetitle = 'update Title';
    const updateBody = 'update Body';

    test('처음 생성시 노트 목록은 비어있다.', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      final listener = Listener();

      container.listen<NoteList>(
        noteListStateProvider,
        listener,
        fireImmediately: true,
      );

      verify(listener(null, NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);
    });
   });
}

먼저 mockito를 이용해 테스트에 사용할 listener를 작성해주었다. 이 listener는 state가 변할때마다 이전 값과 변한 값을 비교해서 test 결과를 알려준다. 해당 listener를 container에 추가하고 테스트를 진행하면 된다.

먼저 처음 테스트는 처음 Provider 생성 시, 비어있는 목록을 확인하는 테스트이다.
mockito package를 이용하여 이전 값과 현재 값을 비교하고, 테스트 결과를 알려준다.
예시와 같이 테스트 결과를 바로 알 수 있다.

crud_test.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:riverpod_test/app/model/note.dart';
import 'package:riverpod_test/app/model/note_state.dart';

final noteListStateProvider =
    StateNotifierProvider<NoteState, NoteList>((ref) => NoteState());

class Listener extends Mock {
  void call(NoteList? previous, NoteList value);
}

void main() {
  group('노트 앱 Provider 기능 테스트', () {
    const title = 'test Title';
    const body = 'test Body';

    test('노트 생성시 노트 목록에 추가된다', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      final listener = Listener();

      container.listen<NoteList>(
        noteListStateProvider,
        listener,
        fireImmediately: true,
      );

      verify(listener(null, NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .createNote(title: title, body: body);

      verify(listener(NoteList(notes: []),
          NoteList(notes: [Note(id: 0, title: title, body: body)]))).called(1);
      verifyNoMoreInteractions(listener);
    });

    test('노트 삭제시 노트 목록에서 제거된다.', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      final listener = Listener();

      container.listen<NoteList>(
        noteListStateProvider,
        listener,
        fireImmediately: true,
      );

      verify(listener(null, NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .createNote(title: title, body: body);

      verify(listener(NoteList(notes: []),
          NoteList(notes: [Note(id: 0, title: title, body: body)]))).called(1);
      verifyNoMoreInteractions(listener);

      container.read(noteListStateProvider.notifier).deleteNote(0);

      verify(listener(NoteList(notes: [Note(id: 0, title: title, body: body)]),
          NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);
    });
  });
}

노트 생성 및 삭제 테스트이다. 생성의 경우 처음 생성 후, 값을 비교하고 그 후 생성하고 나서 다시 값을 비교하는 방식이다. 삭제의 경우 처음 생성 후, 값을 비교하고 생성한 후, 값 비교를 하고, 마지막으로 삭제 후 해당 목록을 확인하는 테스트이다.

crud_test.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:riverpod_test/app/model/note.dart';
import 'package:riverpod_test/app/model/note_state.dart';

final noteListStateProvider =
    StateNotifierProvider<NoteState, NoteList>((ref) => NoteState());

class Listener extends Mock {
  void call(NoteList? previous, NoteList value);
}

void main() {
  group('노트 앱 Provider 기능 테스트', () {
    const title = 'test Title';
    const body = 'test Body';

    const updatetitle = 'update Title';
    const updateBody = 'update Body';

    test('노트 수정시 목록에서 업데이트 된다.', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      final listener = Listener();

      container.listen<NoteList>(
        noteListStateProvider,
        listener,
        fireImmediately: true,
      );

      verify(listener(null, NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .createNote(title: title, body: body);

      verify(
        listener(
          NoteList(notes: []),
          NoteList(notes: [Note(id: 0, title: title, body: body)]),
        ),
      ).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .updateNote(id: 0, title: updatetitle, body: updateBody);

      verify(
        listener(
          NoteList(notes: [Note(id: 0, title: title, body: body)]),
          NoteList(notes: [Note(id: 0, title: updatetitle, body: updateBody)]),
        ),
      ).called(1);
      verifyNoMoreInteractions(listener);
    });

    test('노트의 특정 내역만 수정시 목록에 업데이트 된다.', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      final listener = Listener();

      container.listen<NoteList>(
        noteListStateProvider,
        listener,
        fireImmediately: true,
      );

      verify(listener(null, NoteList(notes: []))).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .createNote(title: title, body: body);

      verify(
        listener(
          NoteList(notes: []),
          NoteList(notes: [Note(id: 0, title: title, body: body)]),
        ),
      ).called(1);
      verifyNoMoreInteractions(listener);

      container
          .read(noteListStateProvider.notifier)
          .updateNote(id: 0, title: updatetitle);

      verify(
        listener(
          NoteList(notes: [Note(id: 0, title: title, body: body)]),
          NoteList(notes: [Note(id: 0, title: updatetitle, body: body)]),
        ),
      ).called(1);
      verifyNoMoreInteractions(listener);
    });
  });
}

노트 수정 테스트이다. 노트 수정 테스트의 경우 2가지로 진행했는데, title과 body 모두 변경하는 경우와 title이나 body중 하나만 변경하는 테스트 이렇게 2가지를 진행했다. 이는 노트 수정 메서드에서 title과 body를 nullable로 받았기 때문에 해당 값의 유무에 따라 각각 테스트를 진행해주었다.
각각의 테스트를 모두 실행시켜보면 다음과 같은 결과를 얻을 수 있다(마지막 통합 테스트는 한 테스트에서 모든 기능을 실행시킨 테스트이다).

2. Widget Test 작성

위에서 볼 수 있듯이 Unit Test는 각 기능별로 테스트를 진행하기 때문에 비교적 간단하게 테스트 진행이 가능하다. 하지만 실제 앱은 화면 동작도 확인을 해야되기 때문에 이럴때는 Widget Test를 사용할 수 있다.

Widget Test

Flutter의 WidgetTester에서는 각 위젯에 대한 동작도 지원한다. 예를 들어 특정 버튼을 클릭하거나 스크롤을 하거나 등 사용자의 동작과 위젯트리 내에서 해당 위젯의 타입이나 값의 비교가 가능하다.

주요 클래스는 다음과 같다

  • Finder: 특정 Widget이나 Element를 찾는다
  • Matcher: 현재 위젯의 존재 유무를 확인할 수 있다.
  • WidgetTester.pump: 변경된 위젯을 다시 작성한다.
  • WidgetTester.pumpAndSettle: 예약된 프레임이 없을 때까지 pump를 반복 호출한다.
  • WidgetTester.pumpWidget: 주어진 위젯을 최상위 트리부터 재구성한다.

현재 작성한 앱에서 페이지 구조는 다음과 같다

  • HomePage: 노트 목록을 볼 수 있는 화면, 노트 생성 or 수정 페이지로 이동 가능
  • NotePage: 노트를 새로 생성할 수 있는 화면
  • NoteEditPage: 생성된 노트를 수정할 수 있는 화면

노트의 삭제는 HomePage에서 Dismissible 위젯을 통해 삭제하도록 구현했다. UI코드까지 첨부하기엔 조금 내용이 길어져 밑에 소스링크를 남겨두었다.

home_page_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_test/app/ui/home/home_page.dart';
import 'package:riverpod_test/app/ui/home/note_edit_page.dart';
import 'package:riverpod_test/app/ui/home/note_page.dart';

void main() {
  group('노트 앱 위젯 테스트', () {
    _pumpTestWidget(WidgetTester tester) => tester
        .pumpWidget(const ProviderScope(child: MaterialApp(home: HomePage())));
    testWidgets('처음 홈 화면은 비어있다.', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      expect(find.byType(ListView), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });
  });
}

먼저 처음 테스트는 처음 앱에 진입했을 때, 비어있는 ListView와 ListTile을 확인하는 테스트이다. findsOneWidget은 해당 위젯의 존재 유무를 확인하는 matcher이고, findNothing은 존재하지 않는지를 확인하는 matcher이다. 기본적으로 Provider를 사용하고 있기 때문에 최상위에 pump함수에 앱을 ProviderScope로 감싸주었다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_test/app/ui/home/home_page.dart';
import 'package:riverpod_test/app/ui/home/note_edit_page.dart';
import 'package:riverpod_test/app/ui/home/note_page.dart';

void main() {
  group('노트 앱 위젯 테스트', () {
    _pumpTestWidget(WidgetTester tester) => tester
        .pumpWidget(const ProviderScope(child: MaterialApp(home: HomePage())));

    testWidgets('홈화면에서 노트 생성 화면으로 이동', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
    });

    testWidgets('노트 생성 화면에서 노트 생성', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
      
      // 값 입력
      await tester.enterText(find.byKey(const ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(const ValueKey('body')), 'there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsNothing);

      // 생성된 노트 확인
      expect(find.byType(ListTile), findsNWidgets(1));
      expect(find.text('hi'), findsOneWidget);
      expect(find.text('there'), findsOneWidget);
    });
  });
}

다음으로는 노트 생성 테스트이다. 노트 생성을 테스트 하기 위해, 먼저 홈페이지에서 노트 생성 페이지로 이동하는 테스트를 먼저 진행했다. WidgetTester에 tap 메서드를 이용해서 특정 위젯에 터치이벤트를 보낼 수 있다.

이후 노트 생성테스트를 진행하는데, 마찬가지로 WidgetTester를 이용해 특정 위젯에 값을 입력하는 동작도 가능하다. 해당 예시에서는 key값을 이용한 Finder로 텍스트 필드에 각각 값을 넣어주었다. 그 후 작성 버튼을 누르고 홈으로 돌아왔을 때 홈위젯에서 새로운 ListTile이 생성되었는지 확인하는 테스트이다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_test/app/ui/home/home_page.dart';
import 'package:riverpod_test/app/ui/home/note_edit_page.dart';
import 'package:riverpod_test/app/ui/home/note_page.dart';

void main() {
  group('노트 앱 위젯 테스트', () {
    _pumpTestWidget(WidgetTester tester) => tester
        .pumpWidget(const ProviderScope(child: MaterialApp(home: HomePage())));

    testWidgets('노트 타일을 누르면 노트 페이지로 이동', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
      await tester.enterText(find.byKey(const ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(const ValueKey('body')), 'there');
      
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsNothing);
      
      // 노트 수정 페이지로 이동
      await tester.tap(find.byIcon(Icons.edit));
      await tester.pumpAndSettle();
      expect(find.byType(NoteEditPage), findsOneWidget);
    });

    testWidgets('노트 수정 후 홈 화면에서 변경 확인', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
      await tester.enterText(find.byKey(const ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(const ValueKey('body')), 'there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsNothing);

      await tester.tap(find.byIcon(Icons.edit));
      await tester.pumpAndSettle();
      expect(find.byType(NoteEditPage), findsOneWidget);
      
      // 노트 수정
      await tester.enterText(
          find.byKey(const ValueKey('editTitle')), 'edit hi');
      await tester.enterText(
          find.byKey(const ValueKey('editBody')), 'edit there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
	
      // 수정 된 노트 확인
      expect(find.byType(NoteEditPage), findsNothing);
      expect(find.byType(ListTile), findsNWidgets(1));
      expect(find.text('edit hi'), findsOneWidget);
      expect(find.text('edit there'), findsOneWidget);
    });
  });
}

다음은 노트 수정 페이지 이동 테스트와, 노트 수정 테스트이다. 마찬가지로 먼저 페이지 이동 테스트 후, 이동한 페이지에서 노트를 수정하고 다시 홈화면에서 수정된 노트를 비교하는 테스트를 작성해주었다. 생성테스트와 방식은 비슷하다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_test/app/ui/home/home_page.dart';
import 'package:riverpod_test/app/ui/home/note_edit_page.dart';
import 'package:riverpod_test/app/ui/home/note_page.dart';

void main() {
  group('노트 앱 위젯 테스트', () {
    _pumpTestWidget(WidgetTester tester) => tester
        .pumpWidget(const ProviderScope(child: MaterialApp(home: HomePage())));

    testWidgets('노트 타일을 스와이프시 해당 노트 타일 삭제', (WidgetTester tester) async {
      await _pumpTestWidget(tester);
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
      await tester.enterText(find.byKey(const ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(const ValueKey('body')), 'there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsNothing);
		
      // 노트 타일을 스와이프 시 노트 삭제
      expect(find.byType(Dismissible), findsNWidgets(1));
      await tester.drag(find.byType(Dismissible), const Offset(500.0, 0.0));
      await tester.pumpAndSettle();
      expect(find.byType(Dismissible), findsNothing);
    });
  });
}

마지막으로 노트 삭제 테스트이다. 노트 삭제는 앞서 언급한 것처럼 Dismissible 위젯을 이용해 타일을 좌우로 스와이프 하면 삭제하도록 구현해놨다. WidgetTester의 drag함수를 이용해 해당 동작을 테스트 할 수 있다. 마찬가지로 삭제 후 해당 위젯의 존재 유무를 테스트했다.


마찬가지로 테스트를 진행해보면 다음과 같은 결과를 얻을 수 있다!

3. Intergration Test 작성

앞서 테스트들을 진행 후, 최종적으로 앱에서 각 기능과 위젯들이 올바르게 동작하는지 확인하는 테스트이다. Intergration Test는 애뮬레이터나 실기기에서 가능한데, 해당 글에서는 안드로이드 기준 실기기를 통해 테스트를 진행했다.

Intergration Test를 진행하기 위해서는 몇가지 설정을 해주어야 한다. 설정은 Flutter Intergration Test 레포에 잘 나와있다. 간단하게 정리를 해보면 다음과 같다

  • build.gradle test 의존성 추가
  • test driver 설정
  • test 폴더 구조 세팅

설정을 다 마쳤다면 마찬가지로 테스트 코드를 작성하면 된다! 앞에 설정을 다 마쳤다면 해당 테스트 파일이 위치하는 곳은 intergration_test/<테스트 앱 이름>_test.dart이 된다

note_intergration_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:riverpod_test/app/ui/home/note_edit_page.dart';
import 'package:riverpod_test/app/ui/home/note_page.dart';

import 'package:riverpod_test/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('노트앱 통합 테스트', () {
    final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
        as IntegrationTestWidgetsFlutterBinding;

    binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
    testWidgets('노트 생성, 수정, 삭제, 조회 테스트', (WidgetTester tester) async {
      // 앱 실행
      app.main();
      await tester.pumpAndSettle();
      
      // 처음 빈 화면
      expect(find.byType(ListView), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);

      // 노트 생성 페이지 이동 후 노트 생성
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsOneWidget);
      await tester.enterText(find.byKey(const ValueKey('title')), 'hi');
      await tester.enterText(find.byKey(const ValueKey('body')), 'there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NotePage), findsNothing);
      expect(find.text('hi'), findsOneWidget);
      expect(find.text('there'), findsOneWidget);

      // 수정 페이지 이동 후 노트 수정
      await tester.tap(find.byIcon(Icons.edit));
      await tester.pumpAndSettle();
      expect(find.byType(NoteEditPage), findsOneWidget);
      await tester.enterText(
          find.byKey(const ValueKey('editTitle')), 'edit hi');
      await tester.enterText(
          find.byKey(const ValueKey('editBody')), 'edit there');
      await tester.tap(find.byType(ElevatedButton));
      await tester.pumpAndSettle();
      expect(find.byType(NoteEditPage), findsNothing);
      expect(find.byType(ListTile), findsNWidgets(1));
      expect(find.text('edit hi'), findsOneWidget);
      expect(find.text('edit there'), findsOneWidget);

      // 노트 삭제
      expect(find.byType(ListTile), findsNWidgets(1));
      await tester.drag(find.byType(Dismissible), const Offset(500.0, 0.0));
      await tester.pumpAndSettle();
      expect(find.byType(ListTile), findsNothing);
    });
  });
}

Intergration Test는 말 그대로 전체를 테스트 한다. 처음 앱을 실행한 후 앞서 작성한 기능과 위젯들을 다 한번에 테스트한다. 테스트를 실행시켜보면 앱이 실행되면서 Test 시작 문구가 나오고, 테스트를 다 마친 후 Test 종료 문구가 나온다. 영상으로 캡쳐하려 했으나 내 능력 밖이었다... :(테스트가 끝나면 해당 이미지처럼 확인이 가능하다!


정리

Testing 방법을 정리해봤다. 사실 코드를 보면 알 수 있듯이 비교적 간단한 기능의 앱이지만 실제 작성하는 test code의 양은 꽤 적지 않다. 당연한거지만 흔히 말하는 TDD 방식의 코드 작성은 시간과 작성할 코드의 양이 늘어나지만 그만큼 안전하고 단단한 코드와 앱을 만들 수 있다는 장점이 있다.
테스트 통과된 걸 보니 왠지 기분도 좋아진다!


최근 진행하고 있는 프로젝트에서 다른 개발자 분들과 이야기를 나누다가, test 관련 이야기가 나왔었다. 대화를 나눠보면서 스스로 많이 반성하게 되었는데, 생각해보면 기본중에 기본이라고 할 수 있는 부분들을 그동안 많이 놓쳤던 것 같다(테스트 코드 대신 log나 devTools만 이용했다...). 언제나 완벽할 순 없기에 더더욱 test가 중요할텐데 그동안 이런 핑계, 저런 핑계를 대면서 신경을 미처 쓰지 못했던 것 같다. 꼭 TDD 방식이 아니더라도 최소한의 기능, 위젯 단위의 테스트는 작성하는 습관을 길러야겠다고 반성하면서 적고있다.

소스코드 Github


참고자료

profile
100년 후엔 풀스택

0개의 댓글