TDD 적용하기 : ReactNative 실전편

dalbodre·2022년 4월 20일
3

study

목록 보기
6/6
post-thumbnail

TDD란

Test Driven Development란

소프트웨어 개발 방법론 중 하나로, 개발 자체가 “테스트"에 의해 주도되는 방식이다. 제품의 기능 구현을 위한 코드와 별개로, 해당 기능이 정상적으로 움직이는지 검증하기 위한 테스트 코드를 먼저 작성한다.

TDD 개발 단계

  1. ReadMe Driven Development : 요구사항 기술
  2. Make it RED(fail) : 테스트 코드 작성
  3. Make it GREEN(success) : 실제 코드 작성
  4. Make it BLUE(refactoring) : 최소한으로 작성된 코드 리팩토링

왜 TDD를 사용하는가?

일반적인 개발방식을 사용하는 경우, 소비자의 요구사항이 처음부터 명확하지 않을 수 있어 처음부터 완벽한 설계는 어렵다. 또한 반복되는 재설계와 코드의 재작성은 제품의 품질을 떨어뜨린다. 작은 기능 수정에도 모든 부분을 다시 테스트해야 하므로, 테스트 비용이 증가한다.
TDD는, 기능별로 테스트코드와 제품코드를 작성하므로 종속성과 의존성이 낮은 모듈화된 방식으로 개발하게 된다. 이는 재설계, 디버깅 시간을 단축시키고 추가 구현에 용이하다. (특히 기존 코드에 미칠 수 있는 영향을 최소화할 수 있다)

라이브러리 설치 및 소개

JEST

페이스북에서 만든 JavaScript 테스팅 라이브러리이다. react-native-cli로 프로젝트 구성시, 이미 dev dependency에 포함되어있다.

  • describe() : 여러 개의 테스트 코드를 하나의 테스트 작업 단위로 묶어주는 API
  • test() : 테스트 코드를 돌리기 위한 API. 하나의 테스트 케이스를 의미
  • expect() : 테스트할 대상(기대 값)을 넣는 API
  • beforeEach() : 각 테스트 코드가 돌기 전에 반복적으로 수행할 로직을 넣는 API

Enzyme

에어비앤비에서 만든 React 컴포넌트 테스팅 라이브러리이다. enzyme, enzyme-adapter-react, react-dom를 함께 설치한다.

  • shallow: 간단한 컴포넌트를 메모리에 렌더링 (단일 컴포넌트를 테스트)
  • mount: HOC나 자식 컴포넌트 까지 전부 렌더링 (다른 컴포넌트와의 관계 테스트 )
  • render: 컴포넌트를 정적인 html로 렌더링 (컴포넌트가 브라우저에 붙었을 때 html로 어떻게 되는지 판단할 때 사용)

Detox

사용자 관점에서 End to End로 테스트하기 위한 라이브러리이다. android/ios 모두 지원하지만 우선은 ios에서만 테스트해보았다. applesimutils, detox-cli, detox, jest-circus를 함께 설치한다.

  • 실제로 사용자 관점에서 테스트하기위한 async 기능 제공

ToDoList 테스트코드 작성

컴포넌트 렌더링 및 props 확인

아래와 같은 간단한 ToDo 리스트에 대한 테스트코드를 작성해보자!

먼저 앱을 처음 실행했을 때에 화면 제목으로 사용될 Text 컴포넌트 내에 "TODO TDD"라는 내용이 들어가있는지, AddToDo와 ToDoList가 존재하는지는 아래와 같이 작성할 수 있다.

describe('App', () => {
  const wrapper = shallow(<App></App>);
  test('snapshot test', () => {
    expect(wrapper).toMatchSnapshot();
  });
  it('is Text visible?', () => {
    expect(wrapper.find('Text').contains('ToDo TDD')).toBe(true);
  });
  it('is AddToDO visible?', () => {
    expect(wrapper.find('AddToDo')).toHaveLength(1);
  });
  it('is ToDoList visibla?', () => {
    expect(wrapper.find('ToDoList')).toHaveLength(1);
  });
});

AddToDo 컴포넌트는 TextInput과 버튼으로 구성되어있고, 버튼 클릭 시 TextInput 내부에 있는 내용으로 (props로 가지고 있는) OnAdded 함수를 호출한다. 내부에 해당하는 컴포넌트가 있는지 확인하는 동작은 위의 코드와 비슷하게 작성하면 되니, 함수 호출과 관련된 부분의 작성법은 아래와 같다. 테스트를 진행하기 전에 AddToDo를 렌더링하고 TextInput 내용을 작성하고 버튼을 누른다. 이후 it에서 OnAdded 함수가 불렸는지, 텍스트 내용을 가지고 불렸는지를 확인한다.

