Tech
크로스 플랫폼 React Native는 소프트웨어 개발 프로세스 중 하나인 V 모델에서 상위 레벨의 Validation에 근접할수록 Appium 또는 Detox와 같은 타사 도구를 사용해야 합니다. 하지만 Flutter는 3가지 유형의 테스트를 자체 패키지로 지원합니다.
메서드 또는 클래스 동작을 확인하는 단위 테스트
앱 자체를 실행하지 않고 Flutter 위젯 동작을 확인하는 위젯 테스트
E2E 테스트 또는 GUI 테스트라고도 불리는 통합 테스트
앱에 기능이 많을수록 수동으로 테스트하기가 더 어려워집니다. 자동화된 테스트는 기능 및 버그 수정 속도를 유지하면서 게시하기 전에 앱이 올바르게 작동하는지 확인하는 데 도움이 됩니다.
Validation 비용은 테스트 피라미드에서 통합 테스트에 근접할수록 높아지는데요. 실제 사용자 흐름에 따른 비즈니스 로직 동작 여부를 검증하는 비용이 Low-Level 테스트에 비해 높기 때문입니다.
전체적으로 올바르게 작동하는지, 특히 구현된 위젯이 서로 통합되었을 때 의도한 형태로 동작하는지에 대해 출시 전 꼭 거쳐야 할 통합 테스트를 Flutter에서 제공하는 flutter_driver 패키지로 어떻게 구현하면 될지 살펴보겠습니다.
최신 상태의 데이터를 보여주도록 페이지 간 데이터를 주고받고 관리하고자 상태 관리 패키지 Provider를 사용했고 Create, Read, Update, Delete 기능 구현된 버킷리스트 앱이다. 로그인 및 회원가입 처리를 위해 Firebase Authentication가 사용되었고 데이터베이스 연동을 위해 Cloud Firestore가 사용되었다. 사용된 패키지 전체는 아래이다.
provider : 상태 관리 패키지
firebase_core : firebase 사용을 위한 필수 패키지
firebase_auth : firebase 로그인 패키지
cloud_firestore : firebase 데이터 베이스 패키지
데이터 접근 권한과 유저를 확인하기 위해 로그인 기능이 필요하다. 소셜 로그인 같은 경우 iOS는 애플 로그인을 필수로 구현해야 마켓 출시가 가능하다. 하지만 이메일 로그인은 상관 없었고 이번 프로젝트의 목적은 Flutter 통합 테스트 라이브러리에 있는 예제 앱 보다 약간 더 복잡한 기능을 테스트해보는 것이므로 이메일 로그인으로 구현하였다.
이메일 로그인을 만약 직접 구현할 경우 일반적으로 다음과 같은 과정이 필요하다.
우선 유저의 정보를 저장할 데이터베이스가 준비되어야 한다.
사용자가 요청한 이메일이, 유효한 이메일인지 확인하기 위해 인증번호 발송 기능이 필요하다.
사용자가 비밀번호를 잊은 경우 임시 비밀번호 발급이 가능해야 한다.
사용자의 이메일이 중복 이메일인지 확인하는 기능이 필요하다.
만약 위와 같은 기능들을 직접 구현하려면 복잡할뿐더러 서버 또한 직접 구축을 해야 한다. 하지만 Firebase Authentication에서는 이미 위와 같은 기능이 구현되어있고 무료로 사용할 수 있기에 Firebase Authentication를 통해서 해당 기능을 구현하였다.
Firebase Auth 사용을 위해 Firebase Console에서 필요한 세팅 진행 후 코드 레벨에서 로그인 기능을 담당해주는 AuthService 클래스를 생성하였다. 그리고 notifyListeners() 함수를 호출하여 화면 리프레시 가능토록 ChangeNotifier 기능을 상속받도록 하였다.
AuthService 클래스에서 구현된 메서드는 아래이다. 각 메서드는 Firebase Auth 서버와 통신해야 하기 때문에 실행에 시간이 소요되는 비동기 코드이므로 동기 처리를 위해 async와 await를 사용하였다.
currentUser : 현재 유저 조회(로그인을 하지 않은 경우 null 반환)
signUp : 회원가입
signIn : 로그인
signOut : 로그아웃
이렇게 설계된 AuthService를 Provider를 이용하여 위젯 트리의 최상단에 넣어줌으로써 AuthService를 어디서든 접근할 수 있도록 하였다.
회원가입 같은 경우는 매뉴얼 블랙박스 테스트 관점에서 사용자가 회원가입 버튼을 누를 때부터 로직이 실행되어야 하므로, 코드 레벨에서의 로직 흐름은 아래와 같다.
회원가입 버튼 클릭
사용자가 입력한 이메일과 비밀번호 가져와서
AuthService에 signUp 함수로 전달
로그아웃 기능은 사용자가 로그아웃 버튼 클릭 시 AuthService의 signOut 함수를 호출하여 관련 로직을 실행하도록 했고 LoginPage 화면을 갱신하기 위해 notifyListeners()를 호출하도록 하였다.
마지막으로 자동 로그인은 만약 앱 시작 시 이미 로그인되어 있는 경우 LoginPage가 아닌 HomePage로 바로 이동하도록 처리하기 위해 위젯 트리 상단에 있는 AuthService에 접근하여 currentUser() 함수를 호출하고, user가 null이라면 로그인되어있지 않은 상태이므로 LoginPage로 이동하고, 그렇지 않은 경우 HomePage로 이동하도록 구현하였다.
BucketService로 구현된 기능은 아래이다.
Create : 버킷 생성하기
Read : 작성한 버킷 리스트 불러오기
Update : isDone(버킷 완료 여부-boolean) 업데이트
Delete : 버킷 삭제하기
현재 입력된 텍스트를 가져와 BucketService의 create 함수를 호출하도록 구현하였다. HomePage에 jobController를 TextField에 controller로 등록하고 jobController.text로 현재 입력된 값을 가져올 수 있도록 하였다. 그리고 Cloud Firestore에 bucket collection이 추가되도록 create 함수를 구현하였다.
bucketCollection에서 uid가 현재 로그인된 유저의 uid와 일치하는 문서만 가져오도록 구현하였다. 구현된 read 함수를 main.dart 파일에서 호출하여 화면에 데이터를 보여주도록 하였다. Wrap with StreamBuilder를 사용해서 StreamBuilder가 ListView를 감싸도록 하였고, BucketService의 read 기능은 반환하는 값이 시간이 걸리는 Future이기 때문에 화면에 바로 보여줄 수 없으므로 FutureBuilder라는 위젯으로 통신 후 데이터를 받아온 뒤에 builder 부분이 갱신되면서 화면에 보여주도록 처리하였다. 그리고 future로 실행하는 값의 결과를 builder 함수에서 snapshot.data를 통해 접근하였고, 모든 docs를 가져오는데 만약 null인 경우 빈 배열을 반환하도록 처리하였다.
isDone 상태에 따라 화면에 다르게 보여주는 부분을 구현하고 doc.id로 현재 문서의 고유 식별자를 가져옴으로써 버킷 아이템 클릭 시 버킷 완료 여부를 변경하도록 구현하였다.
Update와 마찬가지로 docID를 활용해서 삭제 아이콘 클릭 시 입력된 버킷 리스트를 삭제하고 화면을 리프레시하도록 구현하였다.
그리고 만약 버킷리스트에 입력된 모든 버킷을 삭제했을 경우 화면 중앙에 ‘버킷 리스트를 작성해주세요’ 문구를 추가함으로써 Delete 기능 구현이 완료되었다.
테스트 대상은 앞서 구현한 버킷 리스트 앱의 로그인 기능입니다. 사용자는 유효한 이메일과 비밀번호를 입력 후 로그인 버튼을 클릭하면 버킷 리스트 화면으로 이동할 수 있습니다. 통합 테스트 코드 구현은 이미 회원가입 처리된 유효한 이메일과 비밀번호를 텍스트 필드에 입력하고 로그인 버튼 클릭 시 버킷 리스트 텍스트가 화면에 나타난다면 Pass 보이지 않는다면 Fail로 처리합니다.
auth_service.dart와 bucket_service.dart의 구현 사항은 상단의 내부 구현 사항에서 확인할 수 있습니다. 유효한 이메일과 비밀번호로 로그인 가능한지 확인하는 것이 테스트 목적이기 때문에 main.dart에서는 통합 테스트 코드 구현에 필요한 코드만 살펴보겠습니다.
사용자가 정상 로그인 시도하는 플로우를 살펴보면 아래와 같습니다.
앱을 실행하고
로그인 페이지가 보일 때까지 기다렸다가
이메일 텍스트 필드에 유효한 이메일을 입력하고
비밀번호 텍스트 필드에 유효한 비밀번호를 입력하고
로그인 버튼을 클릭하면
버킷 리스트 화면으로 이동됩니다.
플로우에 적힌 입력, 클릭 등과 같이 테스트 동작에 필요한 기능을 구현하기 위해선 우선 로케이터를 설계해야 합니다. 로케이터를 찾을 수 있도록 고유 값을 추가해보겠습니다.
하단의 TextField에 key: Key(‘emailTextField’)를 추가하여 테스트 코드 작성 간에 find.byKey()로 해당 로케이터를 찾을 수 있도록 할 예정입니다.
이메일과 마찬가지로 passwordController가 속해있는 TextField에 고유 값을 추가했습니다. 로그인 버튼 로케이터도 생성해야 하니 ElevatedButton에 btnLogin을 추가해줍니다.
블랙박스 테스트에서는 사용자가 로그인되었다는 것을 증명하기 위해 로그인 이후 존재하는 텍스트가 나타났는지 검증이 필요합니다. LoginPage에서 정상적으로 로그인되었다면 버킷 리스트를 입력하고 수정하고 삭제하는 HomePage로 이동되도록 구현이 되었기에, 해당 AppBar가 잘 나타났는지 검증하고자 AppBar에 고유 값을 작성해줍니다.
이로써 통합 테스트 코드 구현 전 main.dart에서 살펴봐야 하는 코드는 모두 살펴보았습니다. 이제 로케이터를 생성해보겠습니다.
앞서 테스트 필요한 엘리먼트에 고유 값을 부여했습니다. integration_test 패키지에서 제공하는 find.byKey() 메서드 사용이 가능해졌습니다. 조금 전 main.dart에서 작성한 고유 값을 변수화 시키면 아래와 같습니다.
Key 값과 동일하게 카멜 케이스로 변수를 만들었습니다. 실무 레벨에서는 로케이터 변수들이 수십수백 개가 되므로 로케이터만 관리하는 데이터 파일을 별도로 보관하여 프레임워크를 디자인 또는 일반적으로 테스트 자동화 디자인 패턴에서 많이 활용하는 방식인 각 Page에 필요한 로케이터만 설계하여 관리하는 방식을 활용합니다. 이렇게 생성된 로케이터는 테스트 로직에 필요한 메서드의 파라미터로 활용됩니다.
실무 레벨에서는 테스트 데이터를 인자 값으로 넘겨줄 때 테스트 로직 레벨에서 하드 코딩하는 방식을 지양합니다. 테스트 데이터 또한 테스트 로직 레벨보다는 json과 같은 데이터 형식으로 별도 분리하여 관리하는 것이 유지보수가 편리합니다. 만약 테스트 로직 레벨에서 사용하더라도 테스트 데이터는 꼭 변수화 시켜서 작성하는 것이 좋습니다.
해당 로그인 데이터는 이미 회원가입 처리가 완료된 사용자이며 Firebase Authentication Users에 등록되어 있는 사용자입니다. 이번 통합 테스트 코드 구현에서는 Positive 시나리오의 Vaild 데이터만 검증합니다.
다만 실무 레벨에서는 특히 로그인과 같이 E2E 레벨에서 중요한 비즈니스 로직들에 한해서는 Invalid 데이터가 포함된 커버리지도 가능한 자동화 하여 유효성 검증에 필요한 리소스를 최소화시키고 매뉴얼 테스트로는 보다 사람이 잘할 수 있는 테스트에 집중하는 편이 효율적입니다.
Flutter integration_test에서는 tester.enterText()로 로케이터에 값을 입력합니다. 탭 동작이 필요한 경우엔 tester.tap()을 사용합니다. 탭 동작 후에 모든 UI 변경이 될 때까지 기다려야 하므로 tester.pumpAndSettle()을 사용해야 합니다.
위 시나리오에서 tester.pumpAndSettle()를 사용하지 않을 경우 로그인 버튼 클릭 후 화면이 리프레시되지 않아 테스트가 실패됩니다.
실무 레벨에서는 반복된 코드 작성을 최대한 피하는 것이 좋기 때문에 각 페이지간 필요한 동작을 공통 메서드 구현 간에 이러한 사항들을 SDET가 기술적으로 검토 후 설계한다면 보다 적은 수의 코드로 테스트 자동화 로직 구현이 가능합니다.
expect()를 사용하여 테스트 시나리오를 검증할 수 있습니다. actual에 loginSuccess를 넘겨주고, matcher에 findsOneWidget를 넘겨줬습니다. Appium과 달리 matcher가 좀 특이해서 살펴보았는데, findsOnWidget은 정확히 하나의 위젯을 찾는 matcher입니다. 이외에도 하나 이상의 위젯을 찾는 findsWidgets 등을 matcher로 사용할 수 있으니, 테스트 시나리오 검증에 필요한 matcher를 사용하면 됩니다.
동작에 문제가 없다면 위와 같이 All tests passed! 가 나타납니다. 만약 실패할 경우 EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWOR 로그가 터미널에 출력되고, 어떤 위젯에서 실패했는지 확인할 수 있습니다.
그런데 위 테스트 구조, 괜찮은 걸까요? 동작에는 문제가 없지만 보다 더 확장성 있는 구조와 유지보수가 원활한 구조로 변경하기 위해선 어떻게 해야 할지 살펴보겠습니다.
group의 testWidgets에서 app.main()과 await tester.pumpAndSettle()가 실행되어야 합니다. 문제는 group을 하나의 테스트 스위트로 묶을 테고 하위 testWidgets을 넣어줌으로써 테스트 시나리오를 그룹화시켜야 할 텐데 각각의 testWidgets마다 app.main()과 await tester.pumpAndSettle()을 매번 작성해야 합니다.
group과 testWidgets의 동작 방식은 WebdriverIO의 describe/it 구조와 굉장히 비슷한 형태로 보이는데, 그렇다면 it 블록들이 testWidgets을 의미하므로 개별 테스트 시나리오 실행 간에 app.main()과 await tester.pumpAndSettle() 함수가 반드시 호출되어야 한다는 의미입니다.
Page Obeject Model로 프레임워크를 디자인할 경우 별도의 파일을 생성하여 initTest라는 함수를 생성하고, 각 Page의 testWidgets에 await initTest(tester); 형태로 작성하여 관리한다면, 만약 initTest(tester)에 해당하는 내부 코드에 변경 사항이 생길 경우 해당 파일의 함수만 수정하면 다른 Page의 initTest(tester)에 모두 영향을 미치기 때문에 유지보수가 원활해진다는 이점이 있습니다.
Flutter에서 로케이터를 찾는 방식을 별도의 파일로 관리해줍니다. 이렇게 할 경우 앞서 구현한 테스트 코드가 보다 간결해진다는 이점이 있습니다.
위와 같은 BasePage 클래스를 설계합니다. 간단하게 Page 클래스라고 작성해보았는데요. 해당 클래스에는 각 페이지마다 공통적으로 사용될 가장 작은 단위의 메서드를 설계합니다. 클릭, 기다림, 입력, 엘리먼트 검증과 같은 메서드를 이곳에 설계합니다.
이후 각 페이지에서 Page 클래스를 상속받아서 Page 클래스에 설계된 메서드를 사용하여 각 페이지에 필요한 테스트 동작을 구현합니다. 이때 로케이터를 각 Page 내부에서 구현하거나 별도의 파일로 관리하는 방법도 있지만, 일반적인 POM 구조에서는 각 Page 내부에서 구현하여 유지 보수하는 방식을 권장합니다.
이렇게 설계된 구조로 테스트 로직을 작성하게 되면 테스트 로직을 위와 같이 직관적이고 보다 간결하게 작성할 수 있습니다.
단위 테스트와 위젯 테스트는 개별 클래스와 함수 그리고 위젯을 테스트하기 유용합니다. 하지만 각 개별 요소들이 실제 기기에서 어떻게 같이 상호 작용되어 동작하는지 테스트하거나 실제 기기에서 동작하는 앱 성능 측정은 불가능합니다. 이러한 작업들은 통합 테스트를 통해 수행될 수 있습니다. 상대적으로 테스트 비용이 높은 통합 테스트 시나리오 전부를 코드 레벨에서 검증할 순 없겠지만, 가능한 일부분 만이라도 유지보수 원활한 구조로 설계된 테스트 자동화 코드가 CICD 파이프라인에서 자동으로 수행되어 결과를 제공해준다면 보다 사람이 해야만 하고 잘할 수 있는 테스트에 집중할 수 있는 환경이 갖춰질 것입니다.
이상으로 Testing Flutter apps 블로깅을 마치겠습니다.
감사합니다.