E2E UI 테스트 도입, Detox
💡 현재 서비스 운영 중인 사용자 앱의 서비스가 점점 커지다 보니 개발 및 유지보수 후 예측할 수 없는 사이드 이펙트로 인한 중요 비즈니스 기능들의 이슈에 대한 영향이 더 많이 있을 수 있다고 판단하였습니다.
그래서 사용자 앱 서비스 프로덕션 배포전 E2E 테스트를 거쳐갈 필요가 있다고 판단하여 Detox라는 E2E 자동화 테스트 라이브러리를 도입하여 개발 환경에 E2E 자동화 테스트 환경을 구축하게 되었습니다.
Detox는 React Native 앱의 E2E 자동화 UI 테스트를 도와주는 프레임워크를 제공합니다.
실제 사용자가 UI 이벤트를 클릭하는 것처럼 시뮬레이터(IOS) 혹은 에뮬레이터(AOS)에서 사용 이벤트 플로우를 실제로 진행시켜 테스트하는 방식입니다.
Detox 환경세팅
이미 React Native 개발 환경 구축이 되었다는 전제하에 아래 세팅을 진행하셔야 합니다.
Global 개발 환경세팅
npm install detox-cli --global
// [MacOS Only] Detox가 ios 시뮬레이터에서 작동하기 위한 설치
brew tap wix/brew
brew install applesimutils
JDK 버전 11.x 이상으로 업데이트 필요
터미널에서 Java --version 했을 때 버전이 11.x 이상으로 나와야 합니다. 이유로는 Android에서 Java 버전이 낮을 경우 Detox가 세팅되어 있다면 빌드 실패 이슈가 있습니다.
Detox가 JDK 11.x 이상으로 작동한다고 보시면 됩니다.
Xcode Excludes Architectures 확인(m1 mac일 경우)
m1 mac은 Xcode 프로젝트 오픈 > TARGETS {project name} > Excludes Architectures가 arm64가 맞는지 확인해야 합니다.
적용할 프로젝트 개발 환경세팅
Detox 공식 사 아트에 React Native 프로젝트 환경세팅이 친절하게 설명되어 있으니 차근차근 참고하여 세팅합니다.
위의 세팅들이 끝나고 빌드가 성공하면 세팅 완료입니다
공식 GitHub에 RN 프로젝트 세팅 예시가 있으니 참고하시면 더 정확히 세팅을 확인할 수 있습니다.
https://github.com/wix/Detox/tree/master/examples/demo-react-native
Detox 테스트해보기
root 디렉터리에 .detoxrc.js 파일을 만들고 안에다가 시뮬레이터/애뮬레이터 작동 디바이스 모델명 설정하실수 있습니다.
root 디렉토리 e2e폴더를 생성하고 안에서 테스트 코드를 작성할 수 있습니다.
테스트 코드 파일명은 뒤에. test가 붙어야 합니다.
첫 번째로 도입시킨 테스트 시나리오는 가장 중요한 결제 플로우 시나리오 테스트였습니다.
아래와 같이 테스트 코드를 작성하고 실행시키면 시뮬레이터가 작동하고 알아서 테스트 코드 작성한 대로 테스트가 진행됩니다.
(참고 >> 테스트 코드 안에서 tryCatch 구문으로 테스트 메서드가 실패하더라도 분기처리를 할 수 있습니다.)
purchase.test.js
import {by, device, element, expect} from 'detox';
describe('시술 상품 구매 테스트', () => {
beforeAll(async () => {
await device.launchApp({
newInstance: true,
permissions: {notifications: 'YES', userTracking: 'YES'},
});
});
afterAll(async () => {
await device.terminateApp();
});
it('시술 상품 구매를 위해 시술 상품 상세 페이지로 이동 할 수 있는가?', async () => {
try {
await expect(element(by.id('main-popup-close-btn'))).toExist();
await element(by.id('main-popup-close-btn')).tap();
//-----------------------메인 모달이 있을때-----------------------//
try {
await expect(
element(by.id('review-induction-popup-close-btn')),
).toExist();
await element(by.id('review-induction-popup-close-btn')).tap();
//-----------------------후기 유도 모달이 있을때-----------------------//
// 3. 검색 페이지 이동
await expect(element(by.id('search-screen-btn'))).toExist();
await element(by.id('search-screen-btn')).tap();
// 4. 시술 상품 검색
await expect(element(by.id('search-form-text'))).toExist();
await element(by.id('search-form-text')).typeText('Laser');
await waitFor(element(by.id('search-form-text')))
.toHaveText('Laser')
.withTimeout(2000);
await element(by.id('search-form-text')).tapReturnKey();
// 5. 검색한 시술 상품 클릭후 상세 페이지 이동
await waitFor(element(by.id('search-product-item-0')))
.toExist()
.withTimeout(2000);
await element(by.id('search-product-item-0')).tap();
} catch (error) {
//-----------------------후기 유도 모달이 없을때-----------------------//
// 3. 검색 페이지 이동
await expect(element(by.id('search-screen-btn'))).toExist();
await element(by.id('search-screen-btn')).tap();
// 4. 시술 상품 검색
await expect(element(by.id('search-form-text'))).toExist();
await element(by.id('search-form-text')).typeText('Laser');
await waitFor(element(by.id('search-form-text')))
.toHaveText('Laser')
.withTimeout(2000);
await element(by.id('search-form-text')).tapReturnKey();
// 5. 검색한 시술 상품 클릭후 상세 페이지 이동
await waitFor(element(by.id('search-product-item-0')))
.toExist()
.withTimeout(2000);
await element(by.id('search-product-item-0')).tap();
}
} catch (error) {
//-----------------------메인 모달이 없을때-----------------------//
try {
await expect(
element(by.id('review-induction-popup-close-btn')),
).toExist();
await element(by.id('review-induction-popup-close-btn')).tap();
//-----------------------후기 유도 모달이 있을때-----------------------//
// 3. 검색 페이지 이동
await expect(element(by.id('search-screen-btn'))).toExist();
await element(by.id('search-screen-btn')).tap();
// 4. 시술 상품 검색
await expect(element(by.id('search-form-text'))).toExist();
await element(by.id('search-form-text')).typeText('Laser');
await waitFor(element(by.id('search-form-text')))
.toHaveText('Laser')
.withTimeout(2000);
await element(by.id('search-form-text')).tapReturnKey();
// 5. 검색한 시술 상품 클릭후 상세 페이지 이동
await waitFor(element(by.id('search-product-item-0')))
.toExist()
.withTimeout(2000);
await element(by.id('search-product-item-0')).tap();
} catch (error) {
//-----------------------후기 유도 모달이 없을때-----------------------//
// 3. 검색 페이지 이동
await expect(element(by.id('search-screen-btn'))).toExist();
await element(by.id('search-screen-btn')).tap();
// 4. 시술 상품 검색
await expect(element(by.id('search-form-text'))).toExist();
await element(by.id('search-form-text')).typeText('Laser');
await waitFor(element(by.id('search-form-text')))
.toHaveText('Laser')
.withTimeout(2000);
await element(by.id('search-form-text')).tapReturnKey();
// 5. 검색한 시술 상품 클릭후 상세 페이지 이동
await waitFor(element(by.id('search-product-item-0')))
.toExist()
.withTimeout(2000);
await element(by.id('search-product-item-0')).tap();
}
}
});
it('상품 선택 모달창을 열고 상품을 선택하고 예약 페이지로 이동 할 수 있는가?', async () => {
// 1. 구매 예약 하려는 시술 상품 모달 열기 버튼 클릭
await waitFor(element(by.id('product-purchase-selection-open-modal-btn')))
.toExist()
.withTimeout(3000);
await element(by.id('product-purchase-selection-open-modal-btn')).tap();
// 2. 첫번째 상품 클릭 및 선택 이벤트 기능 확인
await expect(element(by.id('select-product-0'))).toExist();
await element(by.id('select-product-0')).multiTap(2);
// 3. 선택한 상품을 예약하려는 예약 페이지 이동 버튼 클릭
await expect(element(by.id('confirmation-select-product-btn'))).toExist();
await element(by.id('confirmation-select-product-btn')).tap();
});
it('시술 예약 필수 정보 입력이 가능하고 다음 구매 페이지로 넘어갈 수 있는가?', async () => {
// 1. 예약 날짜 선택 모달 열기
await expect(element(by.id('open-modal-scheduled-calendar-btn'))).toExist();
await element(by.id('open-modal-scheduled-calendar-btn')).tap();
// 2. 예약 날짜 달력에서 클릭
await expect(element(by.id('select-scheduled-date'))).toExist();
await element(by.id('select-scheduled-date')).tap();
// 3. 예약 시간 선택 모달에서 시간 선택
await element(by.id('date-and-time-reservation-modal-scrollview')).scrollTo(
'bottom',
);
await expect(
element(by.id('open-modal-scheduled-time-picker-btn')),
).toExist();
await element(by.id('open-modal-scheduled-time-picker-btn')).tap();
await expect(element(by.id('confirmation-scheduled-time-btn'))).toExist();
await element(by.id('confirmation-scheduled-time-btn')).tap();
// 4. 예약 날짜 시간 확정 버튼 클릭
await expect(element(by.id('confirmation-scheduled-date-btn'))).toExist();
await element(by.id('confirmation-scheduled-date-btn')).tap();
// 5. 인원수 카운트 버튼 클릭
await element(by.id('reservation-scrollview')).scrollTo('bottom');
await expect(element(by.id('plus-persons-count-btn'))).toExist();
await element(by.id('plus-persons-count-btn')).tap();
// 6. 구매 페이지 이동 버튼 클릭
await expect(element(by.id('confirmation-reservation-info-btn'))).toExist();
await element(by.id('confirmation-reservation-info-btn')).tap();
});
it('구매 페이지에서 최종 구매 버튼을 클릭하고 구매를 위한 결제창이 등장하는가?', async () => {
// 1. 구매 결제 요청 버튼 클릭
await element(by.id('purchase-scrollview')).scrollTo('bottom');
await expect(element(by.id('purchase-payment-request-btn'))).toExist();
await element(by.id('purchase-payment-request-btn')).tap();
// 2. 구매 요청 200 성공후 구매 결제창 웹뷰 등장 체크
await waitFor(element(by.id('purchase-webview')))
.toExist()
.withTimeout(10000);
});
});
구매하기 테스트 시나리오 아래와 같이 흘러갑니다.
시상품 구매하기 테스트 시나리오
- 개발용 시뮬레이터 작동
- 앱 실행
- 스테이징 모드로 변경
- 테스트용 회원 로그인
- 검색 페이지 이동
- 시술 상품 검색
- 검색하여 등장한 시술 상품의 상세 페이지로 이동
- 상품 상세 페이지에서 구매 상품 리스트 모달 열고 상품 선택 후 예약 페이지 이동
- 상품 예약 페이지에서 예약 필수 정보 입력 후 구매 페이지 이동
- 구매 페이지에서 구매 결제 요청 버튼 클릭
- 결제창 웹뷰 등장
- 상품 구매 테스트 성공
- 앱 종료
더 다 세한 Detox의 테스트 코드 작성은 여기 참고
테스트 실행 스크립트 작성
Build
detox build --configuration ios.sim.debug //IOS
detox build --configuration android.emu.debug //AOS
Detox의 원리는 테스트용 앱 apk을 따로 build 하여 시뮬레이터/에뮬레이터 테스트 실행하는 것이어서 e2e테스트 실행 전 최신 프로젝트 코드로 build를 해줘야 합니다.
Test
detox test --configuration ios.sim.debug //IOS
detox test --configuration android.emu.debug //AOS
Detox 테스트용 build가 끝나면 프로젝트 root 디렉터리 터미널에서 위의 명령어를 실행시키면 시뮬레이터/에뮬레이터가 실행되며 자동으로 테스트가 진행됩니다.
저희 서비스는 아래와 같은 플로우로 테스트 및 배포를 진행합니다.
E2E용 Build -> 시뮬레이터 실행 -> E2E 자동 테스트 -> 테스트 Success -> Fastlane 자동 배포 명령실행
설정해 둔 E2E 테스트가 통과가 되어야 Fastlane 배포가 실행되도록 스크립트 명령어를 설정해 두었습니다.
혹시 따로 테스트용으로만 실행할 수 있느니 따로 빌드/테스트 실행을 할 수 있도록 스크립트 명령어를 빼두었습니다.
처음 환경 세팅이 조금 번거롭지만 마음 잡고 한번 테스트 코드 시나리오를 세팅해 두면 더 안전하게 서비스를 운영/개발하는데 도움이 많이 된다는 것을 느꼈습니다.
감사합니다!
'프로그래밍 개발 > React Native' 카테고리의 다른 글
React Native - 작동원리 그리고 UI 스레드 / JS 스레드에 대한 고찰 (0) | 2023.09.22 |
---|---|
React Native - 구글 맵(Google Map) SDK 환경세팅과 위치 기반 서비스 적용기 (0) | 2023.08.22 |
React Native - Firebase Push Notification, Image 이미지 표시 iOS 환경 세팅 (0) | 2023.08.16 |
React Native - 코드 푸쉬(Code Push) 정의 / 개발 환경 세팅 / Staging 환경 세팅 (0) | 2023.08.10 |
React Native - IOS / Android 개발 환경 세팅 (M1 Mac용) (0) | 2023.08.08 |
댓글