소프트웨어 개발 방법론 중 하나로, 개발 자체가 “테스트"에 의해 주도되는 방식이다. 제품의 기능 구현을 위한 코드와 별개로, 해당 기능이 정상적으로 움직이는지 검증하기 위한 테스트 코드를 먼저 작성한다.
일반적인 개발방식을 사용하는 경우, 소비자의 요구사항이 처음부터 명확하지 않을 수 있어 처음부터 완벽한 설계는 어렵다. 또한 반복되는 재설계와 코드의 재작성은 제품의 품질을 떨어뜨린다. 작은 기능 수정에도 모든 부분을 다시 테스트해야 하므로, 테스트 비용이 증가한다.
TDD는, 기능별로 테스트코드와 제품코드를 작성하므로 종속성과 의존성이 낮은 모듈화된 방식으로 개발하게 된다. 이는 재설계, 디버깅 시간을 단축시키고 추가 구현에 용이하다. (특히 기존 코드에 미칠 수 있는 영향을 최소화할 수 있다)
페이스북에서 만든 JavaScript 테스팅 라이브러리이다. react-native-cli로 프로젝트 구성시, 이미 dev dependency에 포함되어있다.
에어비앤비에서 만든 React 컴포넌트 테스팅 라이브러리이다. enzyme, enzyme-adapter-react, react-dom를 함께 설치한다.
사용자 관점에서 End to End로 테스트하기 위한 라이브러리이다. android/ios 모두 지원하지만 우선은 ios에서만 테스트해보았다. applesimutils, detox-cli, detox, jest-circus를 함께 설치한다.
아래와 같은 간단한 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의 End To End 테스트 기능을 통해 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는 공동의 목표를 효율적으로 달성하기 위한 “도구”로, 잘 사용하는 것은 결국 우리들의 손에 달려있다는 것을 잊어서는 안된다
많은 도움이 되었습니다 ^^