Flutter에서 유닛 테스트하기

 ・ 8 min

photo by Hai Nguyen on Unsplash

이제 미루고 미루어 왔던 테스트 시도하려고 해요. 😣
domain -> data -> application -> presentation 순서로 급하게 기능을 만들어 왔는데 조금씩 합치다 보니, 중간중간 에러가 나는데 테스트를 통해 각 항목에서 제대로 만들어졌는지를 확인하고 넘어가자는 생각이 들었어요.

Flutter에서 테스트#

Flutter에서 테스트하는 방식은 다른 곳에서도 많이 사용하는 Given-When-Then 패턴을 사용하기 쉽게 만들었어요. 그리고 플러터는 UI를 테스트하는 것도 지원하고 있어요.

저는 Unit test를 어떻게 했는지 설명할게요. VS Code에서 테스트하고 싶은 파일을 선택해 주세요. 마우스 우클릭을 하면 Go to Tests 옵션이 있는데 이걸 눌러주세요. 만약 파일이 없다면 하단에 Snackbar가 나오면 파일을 만들 거냐고 물어봐요. 좋은 점은 test 폴더 밑으로 같은 경로로 {원래 파일 이름}_test.dart으로 만들어져요.

그렇게 해서 만들어지면 아래와 비슷한 내용으로 파일이 만들어질 거예요.

import 'package:flutter_test/flutter_test.dart';
 
void main() {
  testWidgets('when to meet service ...', (tester) async {
    // TODO: Implement test
  });
}

바로 testWidgets이 만들어졌지만, 지금은 widget 이전의 로직을 테스트하기 위해서 group 함수나 test 함수를 사용할 거예요. main 함수 안에서 주로 들어가는 함수는 testWidget, test, group이에요.

testWidget은 위젯을 테스트하기 위한 함수예요.
test는 로직을 테스트하기 위한 함수예요.
group은 여러 개의 test나 testWidget 함수를 묶어서 실행할 때 감싸는 함수예요.

세 함수 모두, 2개의 필수인자를 포함해서 몇 개의 인자가 더 있지만 기본적인 모습은 비슷해요. callback이나 body 함수의 타입이 똑같은 건 아니에요.

void main() {
	testWidgets(description, callback);
	test(description, body);
	group(description, body);
}

testWidgets 예제는 프로젝트 만들면서 생성된 widget_test.dart 파일이 있으니 지금은 다루지 않을 거예요. 나중에 UI 테스트가 필요할 때, 글을 쓰면서 예제를 만들어 볼게요. 이번에는 repository 클래스를 group과 test 함수를 이용하는 예제를 만들 거예요.

Given-When-Then 패턴#

Given-When-Then 패턴을 사용한다고 했으니 이 방식은 뭔지 간단하게 설명할게요. 테스트를 꼭 이 방식으로 수행하지 않아도 괜찮아요. 테스트하는데 정해진 한 가지 방법은 없어요. 단순히 값만 확인하는 테스트를 할 수도, Selenium 같은 패키지를 사용해 예상된 동작을 수행하는지도 테스트하기도 해요.

Given-When-Then은 준비-실행-검증을 하는 과정이라고 생각하면 돼요.
Given: 해당 시나리오에서 테스트하고자 하는 행동을 시작하기 전, 상황을 설정하는 부분이에요. 테스트를 위한 전제조건이라고 생각해도 돼요.
When: 제가 정한 동작을 수행하는 부분이에요. 테스트가 필요한 대상 함수가 들어가야 해요.
Then: 제가 정한 동작으로 인해 예상되는 변화를 확인하는 부분이에요. 테스트 이후의 결과가 예상한 결과가 맞는지 확인하는 과정이에요.

아래는 다른 사람이 만든 Given-When-Then 패턴 예제인데 이해하는 데 도움이 될 거예요.

Feature: User trades stocks
  Scenario: User requests a sell before close of trading
    Given I have 100 shares of MSFT stock
       And I have 150 shares of APPL stock
       And the time is before close of trading
 
    When I ask to sell 20 shares of MSFT stock
     
     Then I should have 80 shares of MSFT stock
      And I should have 150 shares of APPL stock
      And a sell order for 20 shares of MSFT stock should have been executed

Then에서는 테스트 결과가 성공인지도 확인하지만, 실패인 경우도 실패인지 확인하는 과정이 필요해요. 실패를 예상했는데 성공하는 경우 치명적인 에러가 나타날 수 있으니까요.

테스트 예제#

저는 repository에 만든 함수 중 RemoteWhenToMeetRepository라는 객체가 있어요. 내부에서 firebase의 firestore로 domain 객체 데이터를 저장하는 함수인 addWhentToMeet이 있어요. 이걸 테스트한다고 가정할게요.

