Detox를 사용한 E2E 테스트 도입기

2023년 5월 10일

이 글은 2023년 5월 10일 퍼블 팀 블로그에 먼저 게시되었습니다. 원문 보기

안녕하세요! 퍼블의 소프트웨어 엔지니어 정주형입니다. 퍼블은 기술 없이 나만의 플랫폼으로 수익을 창출할 수 있는 노코드 플랫폼 빌더입니다. 또한 퍼블은 나만의 브랜디드 앱을 만드는 멋진 서비스도 제공하고 있답니다.

이번 글에서는 퍼블에서 테스트를 도입하게 된 연유와 그 과정에 대해 여러분께 공유 드리고자 합니다.

테스트는 좋습니다. 예시를 하나 들어보도록 하겠습니다. 레딧에서 본 도시전설과 같은 이야기입니다.

이야기를 요약하자면 다음과 같습니다. ‘믿을 수 없을만큼 유명한 베이 지역의 회사’에 취직한 한 개발자는 자동화된 테스트를 작성했습니다. 그리고 입사 8개월부터는 그가 짜놓은 테스트 덕분에 출근해서 아무 일도 하지 않았다고 합니다. 그렇게 그는 주 40시간 동안 리그 오브 레전드라는 게임을 즐겼습니다.

물론 테스트의 장점이 태업을 할 수 있다는 것은 아니지만, (장점일지도 모르겠네요 😚) 테스트는 분명히 개발자로 하여금 더 빨리, 그리고 더 잘 개발할 수 있게 합니다. 위 도시전설의 주인공이 주 40시간 동안 게임을 하는 대신 다른 기능을 개발했다고 생각해 보세요!

이처럼 잘 짜여진 테스트는 개발자에게 자신감을 부여하고, 다른 기능들을 더 빨리 개발할 수 있게 합니다. 이렇게 테스트는 좋습니다. 그러면 이제 고민할 필요도 없이 바로 테스트를 쓰러 갈 차례입니다.

그러나 늘 그렇듯이 현실은 녹록치 않습니다.

빠른 개발 환경

순식간에 바뀌는 비즈니스 환경에서 여러분은 애자일하게 반응해야 합니다. 기획은 빠르게 바뀌고, 여러분의 코드도 빠르게 바뀝니다. 즉슨, 여러분의 테스트 코드 또한 빠르게 바뀐다는 것이죠. 여러분이 공들여서 모든 상황을 커버하는 아주 멋진 UI 테스트를 만들었다고 생각해 봅시다. 모든 UI 테스트를 스냅샷으로 저장해서, 단 한줄만 스트링 값이 바뀌어도 바로 감지할 수 있도록 테스트를 작성하였습니다.

그러나 다음날, 새로운 기능이 추가되면서 UI가 대부분 변경되었습니다. 이제 테스트 코드를 다 바꾸어야 할 차례입니다. 한번은 괜찮겠지만, 두번, 세번이 되면 테스트 코드는 오히려 애물단지처럼 느껴지게 될 것입니다.

빠르게 기능을 만들고, 수정하다 보면 열심히 만든 테스트 코드들은 무용지물이 되기 일쑤입니다. 혹은 단순히 테스트 코드를 작성할 시간에 기능을 개발해야 할 상황도 있습니다. 테스트의 이점은 분명하지만 도입 후의 유지보수는 다른 이야기입니다.

이러한 이유로 퍼블에서도 테스트 도입을 고심했습니다. 테스트가 주는 이점에 대해서는 분명히 인지하고 있었지만 도입 자체에는 어려움이 많았죠. 퍼블에서는 각 사용자들이 자신이 원하는 비즈니스를 할 수 있도록 많은 기능들을 제공하고 있습니다. OTT, 소셜 커뮤니티, 실시간 라이브 등등 수많은 기능들이 고도화되고 추가되기를 반복하는 중이었죠. 말 그대로 테스트를 쓸 시간이 없었습니다.

