[Flutter] #6. 테스트 환경과 통합 테스트

Evey
jumpit
Published in
18 min readFeb 2, 2024

클라이언트 혹은 프론트엔드 개발 분야에서 자동화 테스트 코드는, 개발자들이 내부 로직을 체크하던 unit test와, 실제 앱의 작동과 사용자 입력에 의한 흐름이 올바른 방향인지 체크하는 UI test로 나누어 볼 수 있습니다.

그리고 근래에는 이것을 동시에 할 수 있는 통합 테스트 환경이 제공되는 프레임워크들이 많이 생겼습니다.

그러나, 통합테스트를 진행하는 회사의 비율은 매우 적을 것으로 예상합니다.

처음 서비스 MVP의 개발이 이루어질 때부터 적용되어 오지 않았다면 테스트가 고려되지 않은 형태로 개발이 이루어졌을 가능성이 높고, 나중에 적용하려고 하면 몇가지 장벽이 발생하기 때문입니다.

솔직히 말씀드리자면 점핏 서비스 또한 그렇습니다. 특히 통합검색 등의 UI 복잡도가 상당하고, 일부 플로우는 앱내 동작만으로 도달할 수 없기 때문입니다.

그러나 그 가능성은 놓아주기 싫었습니다. 그래서 점핏 서비스도 별도의 테스트 브랜치를 두고, 변경점을 업데이트하고, 공식 툴이 바뀌면 마이그레이션 해 왔습니다. 조직의 방향성과는 별개로 저 개인적으로 그 가능성은 지속적으로 업데이트를 해 오고 있습니다.

(사실은 이미 21년도 부터… 포스팅이 늦어져서 죄송합니다.)

통합 테스트에 필요한 요소

  1. 그룹, 시나리오
  2. UI컴포넌트의 노출여부를 확인
  3. 사용자 행동을 통해 IA 내에서 상호작용

준비 및 예제

코드는 jumpit-boilerplate 내에 공개되어 있습니다.

먼저, 테스트를 하기 위해서 테스트 모듈을 추가해야 합니다.

dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter

테스트 코드 작성을 위해 기존 test/widget_test.dart 를 사용해도 되지만 새로 경로와 파일을 만들어 보겠습니다.

integration_test/ui_test_auto_login.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jumpit_boilerplate/main.dart' as app;
import 'package:jumpit_boilerplate/presentation/screen/login/login_view.dart';
void main() {
group('ui_test_auto_login', () { // 테스트 그룹 안에는 여러개의 testWidgets 가능
testWidgets('자동로그인 테스트', (tester) async {

app.main(); // 앱 시작, 최상단 위젯부터 켜기
// tester.pumpWidget(const app.App()); <- 개별 위젯 테스트 작성시 이렇게 사용

String logStr = "";

// section: wait launch
logStr = "첫화면 my탭 노출 확인";
log(logStr, false);
await tester.pumpAndSettle();
expectWidgetRender('bottomTabMy');
log(logStr, true);

// section: go signin screen
logStr = "my탭을 눌러 로그인화면 노출 확인";
log(logStr, false);
await tester.pumpAndSettle();
await tester.tap(findByKey('bottomTabMy'));
await tester.pumpAndSettle();
expectWidgetRender('textFieldLoginID');
log(logStr, true);
LoginScreenState loginScreenState =
tester.state(find.byType(LoginScreen));

// section: input id
logStr = "ID 입력 확인";
log(logStr, false);
await tester.pumpAndSettle();
await tester.tap(findByKey('textFieldLoginID'));
await tester.pumpAndSettle();
await tester.enterText(findByKey('textFieldLoginID'), "test@test.com");
await tester.pumpAndSettle();
expect(loginScreenState.loginViewModel.idText.val, "test@test.com");
log(logStr, true);

// section: input wrong pw
logStr = "PW 입력 확인";
log(logStr, false);
await tester.pumpAndSettle();
await tester.tap(findByKey('textFieldLoginPW'));
await tester.pumpAndSettle();
await tester.enterText(findByKey('textFieldLoginPW'), "1234qwe");
await tester.pumpAndSettle();
expect(loginScreenState.loginViewModel.pwText.val, "1234qwe");
log(logStr, true);

// section: pw obscure state
logStr = "PW 숨김처리 체크";
log(logStr, false);
TextField textFieldLoginPW = tester.widget(findByKey('textFieldLoginPW'));
expect(textFieldLoginPW.obscureText, true);
log(logStr, true);

// section: wrong pw validation
logStr = "PW 입력 검증";
log(logStr, false);
Text textValidationPW = tester.widget(findByKey("textValidationPW"));
expect(textValidationPW.data, "at least 8 characters");
log(logStr, true);

// section: input correct pw
logStr = "제대로 입력된 PW 입력 확인";
log(logStr, false);
await tester.pumpAndSettle();
await tester.tap(findByKey('textFieldLoginPW'));
await tester.pumpAndSettle();
await tester.enterText(findByKey('textFieldLoginPW'), "1234qwer");
await tester.pumpAndSettle();
expect(loginScreenState.loginViewModel.pwText.val, "1234qwer");
log(logStr, true);

// section: correct pw validation
logStr = "PW 입력 검증";
log(logStr, false);
textValidationPW = tester.widget(findByKey("textValidationPW"));
expect(textValidationPW.data, "good password");
log(logStr, true);

// section: tap sign in button and go home
logStr = "sign in 후 홈으로 이동";
log(logStr, false);
await tester.tap(findByKey('buttonSignIn'));
await tester.pumpAndSettle();
expectWidgetRender('textHome');
log(logStr, true);
});
});
}

