도입

내가 테스트를 작성하기 시작한 것은 약 2달 전이다. 그 전까지는 테스트 코드를 작성하지 않았다. 해본 적이 없었고, 어떻게 시작하는 지 몰라서였다. 구글에 검색해서 나오는 글들은 대부분 간단하고 부작용(side effect)이 없는 순수 함수들을 예제로 하고 있어서, 그렇지 않은 코드가 더 많은 실무 코드에 활용하기가 쉽지 않았다. 이 글에서는 내가 실제로 *테스트 없이 작성되어 있는 기존 코드에 어떻게 테스트를 추가하고, 동시에 TDD를 했는 지 이야기 하려고 한다. 참고로 예제 코드는 React로 작성되었고, 테스트 프레임 워크로는 Jest를 사용하였다.

*테스트: 이 글에서 말하는 테스트는 단위 테스트(unit test)이다.


1. 함수로 분리하자.

프로젝트의 전반적인 컴포넌트 구조는

  • [부모] Redux와 API 요청 및 React 라이프 사이클 함수를 호출하는 Container 컴포넌트

  • [자식] 실제 View를 반환하는 순수 함수로 작성된 Presentational 컴포넌트

이렇게 두 컴포넌트가 중심이 된다.

사이드 이펙트가 발생할 수 있는 모든 요소는 Container 컴포넌트에 있고, Presentational 컴포넌트는 최대한 순수하게 유지하고 있다. 그렇게 하다보니, Container 컴포넌트가 정말 길고 복잡하고, 가독성도 매우 떨어졌다. 내가 테스트를 위해 가장 먼저 한 일은 Container 컴포넌트에 숨어 있는 비즈니스 로직을 순수 함수로 추출하는 일이다. 비즈니스 로직은 DOM 조작이 필요하지 않기 때문에 순수 함수로 분리해낸다면 비교적 쉽게 테스트 할 수 있다.

아래는 앞으로 글 전반에서 사용하는 예제의 풀 버전이다. 안 보고 넘어가도 글의 흐름을 이해하는 데 문제는 없다. 예제 코드는 실제 코드의 복잡도를 재현하고 싶어서 최대한 작성해 보았지만 100% 동작하는 코드는 아니다. API 요청은 fake 함수로 대체하였다.

1) 탐색

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// RegistrationFormContainer.js
import React, { Component } from 'react';
// (이하 생략)

class RegistrationFormContainer extends Component {
handleCloseModal = e => {
e.preventDefault();
UserActions.closeModal();
};

handleSubmit = e => {
e.preventDefault();
this.form.validateFields(async (err, validValues) => {
if (err) {
return;
}

// ************* 함수로 추출할 부분은 바로 여기 이다!! *****************
const phone = validValues.phone
? '+82' + validValues.phone.slice(1)
: null;

const command = {
username: validValues.username || validValues.email,
email: validValues.email,
password: validValues.password,
phone: phone || null,
agreement: validValues.agreement,
};
// ************************************************************

try {
await UserActions.postRegistration(command); // API 요청
this.handleCloseModal(); // 요청이 성공하면 모달을 닫는다.
} catch (err) {
Modal.error({ // 요청이 실패하면 에러 모달을 트리거한다.
message: 'Register failed.',
icon: true,
});
}
});
};

render() {
return ( /* UI */ );
}
}

export default connect(({ user }) => ({
modalVisible: user.getIn(['modal', 'visible']),
}))(RegistrationFormContainer);

2) 분리

processRegistrationCommand 라는 이름의 함수를 외부 파일로 생성하고, 일단 발견한 부분을 무작정 추출해온다. 당연히 코드는 정상적으로 돌아가지 않을 것이다. 아무것도 수정하지 않는다. 다만, 어떤 것을 리턴할 것인지만 정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// processRegistrationCommand.js
export default function processRegistrationCommand() {
const phone = validValues.phone ? '+82' + validValues.phone.slice(1) : null;

const command = {
username: validValues.username || validValues.email,
email: validValues.email,
password: validValues.password,
phone: phone || null,
agreement: validValues.agreement,
};

return command;
}

아까의 RegistrationFormContainer 컴포넌트에서 새로 만든 함수를 불러와서 적용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// RegistrationFormContainer.js
import processRegistrationCommand from '../business/processRegistrationCommand';
// (생략)

class RegistrationFormContainer extends Component {
// (생략)
handleSubmit = e => {
e.preventDefault();
this.form.validateFields(async (err, validValues) => {
if (err) {
return;
}

// ***************************** 함수 적용 **********************
const command = processRegistrationCommand();
// ************************************************************

try {
await UserActions.postRegistration(command); // API request
this.handleCloseModal();
} catch (err) {
Modal.error({
message: 'Register failed.',
icon: true,
});
}
});
};
// (생략)
}