describe('AddToDo Interaction', () => {
  let wrapper;
  let props;
  const text = 'Add To Do Item';

  beforeEach(() => {
    props = {
      onAdded: jest.fn(),
    };
    wrapper = shallow(<AddToDo {...props}></AddToDo>);
    wrapper.find('TextInput').simulate('changeText', text);
    wrapper.find('Button').prop('onPress')();
  });

  it('should call the onAdded callback with input text', () => {
    expect(props.onAdded).toHaveBeenCalledTimes(1);
    expect(props.onAdded).toHaveBeenCalledWith(text);
  });
});

ToDoRenderITem에 대한 테스트 코드 작성도 비슷하다. 기본적으로 내부에 Text, Button들이 알맞게 자리하고 있는지, 초기 스타일은 어떻고 만약 props로 특정 데이터가 들어오면 어떤 다른 스타일을 가지고있는지를 확인한다. 이후 각 버튼이 클릭됨에 따라 적절한 함수가 알맞게 불렸는지를 확인할 수 있다.

// component rendering
describe('rendering', () => {
  let warpper;
  let props;

  beforeEach(() => {
    props = {
      item: {},
    };
    wrapper = shallow(<ToDoRenderItem {...props}></ToDoRenderItem>);
  });

  it('should render a Text', () => {
    expect(wrapper.find('Text')).toHaveLength(1);
  });

  it('should render two buttons', () => {
    expect(wrapper.find('Button')).toHaveLength(2);
  });

  describe('Uncompleted', () => {
    it('should have the default style', () => {
      expect(wrapper.prop('style')).toBe(styles.default);
    });
  });

  describe('Completed', () => {
    beforeEach(() => {
      props.item.completed = true;
      wrapper = shallow(<ToDoRenderItem {...props}></ToDoRenderItem>);
    });
    it('should have the completed style', () => {
      expect(wrapper.prop('style')).toBe(styles.completed);
    });
  });
});

// interaction
describe('interaction', () => {
  let wrapper;
  let props;
  beforeEach(() => {
    props = {
      item: {text: 'first ToDo', completed: false},
      index: 0,
      onCompleted: jest.fn(),
      onDeleted: jest.fn(),
    };

    wrapper = shallow(<ToDoRenderItem {...props}></ToDoRenderItem>);
  });

  it('Complete feature', () => {
    wrapper.find('Button').at(0).prop('onPress')();
    expect(props.onCompleted).toHaveBeenCalledTimes(1);
    expect(props.onCompleted).toHaveBeenCalledWith(props.index);
  });

  it('Delete feature', () => {
    wrapper.find('Button').at(1).prop('onPress')();
    expect(props.onDeleted).toHaveBeenCalledTimes(1);
    expect(props.onDeleted).toHaveBeenCalledWith(props.index);
  });
});

뭔가 세미나에서 이야기하다가 그럼 이 모든 코드를 하나의 파일로 작성해야 하냐는 질문을 받았는데, yarn test 명령어를 실행하면 해당 폴더 내의 모든 테스트코드 파일들이 실행되고 확인되니 컴포넌트별, 기능별로 알맞게 잘 나눠서 쓰면 된다

처음 파일을 실행하면 모든 테스트들이 실패했다고 나올 것이다. 앞에서 말했던 RED단계이다! 이제 테스트코드들을 통과할 수 있는 GREEN 단계를 진행하면 된다. (BLUE 단계는 이후 테스트 코드와 관계 없이 진행되는 단계임을 잊지말자) 만약 해당하는 테스트 코드를 다 통과할 수 있도록 코드를 작성했다면, yarn test 명령어를 통해 실행할 경우 아래와 같은 결과를 확인할 수 있을 것이다.

Detox를 통해 E2E 동작 확인하기

