이번에는 TDD를 프로젝트에 적용해보면서 첫 날에 느낀 장점에 대해 정리를 하는 글을 작성하게 됐습니다. 제가 TDD를 프로젝트에 적용한 이유가 궁금하신 분들은 이전 글 이번 플젝에 TDD를 도입해볼까? 참고 바랍니다.
이전 글에도 작성하였지만 TDD는 단순히 기능을 만들기 전에 테스트를 먼저 짜는 것만 의미하는 게 아니다. 테스트를 먼저 짜는 행위가 가져오는 파급 효과를 생각해보자.
테스트를 먼저 짜기 위해선 무엇을 알아야 할까?
테스트는 해당 기능이 정상적으로 동작하는지 확인하는 과정이다. 즉, 테스트를 작성하기 위해선 해당 기능이 어떤 동작을 해야하는지 알아야 한다. 또한 해당 기능이 테스트에 용이한 구조를 가지게끔 설계해야한다.
테스트하기 쉬운 구조가 왜 좋은 코드일까?
만약 여러분이 아래와 같은 함수를 테스트 해야한다고 가정해보자.
function 주문처리(상품목록, 할인율) {
// 1. 총액 계산
let 총액 = 0;
for (let i = 0; i < 상품목록.length; i++) {
총액 += 상품목록[i].가격 * 상품목록[i].수량;
}
// 2. 할인 적용
const 할인금액 = 총액 * 할인율;
const 최종금액 = 총액 - 할인금액;
// 3. 영수증 출력 형식 구성
const 영수증 = {
총액: 총액,
할인금액: 할인금액,
최종금액: 최종금액,
항목수: 상품목록.length
};
return 영수증;
}
이 함수는 아래와 같은 3가지 책임을 갖고 있다.
즉, 테스트 시 특정 로직만 검증하기가 어렵고 로직 변경 시 다른 부분까지 영향
받기 쉬운 상태이다.
위 함수를 테스트하기 쉬운 구조로 분리하면 어떻게 될까?
function 총액계산(상품목록) { ... }
function 할인적용(총액, 할인율) { ... }
function 영수증생성(총액, 할인금액, 항목수) { ... }
각 함수들이 하나의 책임만 갖게 되어 특정 기능만 테스트 할 수 있고, 다른 기능에 영향을 주지 않는 코드가 탄생하게 된다.
즉, 단일 책임 원칙(SRP)를 잘 지키는 코드 구조를 갖게 된다는 것이다.
그래서 내가 생각하는 TDD의 장점은 다음과 같다.
위의 예시로 앞의 장점 두 개는 충분히 설명된 것 같고, 리팩토링에 대한 두려움이 줄어든다
는 밑에 서술하겠다.
TDD는 3가지 단계를 거쳐 진행된다.
Red
: 통과에 실패하는 테스트 코드를 먼저 작성Green
: 테스트 코드를 성공시키기 위해 실제 코드 작성Blue(Refactor)
: 중복 코드 제거, 일반화 등 리팩토링 수행여기서 핵심은 실패하는 테스트 코드
를 먼저 작성하는 것이다. 테스트 코드를 작성하다보면 개발자의 실수로 무조건 통과하거나 의미 없는 테스트 코드를 작성하게 된다. 특히, 프론트엔드에서 비동기로 동작하는 메서드를 테스트 할 때 주의해야한다.
React의 테스트 툴인 React Testing Library에서는 "너의 컴포넌트가 어떻게 구현되었는지가 아니라, 어떻게 동작하는지를 테스트하라"고 말한다.
div가 몇 겹이고 내부에서 useEffect가 실행되었는지 등 내부 구현에 집착하는 테스트를 하지 말아야 한다.
사용자 입장에서 컴포넌트를 바라보고, 클릭했을 때 어떤 변화가 일어나는지 혹은 텍스트가 정확히 렌더링되는지 테스트 해야한다.
이 컴포넌트를 테스트해보자!
아래와 같이 클릭하면 로그인 페이지로 이동하는 버튼 컴포넌트가 있다
이 컴포넌트는 무엇을 테스트 해야할까?
const LoginButton = () => {
return (
<Link
to="/login"
>
로그인
</Link>
);
};
사용자 관점에서 바라보면 된다.
describe("LoginButton 컴포넌트", () => {
it("버튼이 렌더링되어야 한다", () => {
render(
<MemoryRouter>
<LoginButton />
</MemoryRouter>
);
const linkElement = screen.getByRole("link", { name: "로그인" });
expect(linkElement).toBeInTheDocument();
});
it("버튼 클릭 시 /login 페이지로 이동해야 한다", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<LoginButton />} />
<Route path="/login" element={<div>로그인 페이지입니다</div>} />
</Routes>
</MemoryRouter>
);
const linkElement = screen.getByRole("link", { name: "로그인" });
// 클릭 이벤트 발생
fireEvent.click(linkElement);
// 로그인 페이지로 이동했는지 확인
expect(screen.getByText("로그인 페이지입니다")).toBeInTheDocument();
});
});
이렇게 두 가지만 테스트하면 되고, 이 로그인 버튼이 어떤 태그 안에 생겼는지 등은 테스트하지 않아도 된다.
여기서 잠깐 토막 상식!
React Testing Library에서는 DOM 요소를 가져올 때 getByRole
을 통해 가져오는 것을 권장한다. getById, querySelector는 많이 알겠지만 Role은 생소한 사람들이 많을 것이다. 이것은 WAI 라는 웹 접근성 협회에서 지정한 ARIA-Role을 기반으로 요소를 찾는 메서드이다.
ARIA-Role은 웹 접근성 향상을 위해 HTML 요소가 어떤 역할을 하는지 명시하는 속성이다. 시각장애인 등 보조기기를 사용하는 사용자들이 웹을 이해하고 탐색할 수 있도록 도와준다.
Accessible Rich Internet Applications의 약자이며, 대표적으로 button, link, textbox, heading
등이 있고 시멘틱 태그를 사용한다면 대부분의 요소에 이 역할이 자동으로 적용되게 된다.
프론트엔드를 처음 배울 때 시멘틱 마크업의 중요성에 대해 배우게 된다. 하지만 구현을 하다보면 태그가 가진 의미대로 HTML 구조를 짜는 게 번거롭다는 이유로 필요성을 간과하곤 한다. 나도 그러했다.
시각 장애인은 웹을 어떻게 이해할 수 있을까?
시각장애를 가진 강사님의 웹 접근성 특강을 듣고 내 생각이 바뀌게 됐다. 시각 장애인은 스크린 리더와 같은 보조 도구를 통해 웹을 이해한다. 이 도구는 단순히 텍스트를 읽는 것이 아니라, 웹에 있는 태그들을 기반으로 구조와 의미를 파악해 사용자에게 전달한다.
예를 들어 <h1>
태그는 "여기가 가장 중요한 제목이다"라는 의미로, <button>
태그는 "이건 클릭할 수 있는 버튼이다"라고 안내한다.
하지만 만약에 시맨틱 태그 대신 <div>
나 <span>
만을 사용해 구성된 페이지라면, 스크린 리더는 그것이 무엇인지 알 수 없어 사용자는 혼란을 겪거나 주요 기능에 접근조차 하지 못할 수 있다.
이러한 이유로 웹 접근성에서는 시멘틱 마크업과 ARIA-Role이 매우 중요하다. 개발자가 마크업을 어떻게 작성하느냐에 따라 누군가에게는 아무것도 안보이는 세상이 될 수도 있다. 만약 당신이 프론트엔드 개발자라면 getByRole
을 습관화하여 누구에게나 보이는 세상을 만들어보자.
TDD를 처음 적용해보면서 많은 시행착오를 겪고 ""이게 실제로 유용한가""라는 끊임없는 의구심이 들었다. 이 생각이 180도 바뀌게 된 계기가 있었는데
나와 프론트엔드 팀원은 테스트 코드 작성에 익숙하지 않았기 때문에 연습하기 위해 서로 드라이버 / 네비게이터가 되어주면서 기본 컴포넌트를 구현하였다.
이 사진에 있는 컴포넌트이고, 중복된 코드를 분리하는 리팩토링이 필요했다.
function SoloPracticeButton() {
const navigate = useNavigate();
const handleClick = () => {
navigate('/solo-practice');
};
return <button onClick={handleClick}>혼자 연습하기</button>;
}
function PairPracticeButton() {
const navigate = useNavigate();
const handleClick = () => {
navigate('/pair-practice');
};
return <button onClick={handleClick}>같이 연습하기</button>;
}
따라서, 아래와 같이 공통 기능을 하는 PracticeButton
컴포넌트를 만들고 각 컴포넌트에 적용하였다.
function PracticeButton({ text, path }: PracticeButtonProps) {
const navigate = useNavigate();
return <button onClick={() => navigate(path)}>{text}</button>;
}
function PairPracticeButton() {
return <PracticeButton text="같이 연습하기" path="/pair-practice" />;
function SoloPracticeButton() {
return <PracticeButton text="혼자 연습하기" path="/solo-practice" />;
이 리팩토링 과정에서 혼자/같이 연습하기 버튼 컴포넌트의 변경이 있었지만 이미 TDD로 진행하며 작성된 테스트가 존재하였기 때문에 테스트가 통과된다면 이 기능이 정상적으로 동작한다는 확신을 가질 수 있었다. 무엇보다 놀라웠던 점은, 프론트 서버를 실행하지 않고도 기능을 검증할 수 있었다는 점이다.
[A5] 프론트엔드에서 TDD가 가능하다는 것을 보여드립니다.
이 영상을 보며 테스트 코드 통과 여부만 확인하며 개발한다는 게 이해가 잘 안갔는데, 실제로 TDD를 적용해보니 우리도 테스트 통과 여부만 확인하고 굳이 동작 테스트를 안하게 되었다.
TDD의 가장 강력한 장점은 리팩토링
할 때 느껴진다.
물론 TDD를 적용해본지 얼마 안됐다 보니 경험이 유니크하지는 않을 수 있어도 내 개발 인생에서는 굉장히 유니크한 경험이었다. 앞으로 TDD를 적용해보며 겪은 시행착오를 잘 정리해서 추가적으로 올리겠다.