class RemoteWhenToMeetRepository implements WhenToMeetRepository {
  const RemoteWhenToMeetRepository({required this.fs});
  final FirebaseFirestore fs;
 
  CollectionReference get _whenToMeetCollection => fs.collection('when_to_meet');
 
  @override
  Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
    return _whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson());
  }
 
  @override
  Future<WhenToMeet?> getWhenToMeet(WhenToMeetID id) {
    return _whenToMeetCollection.doc(id).get().then((doc) {
      final data = doc.data();
      if (data == null) {
        return null;
      }
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
  }
}

그러면 이걸 테스트하는 방법은 몇 개가 있어요.

  1. 저장할 객체(WhenToMeet)를 생성하고, FirebaseFiresotre.instance를 test 함수 내부에서 불러온 뒤, 저장하는 함수를 test 내부에서 실행시키고 예상 결과와 비교하기
  2. 저장할 객체(WhenToMeet)를 생성하고, RemoteWhenToMeetRepository 객체를 생성한 뒤, addWhenToMeet 함수 호출하고 예상 결과와 비교하기

아직 이 방식에 익숙하지 않으니, 처음부터 RemoteWhenToMeetRepository를 사용하는 것보다는 하나씩 진행하는 게 좋을 거로 생각해요. RemoteWhenToMeetRepository는 FirebaseFirestore를 외부에서 주입받아야하기 때문에 Firestore에 먼저 저장이 되는지를 확인할게요.

저처럼 when 함수를 사용하실거라면 dependencies에 mockito를 추가해 주세요.
flutter pub add mockito

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FirebaseFirestore.instance;
    final whenToMeetCollection = fs.collection('when_to_meet');
 
    // when
    when(await whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson()));
 
    // 결과를 Firestore에서 가져옴
    final expectResult = await whenToMeetCollection.doc(whenToMeet.id).get().then((doc) {
      final data = doc.data();
      if (data == null) return null;
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
 
    // then
    expect(whenToMeet, expectResult);
  });
}

이걸 실행시키면 에러가 나와요. Firebase 초기화가 진행되지 않아서 뜨는 오류예요. main 함수나 test 함수 어딘가에 await Firebase.initializeApp();를 추가해도 다른 에러가 나와요. FlutterFire를 참고하면 유닛 테스트를 위해 fakes를 사용하라는 안내가 나와요. 저는 fake_cloud_firestore가 필요하니 패키지를 추가할게요.
flutter pub add fake_cloud_firestore

image

Fake를 사용했기 때문에 실제로 저장되진 않아요. 그런데 왜 Fake나 Mock이라는 걸 테스트에서 사용할까요?
가장 큰 이유는 해당 동작까지 테스트할 필요가 없어서라고 말할 수 있어요. 그 부분은 테스트 범위 안에 포함하지 않는 거죠. 그리고 Mock, Fake, Spy, Stub, Dummy를 총칭해서 Test Double이라고 해요. 이 용어는 영화 촬영 시 스턴트맨이 배우 대신 위험한 역할을 하는 Stunt double에서 비롯되었다고 해요.

final fs = FakeFirebaseFirestore(); 부분만 변경(import도 해주세요)하니 테스트 통과되었어요.
이전 코드와 한 줄이 달라졌는데 테스트 환경에서도 잘 돌아가요.

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FakeFirebaseFirestore();
    final whenToMeetCollection = fs.collection('when_to_meet');
 
    // when
    when(await whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson()));
 
    // 결과를 Firestore에서 가져옴
    final expectResult = await whenToMeetCollection.doc(whenToMeet.id).get().then((doc) {
      final data = doc.data();
      if (data == null) return null;
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
 
    // then
    expect(whenToMeet, expectResult);
  });
}

Test Double#

Test Double은 Gemini의 힘을 빌려 짧게 정리할게요.
Test Double을 사용하는 이유

  • 외부 의존성 격리: 외부 의존성으로 인해 테스트 결과가 불안정해지는 것을 방지해요.
  • 테스트 환경 제어: 테스트에 필요한 특정 상황이나 입력을 쉽게 설정할 수 있어요.
  • 테스트 실행 속도 향상: 실제 객체보다 빠르게 실행되는 Fake 객체 등을 사용하여 테스트 속도를 높일 수 있어요.
  • 테스트 커버리지 확대: 외부 의존성으로 인해 테스트하기 어려운 부분까지 테스트할 수 있어요.