2. 함수 스펙을 작성한다.

이제 테스트 파일을 만든다. 그리고 아까 추출한 processRegistrationCommand 함수를 불러온다. 그리고 describe, test 구문을 이용해 스펙을 작성한다. 처음부터 모든 시나리오를 빠짐없이 적으려 할 필요는 없다. 천천히 하나씩 스펙을 추가해도 상관없다. 시작은 주로 핵심 기능으로 시작하는 데, 이는 대부분 Input, Output에 드러나있다. 아래는 테스트는 핵심 기능에 대한 스펙들이 미리 추가된 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// processRegistrationCommand.test.js
import processRegistrationCommand from './processRegistrationCommand';

describe('processRegistrationCommand 함수는', () => {
test('오류를 던지지 않는다.', () => {
expect(processRegistrationCommand).not.toThrowError();
});

describe('Registration 요청 커맨드 객체 형태로 정제하여 반환하는 데, ', () => {
test('username 속성의 default 값은 email이다.');
test('email 속성은 입력 값과 동일하다.');
test('email 속성은 입력 값과 동일하다.');
test('phone 속성은 옵션 값이며, default 값은 null이다.');
test('phone 속성은 첫 번째 0 대신 +82가 포함된 문자열이다.');
test('agreement 속성은 입력 값과 동일하다.');
});
});


3. 테스트 코드를 작성해보자!

1) 실패하는 테스트

현재 첫 번째 테스트는 실패하고 있다. processRegistrationCommand 함수가 현재 오류를 던지고 있기 때문이다. 테스트가 통과하도록 프로덕션 코드를 수정해준다. validValues 변수가 해당 함수의 스코프 내에서 정의되지 않아 오류가 발생한 것이기 때문에, 해당 변수를 파라미터로 받게 하여 오류를 제거한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function processRegistrationCommand(validValues = {}) {
const phone = validValues.phone ? '+82' + validValues.phone.slice(1) : null;

const command = {
username: validValues.username || validValues.email,
email: validValues.email,
password: validValues.password,
phone: phone || null,
agreement: validValues.agreement,
};

return command;
}


2) 성공하는 테스트

나머지 테스트 코드를 하나씩 채워볼 것이다. 두번째 테스트는 실패하지 않을 것이다. 이미 모두 구현되어 있기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import processRegistrationCommand from './processRegistrationCommand';

describe('processRegistrationCommand 함수는', () => {
test('오류를 던지지 않는다.', () => {
expect(processRegistrationCommand).not.toThrowError();
});

describe('Registration 요청 커맨드 객체 형태로 정제하여 반환하는 데, ', () => {
test('username 속성의 default 값은 email이다.', () => {
const param = {
username: undefined,
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
};

const actual = processRegistrationCommand(param);
expect(actual.username).toBe(param.email);
});
// ...
});
});

세번째, 네번째 테스트도 추가한다. 물론 성공하는 테스트이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
test('email 속성은 입력 값과 동일하다.', () => {
const param = {
username: 'username',
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
};

const actual = processRegistrationCommand(param);
expect(actual.email).toBe(param.email);
});

test('password 속성은 입력 값과 동일하다.', () => {
const param = {
username: 'username',
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
};

const actual = processRegistrationCommand(param);
expect(actual.password).toBe(param.password);
});

이쯤하면 약간 거슬리는 부분이 생긴다. 바로, 동일한 param 객체를 반복해서 생성하고 있는 부분이다. param 객체와 같이 테스트를 위해 생성되는 데이터를 Fixture 라고 한다. 위와 같이 여러 테스트에서 반복적으로 사용되는 Fixture는 좀 더 상위 스코프에 선언하는 편이 낫다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
describe('processRegistrationCommand 함수는', () => {
// ...
describe('Registration 요청 커맨드 객체 형태로 정제하여 반환하는 데, ', () => {
const param = {
username: 'username',
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
};

test('username 속성의 default 값은 email이다.', () => {
const actual = processRegistrationCommand({
...param,
username: undefined,
}); // <- 테스트가 검증하고자 하는 바가 username이 undefined 일 때 임이 한눈에 보인다.
expect(actual.username).toBe(param.email);
});

test('email 속성은 입력 값과 동일하다.', () => {
const actual = processRegistrationCommand(param);
expect(actual.email).toBe(param.email);
});

test('password 속성은 입력 값과 동일하다.', () => {
const actual = processRegistrationCommand(param);
expect(actual.password).toBe(param.password);
});

test('phone 속성은 옵션 값이며, default 값은 null이다.');
test('phone 속성은 첫 번째 0 대신 +82가 포함된 문자열이다.');
test('agreement 속성은 입력 값과 동일하다.');
});
});