그러나 이처럼 점점 서비스가 성숙해지고, 기능들이 고도화 될 수록 테스트의 필요성이 대두되기 시작하였습니다. 특정 권한을 가진 유저가 라이브를 사용할 경우에 해당 채팅이 어떻게 되어야 하죠? 라는 질문이 들어왔을때, 답하려면 스스로 그 기능이 작동하는지 확인해야 했습니다. 또한 전혀 다른 부분의 업데이트가 기능에 영향을 끼치는 일들이 생기기 시작했죠. 코드를 쓰는데 자신감은 떨어지고, 이것이 어떠한 기능인지 파악하는데 시간이 더 오래 걸리기 시작하였습니다.

따라서 퍼블에서는 이러한 이유로 테스트를 도입하기로 결정하였습니다. 업데이트에 유연하게 대응하고, 개발자들이 자신감 있게 코드를 쓸 수 있도록 테스트 코드를 작성하기로 한 것입니다.

Before we get started

해당 글은 테스트 코드 도입기를 다룬 글입니다. 이 글의 대상은 테스트를 도입하기로 마음 먹은 개발자들을 상대로 쓰여졌습니다. 따라서 높은 테크닉의 테스트와 같은 기능적인 부분이나 TDD와 같은 원론적인 부분은 다루지 않습니다. 또한 해당 글에서는 기본적인 테스트 용어는 설명하지 않습니다. 만약 Unit 테스트 / Intergration 테스트 / E2E 테스트와 같은 단어가 익숙하지 않다면 링크를 참조해 주세요.

대신 이 글에서는 다음과 같은 내용을 다룹니다. 시간과 인력이 부족한 환경에서 무엇을 테스트 해야 하는지에 대해 가이드라인을 설명합니다. 그리고 실제 사례와 비슷한 기획서를 토대로 이를 어떻게 적용할 수 있는지 예시를 통해 설명합니다.

해당 글이 비슷한 상황에서 테스트 도입을 고민하시는 분들에게 도움이 되길 바라며, 시작하도록 하겠습니다.

What To Test

테스트를 도입하기로 마음 먹었습니다. 그러면… 무엇을 테스트 해야 하는 걸까요? 먼저 제 이야기를 잠깐 해보겠습니다.

https://www.hidoc.co.kr/healthstory/news/C0000477381

https://www.hidoc.co.kr/healthstory/news/C0000477381

약간의 강박증 환자로써 저는 집에서 나오기 전에 의식을 거행합니다. 방의 콘센트가 다 꺼졌는지 확인하고 (특히 헤어드라이기), 냉장고에서 물 한잔을 마신 다음 냉장고 문이 잘 닫혔나 확인합니다. 마지막으로 집의 문을 닫고 문이 잘 닫혔나 확인해 봅니다. 이와 같은 의식을 거행함으로써 저는 그제서야 집 밖으로 나올 자신감을 얻습니다. 고백하자면 한번은 콘센트 확인을 하지 않아서 집까지 되돌아간 적도 있습니다.

테스트를 하는 이유는 뭘까요? 불안함을 지루함으로 바꾸기 위해서입니다. 집 밖으로 나갈때 항상 체크하는 것은 지루하고 귀찮지만, 이를 통해 저는 집에 불이 나지 않았을까 하는 불안감을 잠재울 수 있습니다. 개발에서의 테스트도 이와 같습니다. 테스트란 귀찮은 테스트 코드 작성을 통해 상용 환경에 내 코드를 배포할 수 있는 자신감을 얻는 과정인 것입니다.

테스트 코드 작성의 목적을 되짚어 보면, 무엇을 테스트 해야 할지는 간단합니다. 테스트 했을 때 개발자에게 자신감을 주고 불안감을 덜어 주는 것을 테스트 해야 합니다. 짜잔.

How To Test