Dummy

  • 역할: 단순히 필요한 매개변수를 채우기 위해 사용되는 객체예요. 실제로는 사용되지 않고, 테스트 대상 코드가 정상적으로 동작하는지 확인하는 데 사용돼요.
  • 예시: 함수의 매개변수로 객체가 필요하지만, 실제로 해당 객체를 사용하지 않는 경우 Dummy 객체를 전달할 수 있어요.

Fake

  • 역할: 실제 객체와 유사하게 동작하지만, 단순화되거나 제한된 기능을 가진 객체예요. 실제 객체를 사용하기 어렵거나 복잡한 경우, 또는 테스트 환경에서 빠른 실행이 필요한 경우에 사용돼요.
  • 예시: 실제 데이터베이스 대신 메모리 기반의 Fake 데이터베이스를 사용하여 테스트할 수 있어요.

Stub

  • 역할: 특정 값이나 동작을 미리 정의하여 반환하는 객체예요. 테스트 대상 코드의 특정 입력에 대한 출력을 예측하고 검증하는 데 사용돼요.
  • 예시: 외부 API 호출을 Stub 객체로 대체하여, API 응답에 따른 테스트 대상 코드의 동작을 검증할 수 있어요.

Spy

  • 역할: 실제 객체와 유사하게 동작하면서, 호출 정보를 기록하는 객체예요. 테스트 대상 코드가 다른 객체와 어떻게 상호작용하는지 확인하는 데 사용돼요.
  • 예시: 특정 메서드가 호출되었는지, 호출 시 전달된 인자는 무엇인지 등을 Spy 객체를 통해 확인할 수 있어요.

Mock

  • 역할: 특정 행위를 기대하고 검증하는 객체예요. 테스트 대상 코드가 다른 객체와 예상대로 상호작용하는지 확인하는 데 사용돼요.
  • 예시: 특정 메서드가 특정 횟수만큼 호출되었는지, 특정 순서로 호출되었는지 등을 Mock 객체를 통해 검증할 수 있어요.

정리하고 보니 TDD를 하려면 Given-When-Then 패턴과 Test Double을 알고 있어야 하면서 Unit Test, Integration Test, Code Coverage의 개념과 사용법을 알아야 잘 쓰겠다는 생각이 드네요.. 😅

이제 Firestore로 테스트해 봤으니 RemoteWhenToMeetRepository로 테스트를 해보려고 해요. 저는 이미 주요 함수에서 구현을 미리 했어서 함수만 호출하게 되어서 보이는 코드양은 줄어들었어요.

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FakeFirebaseFirestore();
    final repository = RemoteWhenToMeetRepository(fs: fs);
    // when
    when(await repository.addWhenToMeet(whenToMeet));
 
    // 결과를 Firestore에서 가져옴
    final expectResult = await repository.getWhenToMeet(whenToMeet.id);
 
    // then
    expect(whenToMeet, expectResult);
  });
}

만약 repository 변수를 자주 사용하게 될 것 같다면 setUp, setUpAll 함수로 전역 변수처럼 사용해도 좋아요. 테스트하는 방법은 다양하고 상황에 따라 못 쓰게 되는 경우도 있지만, 이번 기회로 하는 방법은 알 수 있어서 좋았어요.

만약 VS Code를 사용하신다면 테스트 실행 단축키를 알려드릴게요. Start Debugging(단축키 F5)이 먼저 실행된 상태에서 실행하고자 하는 test 함수 안에 커서를 잡아두고 F5 버튼을 눌러도 되고 cmd + ;를 누르고 cmd + c를 입력하면 해당 함수가 실행돼요.

image

시도했던 것#

Mock 객체를 build_runner를 통해 만드는 것도 시도해 봤는데 getWhenToMeet 함수가 이전과 결과가 동일하지는 않았어요. 이건 좀 더 찾아봐야겠지만, 한번 참고하시라고 코드를 남겨둘게요.

@GenerateNiceMocks([MockSpec<RemoteWhenToMeetRepository>()])
import 'remote_when_to_meet_repository_test.mocks.dart';
 
void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final repository = MockRemoteWhenToMeetRepository();
    // when
    when(await repository.addWhenToMeet(whenToMeet));
 
    // 결과를 Firestore에서 가져옴
    final expectResult = await repository.getWhenToMeet(whenToMeet.id);
    // then
    expect(whenToMeet, expectResult);
  });
}

The road of excess leads to the palace of wisdom.

— William Blake


Other posts
cover_image
 ・ 2 min

Flutter에서 아이콘과 이름 수정하기

cover_image
 ・ 3 min

아이폰에서 Flutter 실행시키기

cover_image
 ・ 3 min

Flutter에서 fvm 적용하기