이번 프로젝트는 TDD 방식을 진행하기로 결정했고, 이를 설정하기 위해 개념을 먼저 정리하고자 합니다.
TDD란 무엇인가?
TDD는 작성하고자 하는 코드가 어떤 일을 할 것인지 묘사하고 동작을 검증할 수 있는 테스트 코드를 먼저 작성한 후 테스트를 진행하며 개발하는 방법입니다. 이를 도식화하면 다음과 같습니다.
TDD를 통해 코드의 퀄리티를 높일 수 있고, 빠른 피드백을 통해 개발 시간을 단축할 수 있습니다. 또한 문서의 역할을 하는 테스트를 먼저 작성하기 때문에 더 좋능 문서화가 가능합니다.
테스트의 종류
테스트는 단위 테스트, 통합 테스트/스냅샷 테스트, e2e 테스트 이렇게 구분할 수 있습니다.
단위 테스트
함수를 직접 호출해 원하는 리턴값이 나오는지 확인하는 테스트 입니다. 테스트 단위가 작아 매우 빠르게 테스트를 진행할 수 있고, 어느 부분에 문제가 있는지 단번에 찾을 수 있습니다.
통합 테스트/ snapshot 테스트
한 컴포넌트의 UI 및 동작 방식을 테스트합니다. 통합 테스트와 스냅샷 테스트는 살짝의 차이가 있는데요. 하나의 목적을 기준으로 구분된 컴포넌트들이 올바른 동작을 하는지 확인하는 것을 통합테스트라고 하고, 테스트 전 컴포넌트의 스냅샷을 저장해두고 이후 컴포넌트의 마크업 및 스타일이 바뀌지 않는지 확인하는 것을 스냅샷 테스트라고 합니다.
컴포넌트 하나당 하나의 스냅샷 테스트를 구현하는 것이 좋다고 하네용
e2e 테스트
최상위에서 진행되는 테스트입니다. 제작한 프로젝트 전체의 동작 과정에 오류가 없는지 확인하는 테스트이기 때문에 매우 오래 걸리고 디버깅에는 어려운이 있습니다. 하지만 각 컴포넌트 사이의 문제를 파악하고 해결하는데 용이합니다.
TDD를 적용하는 방법
먼저 TDD의 사이클을 다음과 같습니다.
1. 실패하는 테스트 코드를 작성
2. 테스트를 성공하게 하는 코드 구현
3. 리팩토링
하지만 이 내용만으론 어떻게 TDD를 진행해야 하는지 감이 잡히지 않습니다. 이를 조금 더 쉽게 파악할 수 있게 도와주는 로버트 C 마틴의 글은 이렇습니다.
- 실패하는 테스트 없이 코드를 넣지 않기 (테스트 코드 작성 전에는 코드 작성 금지)
- 실패가 생기면 테스트 작성 멈추기 (새로운 테스트를 작성하는 과정에서 지금까지 작성한 테스트 중 실패가 발생한다면 새로운 테스트 작성을 멈추기 해결하기)
- 실패한 테스트를 넘기면 즉시 코딩 멈추기(테스트 실패 상태에서 코드를 계속 작성하는 것은 금지되고, 실패한 테스트를 해결할 때까지 코딩을 멈추기)
- 리팩토링 이후 반복
- 한 사이클은 10~60초
한 사이클을 굉장히 빨리 돌려야 한다는 것에 놀랐습니다. 즉 테스트를 가장 작게 넣어서 하나씩 빠르게 해결하며 개발하는 것이 좋은 TDD 방식 중 하나라는 것이죠.
TDD 시작 전 다음 마음가짐을 가지고 시작하는게 좋다고 합니다.
- 최소 단위 테스트 : 테스트는 가장 작게 넣어야 합니다.
- 테스트 대상의 고립 : 테스트 대상은 다른 컴포넌트에 의존성이 없어야 합니다.
- 테스트의 문서 역할 : 다른 문서가 없어도 테스트를 읽으면 우리의 코드를 다른 개발자가 바로 쓸 수 있도록 작성해야 합니다.
이제 적용 예시를 알아보면 좋을것 같습니다.
TDD 적용 예시
프로젝트에서 검색 기능을 구현하고 해당 기능을 TDD 방식으로 개발해봅시다.
1. 요구사항 분석 및 테스트 케이스 정의
요구사항 :
- 사용자는 검색 바에 검색어를 입력할 수 있어야 합니다.
- 사용자가 검색어를 입력하면 해당 검색어에 맞는 프로그램 목록이 표시되어야합니다.
테스트 케이스 :
- 검색 바가 렌더링 되어야 합니다.
- 검색어를 입력할 수 있어야 합니다.
- 검색어 입력 시, 프로그램 목록이 필터링 되어야 합니다.
2. 테스트 코드 작성 (실패 상태)
SearchPage 컴포넌트가 있다 가정하고 테스트를 작성합니다.
// pages/SearchPage/SearchPage.test.tsx
import React from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import '@testing-library/jest-dom';
import SearchPage from './SearchPage';
describe('SearchPage 테스트', () => {
test("검색 바가 렌더링되어야 합니다", ()=>{
render(<SearchPage/>); // 페이지 렌더링
const searchInput - screen.getByPlaceholderText(/search/i)
// search가 포함된 placehoder를 가진 요소 불러오기
expect(searchInput).toBeInTheDocument(); // 불러온 요소가 존재하는지 확인
});
test("검색어를 입력할 수 있어야 합니다", () => {
render(<SearchPage/>);
const searchInput = screen.getByPlaceholderText(/search/i);
fireEvent.change(searchInput, { target: { value: 'React' } }); // 검색 바에 React 입력
expect(searchInput).toHaveValue('React'); // React 검색 값을 가지고 있는지 확인
});
test("검색어 입력 시, 프로그램 목록이 필터링되어야 합니다", () => {
render(<searchPage/>);
const searchInput = screen.getByPlaceholderText(/search/i);
fireEvent.change(searchInput, {target: {value: 'React'}});
// 필터링된 프로그램 목록이 화면에 표시되어야 함
const filterProgram - screen.getByText(/React Program/i/);
expect(filterProgram).toBeInTheDocument();
});
});
3. 기능 구현 : 테스트가 성공하도록 기능 코드를 작성합니다.
// pages/SearchPage/SearchPage.tsx
import React, { useState } from 'react';
import ProgramList from '../../components/ProgramList/ProgramList';
const SearchPage = () => {
const [query, setQuery] = useState('');
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
return (
<div>
<h1>Program Search</h1>
<input
type="text"
placeholder="Search"
value={query}
onChange={handleSearch}
/>
<ProgramList query={query} />
</div>
);
};
export default SearchPage;
프로그램을 보여주는 ProgramList 컴포넌트도 만들어 줍시다.
// components/ProgramList/ProgramList.tsx
import React from 'react';
import { usePrograms } from '../../hooks/usePrograms';
const ProgramList = ({ query }: { query: string }) => {
const { data: programs, isLoading, error } = usePrograms();
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error fetching programs</div>;
}
const filteredPrograms = programs.filter((program: any) =>
program.name.toLowerCase().includes(query.toLowerCase())
);
return (
<ul>
{filteredPrograms.map((program: any) => (
<li key={program.id}>{program.name}</li>
))}
</ul>
);
};
export default ProgramList;
이렇게하여 모든 테스트를 통과하게 만들어 줍니다. jest --watchAll을 통해 코드를 저장할 때마다 테스트할 수 있도록 해놓고 해야합니다.
4. 리팩토링
코드를 개선하고 중복을 제거하는 리팩토링을 수행합니다.
위의 내용을 생가해봤을때, 검색 입력 처리 로직을 useSearch 커스텀 훅으로 추출할 수 있습니다.
// src/hooks/useSearch.ts
import { useState } from 'react';
export const useSearch = () => {
const [query, setQuery] = useState('');
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
return { query, handleSearch };
};
// pages/SearchPage/SearchPage.tsx
import React from 'react';
import ProgramList from '../../components/ProgramList/ProgramList';
import { useSearch } from '../../hooks/useSearch';
const SearchPage = () => {
const { query, handleSearch } = useSearch();
return (
<div>
<h1>Program Search</h1>
<input
type="text"
placeholder="Search"
value={query}
onChange={handleSearch}
/>
<ProgramList query={query} />
</div>
);
};
export default SearchPage;
이렇게 한 사이클을 돌렸습니다.
이후 CI/CD 파이프라인에 테스트를 추가하여 코드 변경 시 자동으로 테스트를 실행하고 오류를 알려줄 수 있도록 하면 더 좋습니다.
이를 기반으로 저도 실제 프로젝트에 적용해 볼 예정이고, 그 과정도 추후에 작성하겠습니다. 감사합니다.