테스트 주도 개발(TDD)이란?
TDD는 Test-Driven Development의 약자로 실제 코드를 작성하기 전에 테스트 코드를 먼저 작성하는 개발 프로세스다. TDD 프로세스는 다음 단계를 따른다.
- 테스트 코드 작성: 우선 구현하려는 동작을 설명하는 테스트 코드를 작성한다. 이때, 아직 동작을 구현하는 코드를 작성하지 않았으므로 테스트는 실패 해야한다.
- 테스트 실행: 테스트를 실행한다. 위에 언급했듯이, 코드를 작성하지 않았으므로 실패해야한다.
- 코드 작성: 1번에 작성했던 설명에 부합하는 코드를 작성한다. 이때 테스트를 통과하기 위한 목적으로만 작성해야한다.
- 즉 아직 존재하지 않은 많은 문제들을 생각하여 코드를 작성하기보다는 테스트 목표에 집중하여 작성하는 것이 중요하다.
- 테스트 실행: 테스트를 다시 실행한다.
- 리팩터링: 테스트를 통과하면 코드를 리팩터링 한다.
TDD의 이점
- 테스트를 먼저 작성함으로써, 개발자는 구현하려는 동작에 대해 먼저 생각하게 되어 특정한 요구사항에 맞는 코드를 작성할 수 있다.
- 코드의 유지보수성을 향상시키고, 시간이 지나도 프로젝트의 유지 관리가 쉬워진다.
- 코드 수정 시 미처 생각지 못한 부분(기존 코드에 영향을 받는)에 대한 문제점을 미리 파악할 수 있다.
- 애플리케이션에 대한 신뢰성이 높아진다.
- 테스트 코드는 동작 방식을 설명하니, 테스트 파일 자체를 문서로 활용할 수 있다.
- 각종 에러 케이스에 대한 생각을 많이 하게 되므로 두뇌 건강에 좋다.
테스트 종류
단위 테스트(Unit Test)
툴: Jasmine, Jest, Karma, Mocha
- 단위 테스트는 함수나 메서드와 같은 작은 단위의 코드를 테스트하는 데 중점을 두는 테스트 유형이다. 즉, 시스템의 전체적인 동작에 중점을 맞추지는 않는다.
- 단위 테스트의 목적은 코드의 각 단위가 예상대로 작동하고 요구 사항을 충족하는지 확인하는 것이다.
예: 카카오 프렌즈샵에서 상품을 구매하는 경우, 단위 테스트는 `구매` 기능을 테스트할 때 고객의 잔액에서 상품의 금액 만큼 출금하는지, 상품의 재고를 업데이트 하는지, 영수증을 잘 생성하는지 각각 확인한다.
코드 예시
import { render } from '@testing-library/react';
import Item from '../components/Item';
describe('Item', () => {
it('should render item name and price', () => {
const { getByText } = render(
<Item name="Test Item" price={12000} addToCart={() => {}} />
);
expect(getByText('Test Item')).toBeInTheDocument();
expect(getByText('Price: 12000')).toBeInTheDocument();
});
it('should remove item stock', () => {
// ...
});
// ...
});
통합 테스트(Integration Test)
툴 종류: Jest, Testing Library
- 통합 테스트는 시스템의 여러 단위 또는 구성 요소 간의 상호 작용을 검증하는 테스트 유형이다.
- 서로 다른 단위가 함께 동작하면서 흐름에 맞게 잘 동작하고, 예상한 결과를 생성하는지를 테스트한다.
예: 카카오 프렌즈샵에서 상품을 구매하는 경우, 통합 테스트는 `구매`시 고객의 잔액, 재고 업데이트, 영수증 생성시 다른 구성 요소 간의 상호 작용이 잘 되어 구매가 완료되는지 확인한다.
코드 예시
import { render, screen, fireEvent } from '@testing-library/react';
import Shop from '../pages/shop';
describe('Shop', () => {
it('should display receipt after checkout', () => {
render(<Shop />);
const item1Button = screen.getByText('Item 1');
const item2Button = screen.getByText('Item 2');
const cartItems = screen.getAllByTestId('cart-item');
const checkoutButton = screen.getByText('Checkout');
const receiptItems = screen.getAllByTestId('receipt-item');
const total = screen.getByTestId('receipt-total');
// 장바구니에 상품 추가
fireEvent.click(item1Button);
fireEvent.click(item2Button);
expect(cartItems).toHaveLength(2);
// 장바구니에서 상품 제거
const removeButton = cartItems[0].querySelector('button');
fireEvent.click(removeButton);
expect(cartItems).toHaveLength(1);
// 상품을 구매하고, 영수증 출력
fireEvent.click(checkoutButton);
expect(receiptItems).toHaveLength(1);
expect(receiptItems[0]).toHaveTextContent('Item 2 - 12000원');
expect(total).toHaveTextContent('Total: 16000원');
});
});
E2E 테스트 (End-To-End Test)
툴 종류: Cypress, Puppeteer
- E2E 테스트는 시스템의 시작부터 끝까지 전체 흐름을 확인하는 테스트 유형이다.
- 시스템이 예상대로 작동하고 사용자의 요구 사항을 충족하는지 확인하기 위해 모든 구성 요소와 해당 구성 요소의 상호 작용을 테스트하는 것이 포함된다.
예: 카카오 프렌즈샵에서 상품을 `구매`하는 경우
- 고객이 카카오 로그인
- 상품을 선택하여 장바구니에 추가
- 상품 구매
- 결제 방식 선택
- 구매 완료
- 구매 영수증을 고객 메일로 전송
위의 예시처럼 사용자의 입장에서 전체 Flow가 정상적으로 동작을 하는지 확인한다.
코드 예시
const puppeteer = require('puppeteer');
describe('Shopping', () => {
let browser;
let page;
beforeAll(async () => {
// 테스트 환경 설정
browser = await puppeteer.launch();
page = await browser.newPage();
await page.goto('http://localhost:3000');
await page.type('[data-testid="login-email"]', 'user@example.com');
await page.type('[data-testid="login-password"]', 'password');
await page.click('[data-testid="login-button"]');
await page.waitForNavigation();
});
afterAll(async () => {
// 브라우저 종료
await browser.close();
});
it('should allow a user to buy an item and receive a receipt', async () => {
// 상품을 장바구니에 추가
await page.click('[data-testid="item-123"]');
await page.click('[data-testid="add-to-cart-button"]');
await page.click('[data-testid="cart-icon"]');
// 장바구니의 추가된 상품 체크
const cartItem = await page.waitForSelector('[data-testid="cart-item"]');
expect(await cartItem.evaluate(el => el.innerText)).toContain('Item 1');
// 아이템 구매
await page.click('[data-testid="checkout-button"]');
await page.click('[data-testid="confirm-purchase-button"]');
await page.waitForSelector('[data-testid="receipt"]');
// 영수증 화면 출력
const receiptItems = await page.$eval('[data-testid="receipt-items"]', el => el.innerText);
expect(receiptItems).toContain('Item 1');
expect(receiptItems).toContain('12000 원');
const receiptTotal = await page.$eval('[data-testid="receipt-total"]', el => el.innerText);
expect(receiptTotal).toContain('Total: 16000 원');
// 영수증을 고객 메일로 전송
await page.click('[data-testid="send-receipt-button"]');
const receiptSent = await page.waitForSelector('[data-testid="receipt-sent"]');
expect(await receiptSent.evaluate(el => el.innerText)).toContain('Receipt sent to user@example.com');
});
});
위에서 사용한 테스팅 라이브러리인 Puppeteer와 Jest에 대해 좀 더 알아보고 싶다면, 아래 포스트들을 읽어보는 것을 추천한다.
Puppeteer
Jest
그냥 E2E로만 테스트하면 안 될까?
위의 코드를 보면 어차피 Unit, Integration 테스트 코드를 작성해도, 실제로 사용자는 E2E 방식으로 동작할텐데 굳이 시간 써가면서 각 코드를 써야할까라는 생각이 든다.
사실 Unit, Integration에서 테스트 했던 내용들은 E2E 테스트로 확인할 수 있고, 테스트가 중복되는 경우가 많다.
하지만, Unit이 E2E보다 테스트 실행 시간이 훨씬 빠르다. 그리고 세분화 할수록 어떤 에러가 발생했는지도 빠르게 체크 할 수 있다. 정리하면 다음과 같다.
E2E만 사용할 경우(granularity가 낮을 수록)
- 테스트 코드 작성 시간 ↑
- 유지보수 비용 ↑
- 이슈 발생 시, 문제 해결 시간 ↓
E2E, Integrationm, Unit을 사용할 경우(granularity가 높을 수록)
- 테스트 작성 시간 ↓
- 유지보수 비용 ↓
- 이슈 발생 시, 문제 해결 시간 ↑
어떤 방식이 특별히 나쁘다라고 단언할 수는 없다. 각각의 Trade Off가 존재한다. 어느 정도까지 세분화를 할지는 프로젝트 규모와 개발 기한에 따라 달라질 것이다.
간단 팁
- 필요없는 테스트는 넘어가도 된다: 예를 들어 데이터베이스를 연결이 잘 되는지는 프론트엔드와 관련이 없으므로 테스트할 필요가 없다. 이에 대한 것은 따로 Unit 테스트를 할 필요가 없다.
- E2E 테스트가 가장 우선시 되야한다: 결국은 사용자 관점으로 봐야하므로 E2E 테스트가 가장 우선시 되야한다. 하지만 동작 Flow와 관련 없는 것들은 세분화하자 (예: 프렌즈 샵의 상품의 재고를 표시하는 것은 굳이 E2E 테스트에서 확인할 필요가 없다.)
- 함수의 기능이 `단순한 연결`일 경우는 작성하지 않아도 된다: 예를 들어 NextJS의 getStaticProps는 데이터를 갖고와서 props에 넘겨주기만 하는 함수이다. 이 함수는 테스트할 필요가 없다. (함수 내에 데이터를 변환하거나 복잡한 로직이 있으면 모를까)