이렇게 Fixture를 상위 스코프에 선언해주면, 동일한 또는 유사한 Fixture를 반복적으로 생성하지 않아도 되기 때문에 코드가 훨씬 간결해질 뿐 아니라, 테스트에서 검증하고자 하는 특정 값이 더 명확하게 보여 테스트의 가독성을 높이는 효과를 얻을 수 있다.

이제 나머지 테스트도 채워보자. 이번에도 모두 성공하는 테스트이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
test('phone 속성은 옵션 값이며, default 값은 null이다.', () => {
const actual = processRegistrationCommand({ ...param, phone: undefined });
expect(actual.phone).toBeNull();
});

test('phone 속성은 첫 번째 0 대신 +82가 포함된 문자열이다.', () => {
const actual = processRegistrationCommand(param);
expect(actual.phone).toBe(`+82${param.phone.slice(1)}`);
});

test('agreement 속성은 입력 값과 동일하다.', () => {
const actual = processRegistrationCommand(param);
expect(actual.agreement).toBe(param.agreement);
});

전체적으로 한번 쭉 보니, 대부분의 테스트가 동일한 패턴으로 반복되고 있다. 동일하게 반복되는 이러한 패턴에 맘에 들지 않는다면, 이것도 간략하게 정리할 수 있다. 아래는 정리하고 난 후의 전체 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import processRegistrationCommand from './processRegistrationCommand';

describe('processRegistrationCommand 함수는', () => {
test('오류를 던지지 않는다.', () => {
expect(processRegistrationCommand).not.toThrowError();
});

describe('Registration 요청 커맨드 객체 형태로 정제하여 반환하는 데, ', () => {
const param = {
username: 'username',
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
};

const equalToParam = ['name', 'password', 'agreement'];
equalToParam.forEach(prop => {
test(`${prop} 속성은 입력 값과 동일하다.`, () => {
const actual = processRegistrationCommand(param);
expect(actual[prop]).toBe(param[prop]);
});
});

const defaultOf = [
{
key: 'username',
expected: param.email,
},
{
key: 'phone',
expected: null,
},
];
defaultOf.forEach(({ key, expected }) => {
test(`${key} 속성은 옵션 값이며, default 값은 ${expected}이다.`, () => {
const actual = processRegistrationCommand({
...param,
[key]: undefined,
});

expect(actual[key]).toBe(expected);
});
});

test('phone 속성은 첫 번째 0 대신 +82가 포함된 문자열이다.', () => {
const actual = processRegistrationCommand(param);
expect(actual.phone).toBe(`+82${param.phone.slice(1)}`);
});
});
});

이런 식으로 반복되는 테스트 코드의 패턴을 파악해서 코드의 양을 줄일 수 있을 뿐 아니라, equalToParam이나 defaultOf 같은 Fixture만 보고도 대략적인 내용을 파악할 수 있게 되어 가독성 면에서도 더 나아진다. 하지만 이 가독성이라는 게 다소 상대적인 것이라, 이전의 나열 방식이 더 낫다고 생각할 수도 있다. 그리고 이처럼 Fixture와 반복문을 이용해 중복을 제거하는 방식이 항상 가독성이 더 좋은 것도 아니다. 상황에 따라, 선호도에 따라, 적절히 잘 선택하면 된다.


3) Refactor - 테스트가 실패하지 않는 범위 내에서 코드 개선하기

테스트를 마쳤고 모든 테스트가 성공하는 것을 확인했으니 이제 리팩터링을 할 수 있다. 리팩터링 단계에서 중요한 것은, 테스트가 실패하지 않는 범위 내에서만 코드를 개선하는 것이다. 이 말은, 리팩터링 하다가 테스트가 실패하면 테스트를 고치라는 것이 아니다. 리팩터링을 하는 중에는 절대 테스트에 아무런 변화도 가해선 안 된다. 리팩터링을 하다가 테스트가 실패한다면, 분명 프로덕션 코드에 버그를 만든 것이니 하던 것을 중단하고 코드를 다시 되돌려 놓아야 한다. 아래는 현재 프로덕션 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// processRegistrationCommand.js
export default function processRegistrationCommand(validValues = {}) {
const phone = validValues.phone ? '+82' + validValues.phone.slice(1) : null;

const command = {
username: validValues.username || validValues.email,
email: validValues.email,
password: validValues.password,
phone: phone || null,
agreement: validValues.agreement,
};

return command;
}