그러나 이러한 말은 조금 모호합니다. 분명 누군가는 그래서 자신감을 주는 테스트가 뭔데. 어떻게 하는건데? 하고 되묻게 되기 마련이죠. 자신감을 주는 코드는 사람마다 다릅니다. 누군가는 인자의 타입을 테스트하므로써 또 다른 누군가는 잘 짜여진 유저의 시나리오를 통해 자신감을 얻을 수 있습니다. 따라서 단순하게 자신감을 주는 요소를 테스트하라는 원칙 아래서 작업한다면 각자의 테스트가 난립할 것입니다. 그리고 아마 곧 관리되지 않고 버려지겠죠.

무엇을 테스트할지는 알았으니, 이제 어떻게 테스트 할지에 대해서 생각할 차례입니다. 좀 더 구체적으로 테스트를 짤 수 있는 원칙은 없을까요?

Domics의 일러스트

Domics의 일러스트

퍼블에서는 이러한 모호한 원칙을 막고, 한가지의 기준 아래에서 같이 테스트 코드를 작성할 수 있도록 다음과 같은 테스트 원칙을 정하였습니다. 원칙은 간단합니다.

첫째

  • 기획서에 적힌 내용이면 테스트하고, 아니면 테스트 하지 않습니다.
  • 기획서에 적혀 있는 내용이라면 중요한 내용일테니 테스트합니다.
  • 아니면 중요하지 않을 테니 테스트 하지 않습니다.
  • 만약에 테스트 되지 않은 엣지 케이스로 인해 버그가 난다면, 기획서를 업데이트 하고-테스트를 업데이트 합니다.

둘째

  • 기획서에 서술된 유저 behavior만을 테스트합니다.
  • 통합 테스트를 우선시합니다. UI 테스트는 변경이 많고 노력에 비해 자신감의 리턴값이 작기 때문에 왠만해서는 하지 않습니다.
  • (Advanced) 만약 시간이 남는다면, 더 많은 자신감을 얻기 위해 예상되는 유저의 시나리오들을 만들어 테스트합니다.

해당 원칙은 다음과 같은 생각에서 출발하였습니다.

따라서 unit 테스트 보다는 통합 테스트(E2E와 Intergration을 굳이 나누지 않습니다. 각 기능을 통합적으로 테스트만 할 수 있다면 OK)를 지향합니다. 왜냐하면 이것이 실제로 개발자에게 자신감을 줄 수 있는 테스트이기 때문입니다.

그러나 통합 테스트는 테스트 코드를 관리하기 까다롭습니다. 따라서 유지 보수 코스트를 줄이면서 자신감을 가져갈 수 있도록 테스트의 범위는 기획서에 적힌 유저 behavior로 한정합니다. 이렇게 한다면 물론 테스트 커버리지는 낮아집니다. 그러나 높은데 신뢰할 수 없는 테스트 커버리지보다 낮지만 신뢰할 수 있는 테스트가 더 본래의 목적에 부합할 것입니다.

정리하자면 퍼블의 테스트 원칙은 다음과 같습니다. 기획서에 적힌 유저의 행동을 통합적으로 테스트한다. 이것이 핵심입니다.

그러면 이렇게 만들어진 원칙을 실제로 어떻게 적용할 수 있는지 예시를 들어보도록 하겠습니다.

최대한 도메인 내용을 배제하고 실제 업무 프로세스와 유사한 형태로 예시를 들어보도록 하겠습니다. 도메인 지식을 제외하고 원활한 글의 이해를 위해 예시 앱을 생성하였습니다. 해당 글에서 사용할 예시 앱은 트위터와 비슷한 구조의 앱입니다. 유저는 Home / Setting 페이지에 진입할 수 있습니다.

Write tests. Not too many. Mostly integration.