컴포넌트의 렌더링이나 내부 데이터 확인 등은 앞과 같이 확인할 수 있지만, 사용자가 실제로 서비스를 사용하는 시나리오들이 모두 정상적으로 동작하는지는 어떻게 확인할 수 있을까? Detox의 End To End 테스트 기능을 통해 TodoRenderItem을 사용자가 직접 추가/완료, 삭제하는 과정이 정상적으로 동작하는지를 확인해보도록 하자.

  • 추가 및 완료 : 사용자가 입력 필드에 내용을 입력하고, 추가 버튼을 클릭하고, 미완료 상태의 ToDoRenderItem이 정상적으로 추가되었는지를 확인한다. 이후 완료 버튼을 클릭하면 해당 아이템이 완료 상태로 전환되었는지를 확인하면 완료된다.
  • 삭제 : 앞과 비슷하게 입력 필드에 내용을 입력하고, 추가 버튼 클릭, 목록에 추가되는지를 확인한다. 이후 삭제 버튼을 클릭했을 때에 목록에 아이템이 삭제되어 표시되지 않는지를 확인한다.
describe('Interaction Test', () => {
  beforeAll(async () => { // 처음에 앱을 실행하고
    await device.launchApp();
  });
  beforeEach(async () => { // 매 테스트마다 리로드를 통해 혹시 모를 영향을 끼칠 요소들을 방지하자
    await device.reloadReactNative();
  });

  it('Completing ToDo Item should work!', async () => {
    const text = '입력 완료 여부 체크';
    await element(by.id('textInput')).tap();
    await element(by.id('textInput')).typeText(text);
    await element(by.id('addButton')).tap();
    await element(by.id('completeButton')).multiTap(1);
    await expect(
      element(
       // toDoList라는 아이템 내에 '입력 완료 여부 체크'라는 내용을 
       // 가진 텍스트가 있는지 확인하고, 
       // 그 아이디가 현재 testId로 'completed'를 가지고있는지 확인한다.
        by.id('completed').and(by.text(text)).withAncestor(by.id('toDoList')),
      ),
    ).toBeVisible(); 
  });

  it('Deleting ToDo Item should work!', async () => {
    const text = '입력 삭제 여부 체크';
    await element(by.id('textInput')).tap();
    await element(by.id('textInput')).typeText(text);
    await element(by.id('addButton')).tap();
    await element(by.id('deleteButton')).multiTap(1);
    await expect(
      element(by.text(text).withAncestor(by.id('toDoList'))),
    ).not.toBeVisible(); // not toBeVisible
  });
});

detox build -c ios로 빌드하고 detox test -c ios로 테스트하면 아래와 같은 결과를 확인할 수 있다. 테스트 커맨드 실행시 아래와같이 실제로 앱이 동작하면서 사용자가 텍스트 입력, 버튼을 클릭하는 것을 그대로 실행하면서 시나리오가 진행되는 것을 눈으로 보면서 테스트할 수 있다.

실행 중에 터미널을 확인하면 해당 테스트가 실행됨에 따라 실행이 정상적으로 완료되었다고 [OK] 문구가 추가되고, 테스트에 성공/실패했는지 여부를 자세하게 알려준다. QA 시간을 획기적으로 단축할 수 있어보여 개인적으로 너무 마음에 들었다.

중간에 코드를 바꾼 이후 리로드되어 자동으로 코드에 반영되어야 한다고 생각했는데, 정작 세미나에서 계속 안되어서 아예 detox build된 내용을 아예 날리고 확인하니 정상적으로 진행되었다. (게다가 xcode clean build를 위한 커맨드를 날리고 다시 빌드했었는데, 알고보니 그 클린 커맨드가 작동이 안되었었다.. xcode로 빌드된게 아니라나 뭐라나 ㅠㅠㅠ 엄청 당황했다!!) 안되면 그냥 detox 공식문서에서 하라는 대로 하자..!

꼭 TDD를 써야할 필요는 없다.

물론, 모든 프로젝트와 기타 모든 상황에서 TDD를 무조건 사용해야 하는 것은 아니다. TDD를 처음 도입할 때에는 이를 사용하지 않을 때보다 초기비용이 크게 들기 마련이다(테스트코드 작성). 또한 TDD는 버그를 사전에 발견할 수 있게 하는 것일 뿐, 버그 “해결"은 결국 개발자의 몫이라는 것도 잊어서는 안된다. 게다가 단순히 반복적으로 테스트코드를 복붙하고 의미없이 사용하는 것은 업무프로세스 증가로 이어지기만 할 뿐이다. TDD는 공동의 목표를 효율적으로 달성하기 위한 “도구”로, 잘 사용하는 것은 결국 우리들의 손에 달려있다는 것을 잊어서는 안된다

profile
휘뚜루마뚜루

1개의 댓글

comment-user-thumbnail
2022년 7월 4일

많은 도움이 되었습니다 ^^

답글 달기