먼저, validValues 라는 파라미터 명이 좀 별로인 것 같다. validValues인지 아닌지는 이 함수는 몰라도 된다. 그냥 source로 바꿔준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function processRegistrationCommand(source = {}) {
const phone = source.phone ? '+82' + source.phone.slice(1) : null;

const command = {
username: source.username || source.email,
email: source.email,
password: source.password,
phone: phone || null,
agreement: source.agreement,
};

return command;
}

그 다음, 어차피 리턴해 줄 command 객체를 굳이 변수 선언 해주는 것도 별로인 것 같다. 변수로 선언하지 않고, 곧장 리턴해준다.

1
2
3
4
5
6
7
8
9
10
11
export default function processRegistrationCommand(source = {}) {
const phone = source.phone ? '+82' + source.phone.slice(1) : null;

return {
username: source.username || source.email,
email: source.email,
password: source.password,
phone: phone || null,
agreement: source.agreement,
};
}

source.이 계속 반복 되는 것도 좀 별로다. 없애면 커맨드 객체 부분이 더 깔끔해질 것 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function processRegistrationCommand(source = {}) {
const { username, email, password, phone, agreement } = source;
// phone 변수 명이 중복되어 formattedPhone로 변경해주었다.
const formattedPhone = phone ? '+82' + phone.slice(1) : null;

return {
username: username || email,
email: email,
password: password,
phone: formattedPhone || null,
agreement: agreement,
};
}

formattedPhone도 굳이 변수 선언해주지 않고 바로 적용해도 괜찮을 것 같다. 게다가 :null||null 부분 동일한 로직이 두 번 들어가 있다. 하나는 없애도 무방하다.

1
2
3
4
5
6
7
8
9
10
11
export default function processRegistrationCommand(source = {}) {
const { username, email, password, phone, agreement } = source;

return {
username: username || email,
email: email,
password: password,
phone: phone ? '+82' + phone.slice(1) : null,
agreement: agreement,
};
}

phone 속성의 '+82' + phone.slice(1) 를 템플릿 리터럴(Template literals)로 변경해줄 수도 있다. (이 부분은 개인적인 취향이므로 해도 그만, 안 해도 그만이다.)

1
2
3
4
5
6
7
8
9
10
11
export default function processRegistrationCommand(source = {}) {
const { username, email, password, phone, agreement } = source;

return {
username: username || email,
email: email,
password: password,
phone: phone ? `+82${phone.slice(1)}` : null,
agreement: agreement,
};
}

리팩터링이 끝났다. 코드가 처음보다 훨씬 깔끔하고 간결해졌다! 사실 첫 코드 자체가 그리 복잡하지도 지저분하지도 않았어서, 여지껏 테스트하고, 리팩터링하고 하며 들인 노력에 비해 크게 개선된 것 같지 않아 보일 수도 있겠다. 하지만 우리의 프로덕션 코드에 항상 이런 간략하고 복잡하지 않은 코드만 있는 것은 아니니까.. 분명 이렇게 테스트와 리팩터링을 반복하다보면 코드가 점점 직관적이게, 그리고 깔끔하게 변하는 것을 경험 하게 된다.


++ 4. 스펙을 추가하자! (여기부터 TDD이다)

새로운 비즈니스 요구 사항은 언제나 들어올 수 있다. 새로운 요구사항은 곧 새로운 기능이고, 기존에 없던 새로운 코드가 만들어져야 하는 순간이다. 이 때가 바로 TDD를 할 수 있는 황금 같은 기회이다. 아래와 같은 요구사항이 추가되었다고 가정해보자.


유저로부터 국가 정보를 입력 받는다. 중국이면 +86을, 한국이면 +82를 적용한다.


1. 먼저 테스트 코드부터 작성한다. (RED)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 // ...
const param = {
username: 'username',
email: 'user@email.com',
password: 'password',
phone: '01040022068',
agreement: [true, true, true],
country: 'China', // 유저로부터 입력 받는 값이 추가 되었다.
};
// ...
test('phone 속성은 국가 정보가 중국이면, +86이 포함된 문자열이다.', () => {
const actual = processRegistrationCommand({ ...param, country: 'China' });
expect(actual.phone).toBe(`+86${param.phone.slice(1)}`);
});

유저로부터 입력 받는 값에 국가 정보가 추가 되었으므로, param Fixture에 속성을 추가한다. 그리고 새로운 요구사항을 스펙으로 추가하였다. 테스트 먼저 작성되었고 구현코드는 존재하지 않으니 테스트는 당연히 실패한다. TDD의 첫 번째 단계, Red이다.