유저의 행동은 기획서에 다음과 같이 나타나게 됩니다. 유저가 A 행동을 하면 B가 일어난다 / 나타난다. 이를 테스트 하는 방식은 두가지 방식이 있습니다. 기능들을 통합적으로 검증하는 Intergration 테스트와 실제 유저가 사용하는 방식으로 테스하는 E2E 방식입니다.

테스트가 실제 앱을 닮을수록 자신감을 많이 준다-라는 말처럼 모든 테스트가 E2E 방식으로 진행되는 것이 베스트이나, 실제로는 여러가지 이유로 테스트를 작성할때 모킹을 해야 하는 경우들이 존재합니다. 실제 서버를 사용할 수 없는 경우, 사용하는 라이브러리를 모킹해야 하는 경우 등등이요. 따라서 통합 테스트를 위하여 두가지 방식을 모두 사용해야 합니다.

이번 글에서는 먼저 E2E 방식으로 모킹하지 않고 실제로 유저의 행동을 테스트 하는 방법을 소개하도록 하겠습니다. Intergration Test에 관한 내용은 다음 글에서 적도록 하겠습니다.

퍼블은 사용자들에게 노코드로 네이티브 앱을 만들어 줄 수 있는 서비스를 제공하고 있습니다. 해당 내용의 테스트는 React Native와 Wix에서 제공하는 Detox를 사용합니다. 만약 웹에서 해당 내용을 구현하려면 Cypress나 Playwright를 사용하면 되겠습니다. Detox의 설치와 적용에 관한 내용은 해당 공식 문서를 참조해 주시길 바랍니다.

자, 그러면 화창한 어느 날 회사에 출근한 여러분은 다음과 같은 업무를 부여받게 되었습니다.

해당 기획서대로 개발을 완료했습니다. 이제 배포하고 QA팀에게, 혹은 기획자에게 해당 기능을 건네주기 전에 테스트 코드를 작성해 봅시다.

- 유저가 Age input을 눌렀을 경우
  - 값을 입력할 수 있다

- 유저가 유효하지 않은 값을 입력했을 경우
  - 에러 메세지가 보여질 수 있다
  - 버튼을 누를 수 없다

- 유저가 수정 버튼을 눌렀을 경우
  - 수정에 성공한 경우
    - 수정 성공 얼럿이 떠야 한다
    - 확인 버튼을 누르면 Setting으로 이동할 수 있다
  - 수정에 실패한 경우
    - 수정 실패 얼럿이 떠야 한다
    - 확인 버튼을 누르면 Alert을 숨길 수 있다

아까 설명한 원칙에 따르면 우리가 테스트 할 것은 유저의 동작에 따른 결과들입니다. 해당 내용을 테스트하면 완료입니다.

테스트 환경 설정

beforeAll(async () => {
  await device.launchApp({newInstance: true});
});

beforeEach(async () => {
  await device.reloadReactNative();
  await navigateToEditProfile();
});

const navigateToEditProfile = async () => {
  const settingTabButton = element(by.id('SettingTab'));
  await settingTabButton.tap();

  const settingPage = element(by.id('SettingPage'));
  await expect(settingPage).toBeVisible();

  const NavigateToEditProfileButton = element(by.id('NavigateToEditProfile'));
  await NavigateToEditProfileButton.tap();

  const editProfilePage = element(by.id('EditProfilePage'));
  await expect(editProfilePage).toBeVisible();
};

우선 실제 테스트 코드를 구현하기 전에 알맞은 테스트 환경을 구축해야 합니다. 올바른 테스트를 위해서는 외부 변수들을 잘 차단해서 격리된 테스트 환경을 조성해야 합니다. 따라서 각 테스트 전에 새롭게 앱 인스턴스를 띄우고, React Native의 Hot Reload 기능을 통해 번들을 재시작 하도록 하겠습니다.

또한 아래에 해당 테스트가 이루어지는 EditProfile 페이지로 이동할 수 있도록 공용 함수를 추가해주도록 합시다. 이제 테스트마다 항상 앱을 fresh한 상태로 유지할 수 있게 되었습니다.