void log(String msg, bool isFinished) {
print(
'------------------ UI 테스트 ${isFinished ? "성공" : "시작"} => $msg ------------------');
if (isFinished) print('');
}

void expectWidgetRender(String key) {
expect(findByKey(key), findsOneWidget);
}

Finder findByKey(String key) {
return find.byKey(Key(key));
}

전체 코드는 다음과 같습니다.

그럼 코드가 의미하는 바를 알아보도록 하겠습니다.

1. 그룹, 시나리오

group('ui_test_auto_login', () { // 테스트 그룹 안에는 여러개의 testWidgets 가능
testWidgets('자동로그인 테스트', (tester) async {
app.main(); // 앱 시작, 최상단 위젯부터 켜기
// tester.pumpWidget(const app.App()); <- 개별 위젯 테스트 작성시 이렇게 사용
String logStr = "";
....중략

테스트 코드 main()에는 여러개의 그룹을 정의할 수 있고, 하나의 그룹에는 여러개의 테스트 시나리오(testWidgets)를 넣을 수 있습니다. 그룹은 테스트 실행 단위라고 보시면 됩니다.

예를 들어 main전체를 테스트 하려면 다음과 같이 실행합니다.

flutter test integration_test/ui_test_auto_login.dart

ui_test_auto_login 그룹만 테스트 실행할 때엔 다음과 같이 실행합니다.

flutter test integration_test/ui_test_auto_login.dart --name ui_test_auto_login

그러므로 테스트 그룹 이름은 잘 관리되어야 합니다.

(테스트 실행 참고사항 : Android studio 의 run configuration 에서 flutter test를 추가하면 run버튼을 통해서도 테스트가 가능합니다)

testWidgets()의 인자로는 테스트 시나리오명과 테스트 코드가 필요합니다.

여기서는 ‘자동로그인 테스트' 라고 명명하였습니다.

테스트 코드의 시작은 특정 위젯 테스트인가 혹은 앱 테스트인가에 따라

  • tester의 pumpWidget()으로 개별 위젯을 렌더링하거나,
  • app의 main()을 호출하여 앱을 켜는 것으로 시작됩니다.

2. UI컴포넌트의 노출여부를 확인

자동화테스트를 시작하려면, 이런 조건으로 검사가 이루어져야 합니다.

[현재 렌더링된 화면(Widget tree)에 내가 찾는 이름(Key)의 위젯이 있는가]

그러려면 개발자는 아래와 같은 처리들을 해야 합니다.

  • 위젯에 이름을 붙이는 코드를 추가
  • 렌더링을 갱신하는 코드를 추가
  • 위젯트리에서 해당 이름을 찾는 코드를 추가
  • 성공/실패 결과를 기록

먼저, 위젯에 이름을 붙이는 코드입니다.

Expanded(
child: InkWell(
key: const Key('bottomTabMy'),
onTap: onClickMyTab,
child: const Center(
child: Text("MY"),
),
),
),

간단하게 앱 코드 내 어떤 위젯에 key를 줌으로써 이름을 붙일 수 있습니다.

다만 , 리스트 아이템 등 동일한 위젯이 여러번 생성되는 빌더 내에서는 하나의 이름을 주게 되면 오류가 발생합니다.

Key(‘item_$index’) 와 같이 각기 다른 이름을 가지도록 해 주어야 하는 유의점이 있습니다.

이제 테스트 코드로 돌아옵시다.

렌더링을 갱신하는 코드를 추가해야 합니다.

await tester.pumpAndSettle(); // 렌더링이 더이상 변하지 않게 될때까지 기다림

렌더링 갱신 및 갱신 검사는 테스트 과정에서 계속 일어납니다.

jumpit_boilerplate 에서는 모두 pumpAndSettle()로 통일하였고 동적컨텐츠가 없는 대부분의 화면에서 이렇게만 해도 상관은 없지만, 정확히는 테스트 렌더링에는 3가지 메소드가 존재합니다.

WidgetTester의 pump(), pumpWidget(), pumpAndSettle()에 대한 구글 bard의 설명은 다음과 같습니다.

플러터 위젯 테스터의 pump()과 pumpWidget()의 차이에 대해 알려드립니다.
핵심 차이점:

목적:

pump(): 현재 위젯 트리를 다시 그릴 필요가 있을 때 기존 위젯을 유지하면서 프레임을 트리거합니다.
주로 UI 조작(탭, 스크롤 등) 후 상태 변경을 확인할 때 사용합니다.

pumpWidget(): 완전히 새로운 위젯 트리를 렌더링합니다.
즉, 이전 위젯과 관계없이 주어진 위젯을 기반으로 완전히 새로운 UI를 만듭니다.
테스트 시작 시 또는 위젯을 완전히 변경할 때 사용합니다.


재렌더링:

pump(): 변경된 부분만 다시 렌더링합니다. 따라서 성능이 더 우수합니다.

pumpWidget(): 전체 트리를 다시 렌더링합니다. 성능 측면에서는 덜 효율적입니다.


사용 시점:

pump(): 일반적으로 테스트 내에서 자주 사용됩니다.
UI 상호작용 후 변경된 UI를 확인하거나 애니메이션이 정확하게 실행되는지 테스트할 때 유용합니다.

pumpWidget(): 테스트 시작 시 초기 위젯을 설정하거나 위젯 구조를 근본적으로 변경할 때 사용됩니다.
다른 주의사항:

pumpAndSettle() 메소드도 비슷하게 사용할 수 있습니다.
이 메소드는 현재 프레임이 안정화될 때까지 반복적으로 pump()를 호출합니다.
애니메이션이 완료되거나 모든 미래 작업이 완료될 때까지 기다리고 싶을 때 유용합니다.

다음은, 테스트 코드에서 위젯 트리를 검색하는 코드입니다.

Finder findByKey(String key) {
return find.byKey(Key(key));
}

find.byKey(Key(keyname))를 사용하면 되며, 제경우에는 findByKey라는 함수로 래핑했습니다.

가장 명확한 방법이어서 주로 사용할 뿐, 위젯을 찾는 방법에 키를 사용하는 것만 존재하는 것은 아닙니다. 이것에 대해 bard는 아래와 같이 정리해 주었습니다.

Flutter 통합 테스트에서 메소드 찾기를 위한 다양한 방법들이 있습니다. 
여기 몇 가지 주요 방법을 소개해 드리겠습니다.

1. find.text()
텍스트로 위젯 찾기: 이 메소드는 지정된 문자열을 포함하는 텍스트 위젯을 찾습니다.
예를 들어, 버튼의 레이블을 찾고 싶다면 다음과 같이 사용할 수 있습니다.
final button = find.text('제출');


2. find.byType()
위젯 유형으로 찾기: 특정 유형의 위젯을 찾고 싶을 때 사용합니다.
예를 들어, 이미지 위젯을 찾고 싶다면 다음과 같이 사용할 수 있습니다.
final image = find.byType(Image);


3. find.byKey()
키로 찾기: 유니크한 키를 가진 위젯을 찾을 때 사용합니다.
이는 특히 다이나믹하게 생성되는 위젯을 찾을 때 유용합니다.
final counterText = find.byKey(const Key('counter'));

마지막으로, 테스트에서 성공과 실패 결과를 기록하는 코드입니다.

void expectWidgetRender(String key) {
expect(findByKey(key), findsOneWidget);
}

expect()를 사용하면 되며, 역시 간단히 사용할 수 있게 래핑하였습니다.

결과적으로, 앱이 켜져서 첫번째로 테스트할 위젯이 노출되는 것을 확인하는 코드는 이렇게 됩니다.

// section: wait launch
logStr = "첫화면 my탭 노출 확인";
log(logStr, false);
await tester.pumpAndSettle();
expectWidgetRender('bottomTabMy');
log(logStr, true);

3. 사용자 행동을 통해 IA 내에서 상호작용

앱이 원하는 테스트 시점으로 진입된 것을 확인하였다면 이제 사용자의 행동을 모방한 입력을 통해 출력을 검증해야 합니다.

LoginScreenState loginScreenState =
tester.state(find.byType(LoginScreen));

// section: input id
logStr = "ID 입력 확인";
log(logStr, false);
await tester.pumpAndSettle();
await tester.tap(findByKey('textFieldLoginID'));
await tester.pumpAndSettle();
await tester.enterText(findByKey('textFieldLoginID'), "test@test.com");
await tester.pumpAndSettle();
expect(loginScreenState.loginViewModel.idText.val, "test@test.com");
log(logStr, true);

tap()을 이용하여 ID입력 텍스트필드를 터치한 뒤, enterText()를 이용해 텍스트를 입력합니다.

중간 단계별로 pumpAndSettle()을 호출하여 텍스트필드 애니메이션 등의 렌더링이 완료되었는지 확인합니다.

그리고 위젯 state를 가져와 expect()를 통해서 입력 결과가 일치하는지 확인합니다.

위젯 state를 가져오려 할 때 주의점은, stateful widget내에서 state객체가 private으로 선언되면 가져올 수 없다는 점입니다.

예시)

LoginScreenState _loginScreenState; // 가져올 수 없음

LoginScreenState loginScreenState; // 가져올 수 있음

가져오려는 변수들도 마찬가지로 접근가능하게 개방되어야 합니다.

이 경우에는 loginViewModel이 가지고 있는 idText의 값을 비교하였습니다.

실행결과

한계점

공식 통합테스트의 한계는, 앱내 Flutter 코드 외부 요소에 대해 제어가 불가능하다는 데 있습니다.

앱의 범위를 벗어날 때

가장 큰 난관으로는 소셜로그인, 본인인증절차 등이 그 예가 됩니다. 구글, 페이스북, 패스 등등 다른 앱 화면을 호출하게 되고, 여기서부터 제어가 불가능합니다.

이것은 아직까지는 해소가 불가능한 것으로, 그래픽메모리 읽고 이미지 분석을 통한 테스트툴 등으로 UI테스트 방식 자체를 변경해야 가능한 부분이라고 판단됩니다.

(해본 적은 있으나 개발소요가 커서 큰 규모의 IA에 적용하기 적합하지 않음)

또한 당연하게도, 메일인증처럼 사용자가 타 앱에서 정보를 읽어서 진행해 주어야 하는 프로세스도 진행이 불가능합니다.

웹뷰를 사용하는 부분

앱내 웹뷰를 사용하여 노출되는 UI 또한 동일한 문제가 발생합니다.

테스트 코드가 UI컴포넌트를 찾을 수 없기 때문입니다.

그래서 이를 보완하는 테스트 툴들도 등장했습니다.

Patrol > https://patrol.leancode.co/

appium > https://github.com/appium/appium-flutter-driver

이 툴들은 웹뷰 내에 로드된 컴포넌트들도 검색하여 입출력을 요청하는 것이 가능합니다.

자동화된 고객여정 테스트는, 충분히 고려해볼 여유가 있다면 모두가 탐내는 지점 중 하나입니다.

지속적인 유지보수 과정에서 반복되는 테스트를 통해 사이드이펙트를 찾아내기 용이하고, 사소한 변경이나 개발도구의 버전업 등이 발생할 때 광범위한 영향범위의 테스트에 대한 부담을 줄일 수 있기 때문입니다.

그러나 테스트를 과도하게 중시하게 되면 테스트 코드의 작성 가능여부에 의해 개발 명세가 좌우되는, 주객이 전도될 수 있는 부분이 발생할 수 있습니다.

테스트의 가치를 아주 높게 여긴다면 그것도 틀렸다 말할 수 없는 부분이지만, 어디까지나 가치 판단은 업무 프로세스에 참여하는 구성원들의 합의 하에두는 것이 옳다고 생각합니다.

오랫만에 적어 본 긴 글 읽어주셔서 감사합니다.

--

--