2. 프로덕션 코드를 수정한다. (GREEN)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// processRegistrationCommand.js
export default function processRegistrationCommand(source = {}) {
// source에 country 속성이 추가되었다.
const { username, email, password, phone, agreement, country } = source;

let countryCodeAdded;

// country 속성의 값에 따라 국가 번호를 다르게 적용한다.
if (!phone) {
countryCodeAdded = null;
} else if (country === 'China') {
countryCodeAdded = `+86${phone.slice(1)}`;
} else {
countryCodeAdded = `+82${phone.slice(1)}`;
}

return {
username: username || email,
email: email,
password: password,
phone: countryCodeAdded,
agreement: agreement,
};
}

이렇게 프로덕션 코드를 추가해서 테스트를 성공시킨다. 이게 TDD의 두 번째, Green 단계이다. 코드가 좀 더럽더라도 참아야 한다. Green 단계에서 반드시 지켜야 하는 원칙이 하나 있는 데, 테스트가 성공할 만큼만 코딩하는 것이다. 리팩터링 단계가 괜히 있는 게 아니다. 이 단계에서는 많은 생각을 하지 않고, 테스트를 성공시키는 것에만 집중한다.


3. 리팩터링 한다.(REFACTOR)

드디어 리팩터링 단계이다. 위 코드를 좀 깨끗하게 정리해보자. 먼저, else if 문을 없애고 싶다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export default function processRegistrationCommand(source = {}) {
const { username, email, password, phone, agreement, country } = source;

const countryCodes = {
Korea: 82,
China: 86,
};

let countryCodeAdded;

if (!phone) {
countryCodeAdded = null;
} else {
countryCodeAdded = `+${countryCodes[country]}${phone.slice(1)}`;
}

return {
username: username || email,
email: email,
password: password,
phone: countryCodeAdded,
agreement: agreement,
};
}

country 속성을 통해 넘어오는 국가 명을 key로, 국가 번호를 value로 하는 객체를 생성하여 else if 문을 제거해주었다. 다음으로, if 문을 제거해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function processRegistrationCommand(source = {}) {
const { username, email, password, phone, agreement, country } = source;

const countryCodes = {
Korea: 82,
China: 86,
};

return {
username: username || email,
email: email,
password: password,
phone: phone ? `+${countryCodes[country]}${phone.slice(1)}` : null,
agreement: agreement,
};
}

여기서 리팩터링한 phone 속성 부분은 본래 삼항연산자를 사용해 if문 없이 작성되어 있었는 데, 스펙이 추가되면서 오히려 지저분해졌었다. 이처럼 요구사항이 추가되면서 깔끔했던 코드가 오히려 퇴보하는 일은 생각보다 흔하게 발생한다. 이때 테스트가 있다면 리팩터링을 쉽고 안정적으로 할 수 있겠지만 테스트가 없다면? 혹여나 버그가 생길까 굉장히 두려움에 떨며 리팩터링을 하거나, 혹은 위험 요소를 만들지 않기 위해 아예 리팩터링을 포기해버릴 수도 있을 것이다. 포기를 선택한다면, 당연히, 요구 사항이 추가될 때 마다 코드는 끝도 없이 지저분해질 것이다. 물론 TDD를 한다면 걱정할 필요가 없다.


완성된 코드는 아래에서 볼 수 있다.

Edit Testing-not-tested-code

맺음

이 글을 통해 말하고자 했던 바는 “TDD를 하세요!” 가 아니다. 테스트를 처음 한다면, 그리고 어디서부터 시작해야 할 지 모르겠다면, 기존에 이미 작성되어 있는 코드에 테스트를 추가하는 것부터 시작한다. 이 방법은 실제로 내가 시작했던 방식이고, 지금도 여전히 하고 있는 방법이다. 나는 아직도 테스트가 무지 어렵다. 테스트를 할 수는 있지만, 잘 하는 방법은 아직 잘 모른다. 그래도 계속 하고 있다. 뻘짓도 해보고 삽질도 해보면서, 깨달음도 얻고 더 좋은 방법을 터득해 나가는 중이다. 막막하고 모르겠다고 시작하지 않으면, 은퇴하는 그 날까지도 시작할 수 없을 지 모른다. 일단 시작부터 하자! 그리고 조급하게 생각하지 말자! 경력 30년된 우리 회사 CTO님이 테스트 케이스 만 개 정도는 작성해봐야 한다고 했다!