요즘 프로젝트 스택이나 구인 공고를 보면 테스트 코드를 작성해 본 경험을 우대하는 경우가 많죠. 그리고 프로젝트를 하다 보면 팀원들 사이에서 빠지지 않고 등장하는 주제 중 하나가 바로 테스트 코드일 것이에요.
그렇다면 우리는 왜 테스트 코드를 작성해야 할까요? 단순히 시장에서 요구하기 때문이라면, 시장은 왜 테스트 코드 경험을 중요하게 여길까요? 이번 글에서는 테스트 코드가 필요한 이유와, 처음 테스트 코드를 작성할 때 고려해야 할 점들에 대해 이야기해 볼게요.
요즘 프로젝트 스택이나 구인 공고를 보면 테스트 코드를 작성해 본 경험을 우대하는 경우가 많죠. 그리고 프로젝트를 하다 보면 팀원들 사이에서 빠지지 않고 등장하는 주제 중 하나가 바로 테스트 코드일 것이에요.
그렇다면 우리는 왜 테스트 코드를 작성해야 할까요? 단순히 시장에서 요구하기 때문이라면, 시장은 왜 테스트 코드 경험을 중요하게 여길까요? 이번 글에서는 테스트 코드가 필요한 이유와, 처음 테스트 코드를 작성할 때 고려해야 할 점들에 대해 이야기해 볼게요.
테스트 코드가 제공하는 가장 큰 이점은 다음 두 가지로 이야기해 볼게요.
사용자에게 제공되는 서비스는 언제나 안정된 상태여야 합니다. 여기서 "안정적"이라는 것은 기획한 대로 사용자가 서비스를 경험할 수 있어야 한다는 의미이죠.
예를 들어, A 버튼을 눌렀을 때 AA 컴포넌트가 나타나야 하고, B 버튼을 눌렀을 때 BB 컴포넌트가 보여야 합니다. 만약 반대의 결과가 나오거나, 아무것도 나타나지 않는다면 이는 불안정한 상태라고 볼 수 있습니다.
테스트 코드를 작성하면 이러한 불안정한 요소를 조기에 발견하고 수정할 수 있어요. 즉, 코드 변경이 예상치 못한 오류를 일으키는지 빠르게 확인할 수 있는 것이죠.
테스트 코드는 유지 보수를 더욱 쉽게 만들어 줘요.
예를 들어, 회원가입 페이지의 여러 validation 로직을 “리팩토링”한다고 가정해 봅시다. 로직을 변경한 후 모든 케이스를 수동으로 체크하려면 시간이 많이 걸릴 뿐만 아니라 실수할 가능성도 높아집니다. 하지만 테스트 코드가 있다면 npm test (이는 package.json의 설정에 따라 다르겠지만요..!)한 번으로 모든 validation이 정상적으로 동작 하는지 확인할 수 있습니다.
즉, 테스트 코드가 있으면 기존 로직이 깨지지 않았음을 보장할 수 있기 때문에 유지보수 시간이 단축되고, 실수로 인한 버그를 방지할 수 있습니다.
테스트 코드의 필요성은 이해했지만, 막상 작성하려고 하면 "어디서부터 시작해야 할까?"라는 고민이 생길 것 같아요. 특히 이미 서비스가 운영 중인 상태에서 테스트 코드를 추가하려고 하면 어떤 기능부터..라며 막막할 수 있죠.
이럴 때 저는 서비스의 핵심 도메인부터 테스트 코드를 작성하는 것이 좋다고 생각해요.
이를 DDD(Domain-Driven Design) 관점에서 보면, 서비스의 기능을 다음과 같이 나눌 수 있겠네요.
핵심 도메인은 유저 경험에 가장 큰 영향을 주기 때문에, 테스트 코드 작성의 우선순위를 높이는 것이 좋아요.
테스트 코드를 작성할 때 중요한 점 중 하나는 "문서화 기능"을 고려하는 것이에요. 잘 짜인 테스트 코드는 기능 명세서의 역할을 할 수 있어야 하죠. 즉, 테스트 코드만 봐도 해당 기능이 어떤 시나리오를 만족해야 하는지 이해할 수 있어야 해요.
예를 들어, 객관식 문제 풀이 컴포넌트를 테스트한다고 가정해 볼게요. 이때 컴포넌트의 기능 명세서가 있다면 그것을 따라서 테스트 시나리오를 작성하시면 좋아요. 그렇지 않다면 컴포넌트의 기능을 쭉 작성해보고 그에 맞춰 테스트 코드를 작성하면 좋을 것 같아요.
실제 예시로 4지선다 문제를 푸는 컴포넌트를 테스트한다고 가정해 볼게요.
이 컴포넌트의 명세서에는
| # | 설명/명세 | 조건(실행할 로직) | 검증/확인할 것 |
|---|---|---|---|
| 1 | API를 통해 문제를 서빙 성공 시, 첫번째 문제 랜더링 | API 호출을 성공 | 첫번째 문제 렌더링 |
| 2 | API를 통해 문제를 서빙 실패 시, 메인 페이지로 리다리엑트 | API 호출을 실패 | 메인 페이지로 리다이렉트 |
| 3 | 보기(객관식 문제의 보기) 선택에 따른 하단 버튼 활성 상태 변경 | 사용자가 보기를 선택한다 | 하단 버튼이 활성화 된다. |
| 3-1 | 사용자가 아무 보기도 선택하지 않는다 | 하단 버튼이 비활성화 된다. | |
| 4 | 보기(객관식 문제의 보기) 선택에 따른 보기 상태 변경 | 사용자가 보기를 선택한다 | 선택한 보기의 active상태가 true가 되며 디자인이 변경된다. |
| 5 | 현재 풀고있는 문제의 번호에 따라 하단 TEXT가 변화한다. | 현재 풀고있는 문제가 마지막 문제이다. | 하단 버튼의 TEXT는 ‘제출하기’ |
| 5-1 | 현재 풀고있는 문제가 마지막 문제가 아니다. | 하단 버튼의 TEXT는 ‘다음’ |
// 문제 리스트를 정상적으로 불러오는지 테스트
describe('TodayQuestion 단위 테스트', () => {
it('API 응답이 성공하면 첫 번째 문제를 렌더링해야 한다', async () => {
render(<QuizComponent {...QuestionProps}/>);
expect(await screen.findByText('문제 1')).toBeInTheDocument();
});
// 문제 리스트를 불러오지 못할 경우 메인 페이지로 이동하는지 테스트
it('API 응답이 실패하면 메인 페이지로 이동해야 한다', async () => {
render(<QuizComponent {...ErrorQuestionProps} />);
expect(window.location.pathname).toBe('/');
});
describe('TodayQuestion 단위 테스트에서 보기를', () => {
it('선택 하면 해당 보기의 active 속성이 true 된다.', () => {
render(<TodayQuestion {...QuestionProps} />);
const firstQuestionUnit = screen.getByTestId(
'question-option-1-inactive',
);
act(() => {
fireEvent.click(firstQuestionUnit);
});
const clickTest = screen.getByTestId('question-option-1-active');
expect(clickTest).toBeInTheDocument();
});
it('선택하면 하단 버튼 속성이 활성화 된다.', () => {
render(<TodayQuestion {...QuestionProps} />);
const firstQuestionUnit = screen.getByTestId(
'question-option-1-inactive',
);
act(() => {
fireEvent.click(firstQuestionUnit);
});
const nextButton = screen.getByTestId('next-button').children[0];
expect(nextButton).not.toBeDisabled();
});
it('선택하지 않으면 하단 버튼 속성이 비활성화된다.', () => {
render(<TodayQuestion {...QuestionProps} />);
const nextButton = screen.getByTestId('next-button').children[0];
expect(nextButton).toBeDisabled();
});
});
describe('하단 이동 버튼은', () => {
it('마지막 문제가 아니라면 다음 버튼으로 바뀐다.', () => {
render(<TodayQuestion {...QuestionProps} />);
const firstQuestionUnit = screen.getByTestId(
'question-option-1-inactive',
);
act(() => {
fireEvent.click(firstQuestionUnit);
});
const nextButton = screen.getByTestId('next-button').children[0];
expect(nextButton).toHaveTextContent('다음');
});
it('마지막 문제라면 점수 보기 버튼으로 바뀐다.', () => {
render(<TodayQuestion {...QuestionProps} />);
const totalQuestions = QuestionProps.questionArr.length;
for (let i = 0; i < totalQuestions - 1; i++) {
const firstQuestionUnit = screen.getByTestId(
`question-option-${i + 1}-inactive`,
);
const nextButton = screen.getByTestId('next-button').children[0];
act(() => {
fireEvent.click(firstQuestionUnit);
});
act(() => {
fireEvent.click(nextButton);
});
}
const nextButton = screen.getByTestId('next-button').children[0];
expect(nextButton).toHaveTextContent('점수 보기');
});
});
});
이 테스트 코드는 문제 풀기 기능의 핵심 시나리오를 명확하게 보여줍니다. 즉, 누군가 이 코드를 읽더라도 "로그인은 이렇게 동작해야 한다"는 것을 쉽게 이해할 수 있어요.
또 중요하게 생각해 보면 좋을 것은
컴포넌트는 기획서와 디자인을 기반으로 API 호출 성공/실패에 따라 처리를 어떻게 할지, 특정 정보가 조건에 맞춰 노출 혹은 노출되지 않는지, 사용자의 입력과 버튼을 눌렀을 때 어떤 처리가 일어나는지 등 여러 정책이 존재한다면 이는 모두 테스트 케이스 화해야해요.
컴포넌트 내부 구현과 상관없이 오로지 명세대로 테스트 코드가 구현되어야한다.
테스트 코드 작성에 내부 구현은 중요하지 않다는 이야기예요. 어떤 식으로 구현하든 테스트 코드를 통과하는 컴포넌트를 구현이 중요한 것이죠. 예를 들어 “선택 하면 해당 보기의 active 속성이 true 된다.”의 테스트의 경우는 "선택하면 해당 보기의 active 속성이 true가 된다"라는 명세에 맞춰, 사용자가 첫 번째 옵션을 클릭했을 때 active가 true로 변경되는지 확인하는 과정만 있습니다. 이때 active 상태가 컴포넌트 내부에서 어떻게 처리되는지는 전혀 중요하지 않으며, 단지 "클릭 시 active 상태가 반영된다"는 결과를 체크하는 것입니다. useState, useReducer, Context 또는 다른 상태 관리 방법을 사용하고 있는지에 대한 구체적인 사항은 전혀 고려하지 않고 있습니다.
테스트 코드를 작성해보자!라는 목표만 있고 목표에 대한 기대하는 값이 없었던 순간을 되돌아보며 테스트 코드의 필요성 및 접근 방식에 대해서 이야기해 볼 수 있었어요.
처음 테스트 코드를 작성할 때는 핵심 도메인을 우선적으로 테스트하고, 명확한 시나리오 기반의 테스트 코드를 작성하는 것이 중요해요. 그리고 테스트 코드는 단순한 검증 도구를 넘어, 기능 명세서 역할을 할 수 있도록 고도화 해나가는 것 또한 지속적으로 노력하고 생각해보야할 부분이라는 것을 다시 한 번 되세길 수 있었어요.
테스트 코드를 도입하는 것이 어색할지 몰라도 이후에는 점점 익숙해지며 개발의 효율을 크게 향상 시킬 수 있을 것이라고 생각해요.