이제 본격적으로 테스트 코드를 작성할 차례입니다.

- 유저가 Age input을 눌렀을 경우
  - 값을 입력할 수 있다
describe('유저가 Age input을 눌렀을 경우', () => {
  it('값을 입력할 수 있다', async () => {
    const ageInput = element(by.id('AgeInput'));
    await ageInput.replaceText('123');
    await ageInput.tap();

    await expect(ageInput).toHaveValue('123');
  });
});
- 유저가 유효하지 않은 값을 입력했을 경우
  - 에러 메세지가 보여질 수 있다
  - 버튼을 누를 수 없다
describe('유저가 유효하지 않은 값을 입력했을 경우', () => {
  it('에러 메세지가 보여질 수 있다', async () => {
    const ageInput = element(by.id('AgeInput'));
    await ageInput.replaceText('aef');
    await ageInput.tap();

    const editProfileButton = element(by.id('EditProfileButton'));

    await expect(editProfileButton).not.toBeFocused();
  });

  it('버튼을 누를 수 없다', async () => {
    const ageInput = element(by.id('AgeInput'));
    await ageInput.replaceText('aef');
    await ageInput.tap();

    const editProfileButton = element(by.id('EditProfileButton'));
    await editProfileButton.tap();

    await expect(editProfileButton).not.toBeFocused();
  });
});

코드만 봐도 어떠한 내용을 테스트 하는지 이해가 쉽게 갈 것이라 생각합니다. 유저가 유효하지 않은 값을 입력한 경우, 기획서에 적힌 대로 해당 행동이 이루어지는지를 검사합니다.

- 유저가 수정 버튼을 눌렀을 경우
  - 수정에 성공한 경우
    - 수정 성공 얼럿이 떠야 한다
    - 확인 버튼을 누르면 Setting으로 이동할 수 있다
  - 수정에 실패한 경우
    - 수정 실패 얼럿이 떠야 한다
    - 확인 버튼을 누르면 Alert을 숨길 수 있다

위에서 본 케이스들은 특별한 API 요청 없이 프론트 내부의 로직이 잘 돌아가는지 기획서에 따라 검증했습니다. 다만 해당 케이스에서는 버튼을 누를 경우 API 요청을 보내게 됩니다. 그리고 결과에 따라서 특정 행동들이 이루어지게 되죠.

테스트에서는 외부 변수를 차단하기 위해 목 데이터를 사용하는 것이 일반적이지만 E2E 테스트에서는 일반적으로 모킹을 하지 않습니다. 실제 서버에서는 오류가 발생해서 데이터 값이 잘못 내려오고 있는데 모킹을 하고 있다면 테스트를 통해서 해당 오류를 잡아낼 수 없겠죠.

그러나 소셜 로그인 기능, 핸드폰이라면 카메라 테스트 등 모든 경우를 모킹하지 않고 대응할 수 없는 경우가 많습니다. 따라서 라이브러리 모킹, API 요청을 Intercept 해서 미리 정한 값을 돌려주는 MSW, Mirage JS와 같은 라이브러리들을 사용해 테스트를 하는 경우가 많은데 해당 내용은 Intergration 테스트를 다루는 다음 글에서 알아보도록 하겠습니다.

