코드와 함께 살펴보는 프론트엔드 단위 테스트 – Part 1. 이론 편
개발자들에게 테스트 코드 작성은 해야 하지만 손이 잘 가지 않는 숙제 같은 일입니다. 테스트 코드를 쓰면 쓸수록 귀찮다고 얘기하는 분도 본 것 같습니다. (ㅋㅋㅋㅋ) 그럼에도 테스트의 중요성을 부정하는 개발자분은 없을 것으로 생각하는데요. 클린코드, 리팩터링을 비롯한 많은 개발 서적에서도 테스트 코드의 중요성을 강조하고 필요하다고 이야기하고 있습니다. TDD(Test Driven Development)에서는 아예 테스트 코드를 먼저 만들고 본 코드를 작성하라고 이야기하고 있습니다. 최근 몇 년간 프론트엔드에서 다루는 영역이 늘어나면서 코드의 복잡성도 올라가고 예전보다 많은 비즈니스 로직을 프론트엔드에서 다루게 되었습니다. 이와 함께 프론트엔드의 품질과 테스트에 대한 관심과 중요성도 함께 올라가는 것 같은데요. 예전에는 프론트엔드 개발자에게 테스트에 대한 역량이 경쟁력이었다면 요즘은 서서히 필수 역량이 되어가는 것 같기도 합니다.
이번 글에서는 테스트 코드 중에서도 단위 테스트에 집중해 프론트엔드 코드와 함께 살펴볼 예정입니다. 총 두 편으로 구성했으며 "Part 1. 이론 편"에서는 단위 테스트에 대한 간략한 소개와 효과적인 단위 테스트 코드 작성을 위해 신경 쓰면 좋은 부분, 그리고 테스트 코드가 코드 개발에 어떤 도움을 줄 수 있는지까지 다루어 보겠습니다.
프론트엔드와 단위 테스트
프론트엔드는 여전히 많은 분야가 불모지이고 테스트도 그중 하나입니다. 하지만 많은 개발자의 고민과 노력으로 매년 다양한 라이브러리가 나오고 있습니다. 테스트 종류에도 오늘 이야기하는 단위 테스트 말고도 스냅샷 테스트, e2e 테스트 등 다양하게 존재합니다. 테스트는 기본적으로 무언가를 검증하고 확인하는 용도로 만드는 것이므로 어떤 것을 확인할 것인가에 따라 적용하는 테스트 종류가 다릅니다.
단위 테스트는 특정한 모듈 혹은 단위가 기능을 의도한 대로 올바르게 수행하고 있는지 확인하는 것으로, 테스트 중에서도 가장 기본이고 기저에 깔려 있어야 하는 테스트입니다. 특정 모듈이나 단위라는 단어가 의미하는 것처럼 단위 테스트는 범위가 한정적이므로 각 단위에 대한 테스트 코드의 양이 적고 효율적입니다. 하지만 명세가 변경된다면 가장 많이 영향을 받는 테스트이기도 하니 설계에 신중해야 합니다.
효과적인 테스트 코드 작성법
테스트 코드는 말 그대로 프로덕트와 코드가 올바르게 동작하고 기능하는지를 테스트하는 코드입니다. 즉, 코드가 올바르게 작성되었는지 검증하는 또 다른 코드라는 이야기입니다. 테스트 코드는 프로덕트의 안정성과 유지 보수성 향상에 기여하는 코드로 대충 쓰는 것이 아닌 잘 작성하려는 노력이 필요합니다. 테스트 코드 역시 한 번 만들고 끝이 아니라 프로덕트를 구성하는 코드처럼 계속 유지 보수되고 발전시켜야 합니다. 지금부터는 테스트 코드 작성에 앞서 좋은 테스트 코드를 작성하기 위해 알아야 할 혹은 도움이 될만한 내용을 소개하겠습니다.
F.I.R.S.T 원칙
F.I.R.S.T 원칙은 단위 테스트가 가져야 할 특성과 원칙에 관해서 이야기하고 있습니다. 프로그래밍에는 더 나은 프로덕트를 위해 다양한 방법론과 규칙들이 나왔고 이 원칙은 단위 테스트 코드에 대해 더 나은 코드를 만들 수 있는 원칙에 관해 설명합니다.
- Fast: 단위 테스트는 빨라야 한다.
- Isolated: 단위 테스트는 외부 요인에 종속적이지 않고 독립적으로 실행되어야 한다.
- Repeatable: 단위 테스트는 실행할 때마다 같은 결과를 만들어야 한다.
- Self-validating: 단위 테스트는 스스로 테스트를 통과했는지 아닌지 판단할 수 있어야 한다.
- Timely/Thorough – 2가지 해석이 존재
- Timely: 단위 테스트는 프로덕션 코드가 테스트에 성공하기 전에 구현되어야 한다. TDD에 적합한 해석
- Thorough: 단위 테스트는 성공적인 흐름뿐만 아니라 가능한 모든 에러나 비정상적인 흐름에 대해서도 대응해야 한다.
테스트 코드는 DAMP하게
테스트 코드에서는 DAMP(Descriptive And Meaningful Phrases)하게 작성하라고 이야기하는 글들이 많습니다. 한글로 풀어보면 서술적이고 의미 있게 작성하라는 이야기인데요. 내용이 추상적이죠? 좀 더 쉽게 말하면 읽기 쉽고 이해하기 쉬운 테스트 코드를 만들자는 이야기입니다. 이를 통해 유지 보수가 한층 쉬워지겠죠!
DAMP하게 코드를 작성하다 보면 DRY(Don’t Repeat Yourself) 원칙과 충돌할 때도 있습니다. 일반적인 코드라면 DRY 원칙에 따라 중복을 줄이려 노력할 것입니다. 하지만 테스트 코드는 중복이 다소 발생하더라도 직관적이고 명확하게 이해되도록 테스트 코드를 작성하는 쪽에 무게를 두고 작성하는 것이 좋습니다. 그렇다고 중복이 너무 늘어나는 것도 가독성이 떨어지니 둘 사이에서 잘 저울질해야 합니다.
아래의 첫 번째, 두 번째 테스트 코드는 똑같은 기능을 검증하는 데 사용됩니다. 그중 첫 번째 테스트 코드는 동작을 검증하는 데 문제가 없지만 파악이 좀 어려울 수 있습니다. 하지만 두 번째 테스트 코드는 이해가 바로 되죠? DAMP하게 테스트 코드를 작성하라는 이야기가 뭘까 고민하면서 두 테스트 코드를 비교해 보세요!
// 첫 번째 테스트 코드
it('설명을 생략하면 이해가 되시나요?', async () => {
const user = userEvent.setup();
render(<Component {...preDefinedProps} />);
const buttonElement = screen.getByRole('button', { name: '버튼입니다' });
await user.click(buttonElement);
expect(doSomething).toBeCalledWith(1234);
});
// 두 번째 테스트 코드
it('설명이 없어도 대충 느낌은 오시죠?', async () => {
const something = 1234;
const doSomething = vi.fn();
const user = userEvent.setup();
render(<Component {...preDefinedProps} something={something} doSomething={doSomething} />);
const buttonElement = screen.getByRole('button', { name: '버튼입니다' });
await user.click(buttonElement);
expect(doSomething).toBeCalledWith(1234);
});
Given-When-Then
테스트 코드는 주어진 상황에 대한 결과를 검증하는 게 목적입니다. 이 목적을 달성하기 위해 Given-When-Then 패턴이 도움을 줄 수 있습니다. BDD(Behavior Driven Development)에 대해 공부해 보셨다면 이미 들어보셨을 수도 있습니다. Given-When-Then 구조는 BDD의 중심인 사용자 행위를 기반으로 한 테스트 시나리오를 정의할 수 있도록 도와줍니다. 테스트 코드가 정책을 기반으로 작성되지만 테스트 코드 자체는 개발자가 이해하기 쉬워야겠죠? Given-When-Then 구조로 테스트를 구성한다면 명확한 시나리오 위에서 개발자가 코드를 쉽게 파악하고 이해할 수 있습니다.
- Given: 테스트를 하기 위해 세팅하는 주어진 환경
- When: 테스트를 하기 위한 조건으로 프론트엔드에선 사용자와의 상호작용인 경우도 많음
- Then: 예상 결과를 나타내며 의도대로 동작하는지 검증 및 확인할 수 있음
it('버튼을 1회 클릭하면 1번 클릭했다는 문구가 노출된다', async () => {
// Given: 사용자와 화면이 준비되어 있고, 화면에는 버튼이 존재함
const user = userEvent.setup();
render(<Component />);
// When: 사용자가 '여기를 눌러보세요'라는 버튼을 클릭함
const buttonElement = screen.getByRole('button', { name: '여기를 눌러보세요' });
await user.click(buttonElement);
// Then: 문구가 나타나는지 검증함
expect(screen.getByText('버튼을 1번 클릭했습니다.')).toBeInTheDocument();
});
개별 테스트 케이스의 목적은 명확히
각 테스트 케이스에 해당하는 테스트 코드 작성할 때 작성하고 있는 테스트 케이스의 목적이 무엇인지 명확히 생각하고 테스트 코드를 작성해야 합니다. 아래 컴포넌트 코드 예시로 한번 살펴봅시다.
// store.ts
interface StateAndAction {
word: string;
updateWord: (newWord: string) => void;
}
const useStore = create<StateAndAction>((set) => ({
word: '사과',
updateWord: (newWord) => set({ word: newWord }),
}));
// WordWithButton.tsx
const WordWithButton = () => {
const word = useStore((state) => state.word);
const updateWord = useStore((state) => state.updateWord);
return (
<main>
<h1>
나는 {word}를 좋아한다!
</h1>
<button
type="button"
onClick={() => {
updateWord('바나나');
}}
>
좋아하는 과일 바꾸기
</button>
</main>
);
};
WordWithButton은 전역 상태와 버튼 그리고 heading 역할의 문구가 존재하는 컴포넌트입니다. 간단한 컴포넌트니 어떤 식으로 변경이 일어나고 동작을 수행할지 예측되시죠? 해당 컴포넌트에 테스트 코드를 작성한다면 버튼을 눌렀을 때 좋아하는 과일이 바나나로 바뀐 문구가 노출되는지 검증하는 내용일 것입니다. 어떤 테스트 코드가 이 목적을 달성할 수 있을까요?
// 첫 번째 테스트 코드
it('바나나 문자열로 updateWord 호출 시 word가 바나나로 변경된다', () => {
const { result } = renderHook(() => useStore());
act(() => {
result.current.updateWord('바나나');
});
expect(result.current.word).toBe('바나나');
});
첫 번째 테스트 코드는 내부적으로 가지는 단어라는 상태가 올바르게 변경되는지 테스트하고 있습니다. 어쩌면 이 코드가 필요하다고 생각하실 수 있지만 이 테스트 코드는 우리가 테스트하려는 목적에 맞지 않습니다. 우리가 테스트하고자 하는 내용은 버튼을 클릭하면 표시되는 문구가 바뀌는 것입니다. 하지만 현재 테스트 코드가 검증하고 있는 것은 내부 동작입니다. 다시 말하면, 컴포넌트를 테스트하는 것이 아니라 내부에서 사용하는 zustand 라이브러리가 올바르게 동작하는지 확인하고 있습니다.
우리는 컴포넌트 밖에서 드러난 인터페이스를 활용해 외부에서 테스트를 해야 하지만 현재는 내부 구현이 테스트 코드에 드러나 있습니다. 컴포넌트를 리팩토링한다면 이 테스트 코드는 컴포넌트 내부에서 사용하는 기술에 따라 또 바뀌어야 할 텐데 뭔가 이상하죠? 우리가 작성해야 할 테스트 코드는 사용하는 라이브러리가 똑바로 동작하는지 검증하는 코드가 아닙니다.
// 두 번째 테스트 코드
it('버튼 클릭 시 좋아하는 과일이 바나나로 바뀐다', async () => {
const user = userEvent.setup();
render(<WordWithButton />);
await user.click(screen.getByRole('button', { name: '좋아하는 과일 바꾸기' }));
expect(screen.getByText(/바나나/i)).toBeInTheDocument();
});
두 번째 테스트 코드입니다. 이 코드는 올바른 테스트 코드일까요? 사용자가 클릭하는 동작도 있고 ‘바나나’를 확인하는 코드도 있습니다. 원하는 대로 검증하고 있는 것 같죠? 사실 아닙니다. expect를 보시면 올바른 문구로 변경되었는지 검증하는 것이 아니라 화면 내에 ‘바나나’라는 단어가 있는지 확인하고 있습니다. 우리가 의도한 테스트 목적을 어느 정도 달성한 코드지만, ‘바나나’가 화면 다른 곳에 노출되거나 버튼명이 ‘바나나로 바꾸기’와 같은 내용이었다면 해당 테스트는 실패했을 것입니다. 테스트 목적에 맞게 내가 작성한 코드의 동작을 올바르게 검증하는 것인지 고민하면서 테스트 코드를 작성해야 합니다.
// 세 번째 테스트 코드
it('버튼 클릭 시 heading 영역의 문구가 바나나를 좋아한다는 내용으로 변경된다', async () => {
const user = userEvent.setup();
render(<WordWithButton />);
await user.click(screen.getByRole('button', { name: '좋아하는 과일 바꾸기' }));
expect(screen.getByRole('heading', { name: '나는 바나나를 좋아한다!' })).toBeInTheDocument();
});
이번엔 세 번째 테스트 코드인데요. 테스트 코드를 보면 버튼을 눌렀을 때 heading 영역의 문구가 바나나를 좋아한다는 내용으로 변경된다는 것을 검증하고 있습니다. 비로소 우리가 테스트하고자 하는 목적과 부합하는 코드가 등장했습니다. 우리가 테스트하고자 하는 것은 내부 동작이 아니라 사용자가 특정 동작을 수행했을 때 특정 영역의 문구가 정확하게 변경되는지입니다.
테스트 코드의 내용과 더불어 expect 구문을 통해 검증하는 내용도 명확해야 합니다. 내가 특정 단어가 화면에 있는 것을 찾는 것인지, 문구가 특정한 형식으로 표시되는 것을 검증하는 것인지, 어떤 내용을 포함하는지 아니면 정확한 문구인지 비교하는 것인지 등 검증하고자 하는 것의 범위를 특정해야 합니다. 문구가 아니더라도 어떤 함수가 단순히 호출되었다, 몇 번 호출되었다, 특정 파라미터들을 가지고 호출되었다는 등 어떤 것으로 검증할 것인지 결정해야 합니다. 내가 작성하는 테스트 케이스가 정확하게 어떤 것을 검증할 것인지 테스트 케이스의 목적을 생각하며 코드와 expect를 작성하도록 합시다.
테스트 코드의 설명도 중요합니다
많이들 간과하지만 의외로 테스트 코드의 설명은 중요합니다. 테스트 코드의 설명을 대충 쓰거나 필요한 내용들을 빠트리는 경우도 있는데요! 설명이 잘 작성되어 있다면 기능을 수정 혹은 확장할 때, 코드를 디버깅할 때 큰 도움이 됩니다. 설명으로 이해가 되지 않는다면 코드를 한 줄씩 뜯어보며 파악에 비교적 많은 시간을 들여야 할 것입니다. 평소엔 테스트 코드의 설명을 잘 찾아보지 않을 수 있지만, 문제가 발생해 파악이 필요하거나 코드를 수정하려고 할 때 설명이 가장 먼저 코드에 대한 힌트를 제공할 것입니다.
설명을 작성할 때는 구현 내용에 집중하지 말고 함수를 밖에서 본다고 생각하고 만들어야 합니다. 구현 내용에 집중하면 명세가 아니라 코드를 해석하는 설명이 나오기 십상입니다. 코드 구현보다는 이 함수의 역할이나 기획서를 보고 테스트 코드를 써봅시다. 테스트 코드를 만들고 본 코드를 작성하는 것도 한 가지 방법입니다.
간결하고 명확하게 설명을 작성해 주세요. ‘~~하면 정상적으로 작동한다’ 같은 추상적인 내용도 피해야 합니다. 코드뿐만 아니라 설명에서도 우리는 내부 구현이 아니라 결과를 검증하기 위해 테스트 코드를 작성했다는 사실을 잊지 마세요!
TMI로 테스트 코드 문법에 it도 있고, test도 있는데 둘 다 사용 가능하지만 it에 영어로 설명을 작성한다면 it(’returns true only when ~~’, ~~)와 같은 식으로 문장으로 작성할 수 있습니다.
// Hmm.. - 내부 구현에 집중되어 있고 명확하지 못한 테스트 코드입니다.
it('입력한 숫자 문자열의 첫자리가 3,4,7,8일 때만 true, 이외엔 false를 반환한다', () => {
expect(doSomething('1234567')).toBe(false);
expect(doSomething('2134567')).toBe(false);
expect(doSomething('3124567')).toBe(true);
expect(doSomething('4123567')).toBe(true);
expect(doSomething('5123467')).toBe(false);
expect(doSomething('6123457')).toBe(false);
expect(doSomething('7123456')).toBe(true);
expect(doSomething('8123456')).toBe(true);
});
// Good! - 간결하고 명확하게 함수의 명세와 정책을 담아 설명을 표현합시다.
it('2000년 이후에 태어난 사람의 주민번호 뒷자리인지 검증한다', () => {
expect(doSomething('1234567')).toBe(false);
expect(doSomething('2134567')).toBe(false);
expect(doSomething('3124567')).toBe(true);
expect(doSomething('4123567')).toBe(true);
expect(doSomething('5123467')).toBe(false);
expect(doSomething('6123457')).toBe(false);
expect(doSomething('7123456')).toBe(true);
expect(doSomething('8123456')).toBe(true);
});
테스트 코드와 좋은 코드
지금까지 테스트 코드 작성에 앞서 알고 있으면 도움이 될만한 내용들을 소개했습니다. 하지만 테스트 코드에 관심을 가지고 있어도 테스트 코드가 왜 필요한지 의문을 가지고 계신 분도 있을 것입니다. 그러면 여기서 여러분께 질문을 하나 드리고 싶은데요!
여러분이 생각하는 좋은 코드는 무엇인가요?
각자의 답이 있겠지만 제가 생각하는 좋은 코드는 내가 아닌 누군가가 작업해도 변경하고 확장하기 쉬운 설계를 가지고 있는 코드입니다. 개발자로 일하시는 분들은 기존에 존재하는 코드에 어떤 기능을 추가하거나 혹은 리팩토링을 진행한 경험이 있으실 겁니다. 그 과정이 항상 순탄했나요? 조금씩 리팩토링이 힘들어서 매번 크게 구조 개편이 있거나 기존 코드를 건드려서 QA 범위가 넓어진 경험은 없으신가요? 지금부터는 테스트 코드가 좋은 코드를 만들 수 있게 어떻게 도와줄 수 있는지 소개해 드리겠습니다.
잘 만든 테스트 코드는 그 자체로 명세 역할을 수행합니다
아래와 같은 기능을 구현하기 위해 컴포넌트를 설계하고자 합니다.
- 처음에는 단어 리스트를 받아 노출한다.
- 검색기능이 있어 검색창이 화면에 노출되며, 검색어 입력 후 검색 버튼을 누를 경우 해당 문자열이 포함된 단어들만 노출되며 검색창에는 입력한 값이 변경되지 않고 그대로 유지된다.
- 검색 버튼 클릭 시 해당 검색어를 최근 검색어 목록으로 저장하며 화면에 노출된다.
- 최근 검색어를 클릭할 경우 클릭한 검색어를 기준으로 검색 버튼을 눌렀을 때와 같은 동작을 수행하되 검색창 입력에는 반영되지 않고 기존 입력을 유지한다.
명세를 기반으로 테스트 코드 작성을 해보겠습니다. 테스트 코드 it 내부 구현은 생략하겠습니다. 테스트 코드를 공부 중이라면 Part 2를 보시고 나서 한번 작성해 보시는 걸 권해드립니다. 🙂
describe('SomeComponent 단위 테스트', () => {
it('현재 검색어가 없으면 단어 목록이 모두 노출된다', () => {
// ...
});
it('검색어를 입력 후 검색 버튼 클릭 시, 단어 목록은 입력한 검색어 문자열이 포함된 목록만 노출된다', () => {
// ...
});
it('검색어를 입력 후 검색 버튼 클릭 시, 최근 검색어로 저장되며 화면에 노출된다', () => {
// ...
});
it('최근 검색어 클릭 시, 단어 목록은 클릭한 검색어 문자열이 포함된 목록만 노출된다', () => {
// ...
});
it('검색어 입력 후 검색 버튼 클릭 시, 입력한 검색어가 초기화되지 않고 유지된다', () => {
// ...
});
it('검색어 입력 후 최근 검색어 클릭 시, 입력한 검색어가 초기화되지 않고 유지된다', () => {
// ...
});
});
작성한 테스트의 설명만 봐도 SomeComponent에 어떤 역할을 기대하는지 모두 알 수 있습니다. 설명뿐만 아니라 it 내부도 잘 작성했다면 좀 더 명확한 명세를 파악할 수 있을 것입니다. 평소엔 테스트 코드와 설명을 자세히 살펴보지 않을 수도 있지만, 장애가 났거나 기능 추가 혹은 리팩토링할 때 컴포넌트의 명세가 있다면 코드 파악에 큰 도움이 될 것입니다. 우리가 회사에서 코드를 작성한다면 본인이 작성한 코드를 본인만 보는 것이 아니죠? 다른 사람들이 볼 때도 테스트 코드는 컴포넌트를 빠르게 파악할 수 있도록 도움을 줄 것입니다. 테스트 코드를 기반으로 컴포넌트 코드를 한번 작성해 보겠습니다. 역시 자세한 내부 구현은 생략하도록 하겠습니다.
const SomeComponent = ({ wordList }: SomeComponentProps) => {
const [keyword, setKeyword] = useState('');
const [keywordHistory, setKeywordHistory] = useState<string[]>([]);
const search = (value: string) => {
setKeyword(value);
setKeywordHistory([value, ...keywordHistory]);
};
const handleChangeInput = (...args: unknown[]) => {
// 검색창에서 검색어 변경하는 핸들러
};
const handleClickButton = (...args: unknown[]) => {
// 검색버튼 클릭 핸들러
};
const handleClickPreviousKeyword = (...args: unknown[]) => {
// 최근 검색어 클릭 핸들러
};
const filteredWordList = wordList.filter((word) => word.includes(keyword));
return <>{/* 컴포넌트가 조합되는 곳 */}</>;
};
테스트 코드를 통과하는 SomeComponent를 구현했습니다. 테스트 코드를 미리 작성하고 테스트를 통과하는 컴포넌트를 구현했으므로 작성한 코드가 의도한 대로 동작한다는 것을 보장할 수 있습니다. 테스트 코드가 있다면 개발 시에 내가 실수한 부분이 있더라도 테스트를 실행시켜 발견할 수 있을 것입니다. 코드 리뷰를 진행할 때도 해당 컴포넌트의 동작을 파악하고 좀 더 상세한 리뷰를 위해 참고할 수 있는 좋은 문서의 역할도 할 것입니다. 나중에 내가 혹은 다른 사람이 의도와 동작을 잘못 이해하고 코드를 잘못 수정하더라도 테스트 코드를 통해 손쉽게 잘못된 부분을 알 수 있을 것입니다.
테스트 코드와 함께 코드의 응집도를 높일 수 있습니다
응집도란 모듈 내부 요소들 간 얼마나 연관되어 있는지를 나타내는 것으로 다르게 말하면 함께 변경되는 정도로도 표현할 수 있습니다. 자주 바뀌는 코드라면 높은 응집도를 가지도록 코드를 구현하는 것이 여러모로 좋습니다. 방금 전 작성한 SomeComponent의 테스트 코드를 자세하게 살펴봅시다. 잘 만든 테스트 코드는 명세이자 문서가 되었는데요! 테스트 코드를 통해 컴포넌트가 아래와 같이 크게 3가지 역할을 수행하고 있는 것을 알 수 있습니다.
- 단어 목록은 현재 검색어를 포함하는 문자열만 노출된다.
- 검색창 입력 후 버튼 클릭 시 혹은 최근 검색어 클릭 시 현재 검색어가 변경된다.
- 검색창 입력 후 버튼 클릭 시 혹은 최근 검색어 클릭 시 최근 검색어 목록에 저장된다.
우리가 익히 알고 있는 단일 책임 원칙과 관심사 분리에 따르면 컴포넌트를 분리하는 시도를 해봐도 좋을 것 같습니다. 이미 작성한 테스트 코드가 있으므로 큰 부담 없이 리팩토링 작업을 진행할 수 있습니다. SomeComponent를 역할대로 분리해 기능을 나누어 봅시다.
// 컴포넌트 1: 검색창 입력 후 버튼 클릭 시 혹은 최근 검색어 클릭 시 현재 검색어가 변경하는 컴포넌트
const DividedComponentOne = ({ keywordHistory, changeKeyword }: DividedComponenOneProps) => {
const handleChangeInput = (...args: unknown[]) => {
// 검색창에서 검색어 변경하는 핸들러
};
const handleClickButton = (...args: unknown[]) => {
// ...
changeKeyword(...)
// ...
};
const handleClickPreviousKeyword = (...args: unknown[]) => {
// ...
changeKeyword(...)
// ...
};
return <>{/* 컴포넌트가 조합되는 곳 */}</>;
};
// 컴포넌트 2: 현재 검색어를 포함하는 문자열만 노출하는 컴포넌트
const DividedComponentTwo = ({ wordList, keyword }: DividedComponentTwoProps) => {
const filteredWordList = wordList.filter((word) => word.includes(keyword));
return <>{/* 컴포넌트가 조합되는 곳 */}</>;
};
// 컴포넌트 3: 상위에서 데이터를 제어하며 검색어 변경 시 최근 검색어 목록에 저장하는 컴포넌트
const SomeComponent = ({ wordList }: SomeComponentProps) => {
const [keyword, setKeyword] = useState('');
const [keywordHistory, setKeywordHistory] = useState<string[]>([]);
const changeKeyword = (value: string) => {
setKeyword(value);
}
useEffect(() => {
setKeywordHistory([keyword, ...keywordHistory]);
},[keyword])
return (
<>
<DividedComponentOne changeKeyword={changeKeyword} keywordHistory={keywordHistory} />
{/* 컴포넌트가 조합되는 곳 */}
<DividedComponentTwo wordList={wordList} keyword={keyword} />
</>
);
};
각각의 컴포넌트의 책임이 명확해지고 가독성도 향상되었습니다. 코드의 응집도 역시 높아졌는데 느껴지시나요? 예를 들어보겠습니다. 검색어 입력 시 해당 문자열을 가지고 있지 않은 단어 목록만 노출되도록 기능을 바꾼다면 위 설계에선 DividedComponentTwo 컴포넌트만 변경되고 다른 컴포넌트는 변경이 일어나지 않겠죠? 또 최근 검색어 클릭 시 입력창이 초기화되도록 정책이 바뀌었다고 생각해 봅시다. DividedComponentOne 컴포넌트만 변경하면 되겠죠? 분리하기 전의 코드라면 두 가지 경우 모두 SomeComponent에서 코드 변경이 일어납니다. 컴포넌트를 분리하고 나서 테스트 코드는 어떻게 변할까요? 사실 그대로 써도 SomeComponent 수준에서 통합 테스트 느낌으로 쓸 수 있으나 우리는 오늘 단위 테스트를 다루고 있으니 단위 테스트 수준으로 변경해 봅시다.
describe('DividedComponentOne 단위 테스트', () => {
it('검색어를 입력 후 검색 버튼 클릭 시, 현재 검색어를 입력된 문자열로 변경한다', () => {
// ...
});
it('최근 검색어 클릭 시, 현재 검색어를 클릭한 검색어로 변경한다', () => {
// ...
});
it('검색어 입력 후 검색 버튼 클릭 시, 입력한 검색어가 초기화되지 않고 유지된다', () => {
// ...
});
it('검색어 입력 후 최근 검색어 클릭 시, 입력한 검색어가 초기화되지 않고 유지된다', () => {
// ...
});
});
describe('DividedComponentTwo 단위 테스트', () => {
it('현재 검색어가 없으면 단어 목록이 모두 노출된다', () => {
// ...
});
it('현재 검색어가 존재할 경우 검색어 문자열이 포함된 단어들만 노출된다', () => {
// ...
});
});
describe('SomeComponent 단위 테스트', () => {
it('검색어를 입력 후 검색 버튼 클릭 시, 최근 검색어로 저장되며 화면에 노출된다', () => {
// ...
});
it('최근 검색어 클릭 시, 최근 검색어로 저장되며 화면에 노출된다', () => {
// ...
});
});
컴포넌트 코드뿐만 아니라 각 컴포넌트가 수행하는 테스트 케이스의 수도 적습니다. 컴포넌트의 분리로 각 컴포넌트로 책임이 세분화되어 각각이 가진 기능이 하나의 컴포넌트일 때보다 적기 때문이죠! 만약 어떤 컴포넌트가 테스트하기 어렵다면 응집도가 낮거나 인터페이스가 잘못 설계된 코드가 아닌지 의심해 보아야 합니다. 위의 예제만 보아도 테스트하기 좋은 코드가 잘 작성된 코드일 것 같은 느낌이지 않나요? 개선한 코드가 파악하기에도 수정하기에도 훨씬 쉽습니다. 이번엔 비교적 간단한 컴포넌트를 분리한 거라 와닿지 않으실 수도 있습니다. 하나의 기능에 테스트 코드가 많을 수 있지만 보통 테스트 코드가 많아진다는 것은 그만큼 모듈의 역할과 책임이 늘었다는 것을 의미합니다. 컴포넌트가 하는 일이 많아지고 더불어 테스트 코드가 많아진다면 그건 컴포넌트 분리를 해야 한다는 신호일 가능성이 큽니다. 코드를 분리하는 여러 가지 기준에 테스트 코드도 참고한다면 좀 더 응집도 있게 컴포넌트를 운영할 수 있을 겁니다.
Testing Library를 사용하면 웹 표준과 접근성에 좀 더 신경 쓸 수 있습니다
웹 표준과 접근성은 웹프론트엔드 개발자라면 신경 써서 개발해야 할 부분입니다. 하지만 컴포넌트 단위로 개발되고 다양한 라이브러리를 사용하면서 태그 구조가 이상하거나 빠진 속성을 체크하기가 조금 어려워졌습니다. 이런 상황에서 Testing Library를 활용한다면 간단하지만, 효과적으로 웹 표준과 접근성을 좀 더 신경 쓸 수 있도록 도와주는데요! 아래 코드를 살펴봅시다.
const ComponentOne = () => {
const contents = [
{ id: 1, title: '제목1', content: '내용1' },
{ id: 2, title: '제목2', content: '내용2' },
];
return (
<div>
<p>안녕하세요! 배달의민족입니다.</p>
<p>
{contents.map(({ id, title, content }) => (
<ComponentTwo key={id} title={title} content={content} />
))}
</p>
</div>
);
};
const ComponentTwo = ({ title, content }) => {
return (
<>
<h4>{title}</h4>
<p>{content}</p>
</>
);
};
혹시 이상한 점을 눈치채셨나요? p 태그 내에서 ComponentTwo 컴포넌트를 호출하고 있고, ComponentTwo 내부에는 h4 태그와 p 태그가 있습니다. h4 태그와 p 태그 모두 p 태그 내부에서 사용하는 건 표준에 맞지 않습니다. 여러분이 이 사실을 알고 있더라도 같은 파일이 아닌 여러 파일에 컴포넌트가 흩어져 있는 React의 특성상 놓치는 부분이 생기기 마련입니다. 일반적인 그냥 태그가 아니라 styled-components나 사내 디자인 시스템 컴포넌트 같은 것을 썼다면 모든 곳에 올바르게 웹 표준을 준수해서 HTML 태그를 작성했다고 자신할 수 있을까요? React에서 알려주긴 하지만 개발할 땐 눈에 띄지 않을 수 있습니다. Testing Library와 함께 테스트를 자동화한다면 아래와 같은 오류도 한 번 더 체크하면서 개발할 수 있습니다.
웹 표준 준수에도 도움을 줄 뿐만 아니라 Testing Library는 또 접근성에도 신경 쓸 수 있게 해줍니다. Testing Library를 사용하실 때 여러분은 혹시 TestId를 쓰시나요? Testing Library의 철학상 TestId는 사실 후순위로 고려해야 할 인터페이스입니다. 물론 필요한 부분에는 사용해야겠지만 대부분은 Role이나 Text, Label로 찾을 수 있습니다. Testing Library에서 요소를 찾는 방법의 우선순위를 지정해 두었는데 ByTestId는 가장 마지막에 있습니다. Role은 aria-role, aria-label을 비롯한 Label Text 등 접근성과 관련한 여러 가지 방법으로 HTML 요소를 찾을 수 있으니 사용자 입장에서 볼 수 없는 요소가 아니라면 다른 방식을 이용해 테스트 코드를 작성하면서 동시에 접근성도 챙겨보아요!
Part 1을 마치며, 이제는 손으로 익힐 차례입니다
지금까지 단위 테스트 코드 작성에 앞서 도움이 될만한 “이론적인” 이야기를 여럿 소개했습니다. 이제 좋은 테스트 코드란 무엇인지 감이 좀 잡히시나요? 여전히 모르시겠나요? 만약 그렇다면 말로만 떠들었지 아직 실제 테스트 코드 작성을 하지 않아서 그럴 수도 있습니다. Part 2는 실전 편이라는 이름으로 테스트 코드 작성을 현실적인 예제와 함께 작성해볼 예정인데요! 이론 편을 다 읽으신 여러분들께 질문을 하나 남기면서 Part 1을 마무리하고 싶습니다.
오늘은 이론적인 내용을 소개해서 머리로 익혔으니 이제 손으로 직접 코드를 써가면서 익힐 때입니다. 다음 글을 조금만 기다려주세요!
참고 자료
- Google Testing Blog: Testing on the Toilet: Tests Too DRY? Make Them DAMP!
- DAN NORTH & ASSOCIATES LIMITED: Introducing BDD
- 5 Questions Every Unit Test Must Answer
- Testing Library

서비스의 시작과 끝을 담당하는 프론트엔드 개발자로서 매일 고객분들과 맞닿고 있습니다. 주문, 혜택, 선물하기, 이벤트 등 다양한 프로덕트를 거쳐 요즘은 코어웹프론트개발팀에서 회원과 관련된 도메인을 주로 다루고 있습니다.