들어가며
회사에서 레거시코드를 열심히 고치는 도중 "이거 제대로 하고 있는게 맞나?" 하는 생각이 문득 들었다. 다행히도 내가 받은 코드는 서로에 대한 의존성이 거의 없고 재사용되는 코드가 없어서 읽기가 힘들 뿐 한번 읽은 뒤로는 분리하기는 쉬웠다.
테스팅 없는 리팩토링은 줄 없는 번지점프를 하는 것과 같다.
개발자 커뮤니티에서 널리 퍼진 격언이다. 지금까지 줄 없이 뛰었으니 이제 줄 다는 방법부터 배워야 할 차례이다.
테스팅 종류는 어떤게 있지?
테스트도 종류가 여러 가지다. 크게 세 가지로 나눠볼 수 있다.
단위 테스트(Unit Test)
가장 작은 단위의 테스트다. 함수 하나, 메서드 하나가 제대로 동작하는지 확인한다. "이 함수에 2랑 3을 넣으면 5가 나와야 해"처럼 아주 구체적인 검증을 한다.
예를 들어 장바구니 할인 계산 함수가 있다고 치자.
JAVASCRIPT1// 테스트 대상 함수 2function calculateDiscount(price, discountRate) { 3 if (discountRate < 0 || discountRate > 100) { 4 throw new Error('할인율은 0~100 사이여야 합니다'); 5 } 6 return price * (1 - discountRate / 100); 7} 8 9// 단위 테스트 (Unit Test) 10describe('calculateDiscount', () => { 11 it('10000원에 20% 할인하면 8000원이다', () => { 12 expect(calculateDiscount(10000, 20)).toBe(8000); 13 }); 14 15 it('할인율이 0이면 원래 가격 그대로다', () => { 16 expect(calculateDiscount(10000, 0)).toBe(10000); 17 }); 18 19 it('할인율이 100 초과면 에러를 던진다', () => { 20 expect(() => calculateDiscount(10000, 150)).toThrow(); 21 }); 22});
이런 식으로 설명내용은 한글로 적고 아래쪽 expect 영역에는 기대되는 값이나 결과를 적으면 된다. 외부 의존성을 최소화하여 로직만을 테스트하기 때문에 엮었을 떄 터질수도 있다는 단점이 있다.
통합 테스트 (Integration Test)
단위 테스트의 한계를 보완한다. 여러 모듈이 서로 잘 연결되는지, DB 연동은 제대로 되는지, API 호출은 문제없는지 확인한다. "회원가입 로직이 실제 DB에 잘 저장되나?" 같은 질문에 답하는 테스트다.
실제로 주문 API를 테스트한다고 해보자.
JAVASCRIPT1describe('POST /orders', () => { 2 beforeEach(async () => { 3 // 테스트용 DB 초기화 4 await db.clear(); 5 await db.users.create({ id: 1, name: '테스트유저', point: 5000 }); 6 await db.products.create({ id: 100, name: '키보드', price: 50000, stock: 10 }); 7 }); 8 9 it('주문하면 재고가 줄고 포인트가 적립된다', async () => { 10 const response = await request(app) 11 .post('/orders') 12 .send({ userId: 1, productId: 100, quantity: 2 }); 13 14 expect(response.status).toBe(201); 15 16 // DB 상태 확인 17 const product = await db.products.findById(100); 18 expect(product.stock).toBe(8); // 10 - 2 19 20 const user = await db.users.findById(1); 21 expect(user.point).toBe(10000); // 5000 + 5000 (5% 적립) 22 }); 23 24 it('재고보다 많이 주문하면 실패한다', async () => { 25 const response = await request(app) 26 .post('/orders') 27 .send({ userId: 1, productId: 100, quantity: 99 }); 28 29 expect(response.status).toBe(400); 30 expect(response.body.message).toBe('재고가 부족합니다'); 31 }); 32});
실제 환경에 가까워서 신뢰도가 높은 테스트이다. 실제로 가장 많이 사용하는 방식이다.
E2E 테스트 (End-to-End Test)
사용자 입장에서 전체 흐름을 테스트한다. 브라우저를 띄워서 실제로 버튼 클릭하고, 폼 입력하고, 페이지 이동하는 걸 자동화한다.
테스팅 툴은 어떤게 있지?
테스트 종류를 알았으니 이제 실제로 뭘로 작성할지 골라야 한다. JavaScript/TypeScript 생태계 기준으로 많이 쓰는 도구들을 정리해봤다.
Jest
Facebook에서 만든 테스트 프레임워크다. 사실상 표준처럼 쓰인다. 설정이 거의 필요 없고, 테스트 러너, assertion, mocking이 전부 내장되어 있어서 따로 이것저것 설치할 필요가 없다.
JAVASCRIPT1// jest로 작성한 테스트 2describe('UserService', () => { 3 it('유저를 생성하면 암호화된 비밀번호가 저장된다', async () => { 4 const user = await UserService.create({ 5 email: 'test@test.com', 6 password: '1234' 7 }); 8 9 expect(user.password).not.toBe('1234'); 10 expect(user.password).toMatch(/^\$2b\$/); // bcrypt 해시 패턴 11 }); 12});
Vitest
Vite 기반 프로젝트라면 이쪽이 더 낫다. Jest와 문법이 거의 똑같아서 마이그레이션도 쉽고, 속도가 훨씬 빠르다. 요즘 새 프로젝트 시작하면 Jest 대신 Vitest 쓰는 경우가 많아졌다.
JAVASCRIPT1// vitest도 문법은 거의 같다 2import { describe, it, expect } from 'vitest'; 3 4describe('calculateTotal', () => { 5 it('배열의 합을 구한다', () => { 6 expect(calculateTotal([1, 2, 3])).toBe(6); 7 }); 8});
Playwright
Microsoft에서 만든 E2E 테스트 도구다. Chromium, Firefox, WebKit 전부 지원하고, 자동 대기 기능이 있어서 "요소가 나타날 때까지 기다려" 같은 코드를 덜 써도 된다.
JAVASCRIPT1import { test, expect } from '@playwright/test'; 2 3test('로그인 후 대시보드로 이동한다', async ({ page }) => { 4 await page.goto('/login'); 5 6 await page.fill('input[name="email"]', 'user@test.com'); 7 await page.fill('input[name="password"]', 'password123'); 8 await page.click('button[type="submit"]'); 9 10 // 자동으로 페이지 이동 기다림 11 await expect(page).toHaveURL('/dashboard'); 12 await expect(page.locator('h1')).toHaveText('환영합니다'); 13});