describe('유저가 수정 버튼을 눌렀을 경우', () => {
  describe('수정에 성공한 경우', () => {
    const ageInput = element(by.id('AgeInput'));
    const editProfileButton = element(by.id('EditProfileButton'));

    it('수정 성공 얼럿이 떠야 한다', async () => {
      await ageInput.replaceText('1234');
      await editProfileButton.tap();

      const successAlert = element(by.text('Profile edited'));
      await expect(successAlert).toBeVisible();
    });

    it('확인 버튼을 누르면 Setting으로 이동할 수 있다', async () => {
      await ageInput.replaceText('1234');
      await editProfileButton.tap();

      const successAlertButton = element(by.text('OK'));
      await successAlertButton.tap();

      const SettingPage = element(by.id('SettingPage'));
      await expect(SettingPage).toBeVisible();
    });
  });

  describe('수정에 실패한 경우', () => {
    const ageInput = element(by.id('AgeInput'));
    const editProfileButton = element(by.id('EditProfileButton'));

    it('수정 실패 얼럿이 떠야 한다', async () => {
      await ageInput.replaceText('12');
      await editProfileButton.tap();

      const errorAlert = element(by.text('Error'));
      await expect(errorAlert).toBeVisible();
    });

    it('확인 버튼을 누르면 Alert을 숨길 수 있다', async () => {
      await ageInput.replaceText('12');
      await editProfileButton.tap();

      const errorAlertButton = element(by.text('OK'));
      await errorAlertButton.tap();

      await expect(errorAlertButton).not.toBeVisible();
    });
  });
});

다시 돌아와서, 해당 시나리오의 테스트 케이스는 다음과 같습니다. 실제 코드에서는 이미 기획서를 기반으로 서버에서 오는 리스폰스에 따라 성공 실패 분기 처리가 되어 있을 것이므로, 우리는 테스트에서 해당 내용이 실제로 보이는지만 확인하면 됩니다.

모두 성공!

모두 성공!

테스트를 도입해서 얻은 장점들

이렇게 해서 기능 개발 후 테스트 원칙에 따라서 테스트 코드를 작성하였습니다. 실제로 퍼블도 다음과 같은 원칙에 따라 기존에 작성되었던 기능부터 테스트를 도입하고 있습니다. 아직 커버리지는 높지 않지만 이미 좋은 결과를 보여주고 있구요. 도입하면서 얻게 된 이점들은 다음과 같습니다. 예상했던 부분도 있었고, 예상하지 않았지만 더 좋았던 부분도 있었습니다.

우선 첫 번째로 중요한 것은 개발자들이 자신감을 가지게 되었다는 것입니다. 이제 새로운 기능을 개발할때 해당 기능이 기존의 기능을 망가트리진 않을지 의심하지 않게 되었습니다. 실제 사이드 이펙트가 발견되었다면 테스트가 fail 할 것이고, 그러면 수정하면 끝입니다. 또한 기존 기능을 리팩토링하는 것에도 거리낌이 없어졌습니다.

테스트 코드는 불안감을 지루함으로 바꾸는 과정이라는 말이 생각나시나요? 이처럼 코드 작성의 지루함은 얻었지만, 불안함은 사라졌습니다.

두번째로는 도메인에 대한 이해가 빨라졌다는 것입니다. 도메인이 복잡하다보니 다른 사람의 작업을 이해하는데는 굉장히 오랜 시간이 걸리고는 했습니다. 그러나 이제는 코드를 보는 것이 아니라 먼저 테스트 코드를 통해 이것이 어떻게 동작 하는지 파악할 수 있었습니다. 많은 경우에 코멘트를 남겨서 도메인에 대한 설명을 했어야 했는데, 이제 테스트 코드를 보고 소통이 가능해졌죠. 또한 유저의 행동들을 작성하면서 예상치 못한 유저의 행동들을 잡아낼 수 있었습니다.

세번째는 리팩토링 과정에서의 원칙까지 챙길 수 있었다는 것입니다. 지금까지는 컴포넌트 분리에 대한 원칙이 조금 모호했다면, 이제는 테스트 하기 좋은 범위-단일 책임 원칙에 따라서 코드들을 리팩토링 할 수 있었습니다. 해당 부분은 다음글에서 Intergration 테스트 예제와 함께 더 자세하게 설명하도록 하겠습니다.


Test: Why, What, How? E2E 테스트 도입기 was originally published in 퍼블 팀 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.