테스트하기 쉬운 코드의 조건들은 무엇인가?
- 함수는 순수하게
- 함수는 순수성을 지키도록 작성한다.
- SRP를 지킨다.
- 하나의 함수, 하나의 모듈은 하나의 책임만 짊어진다.
어제 선언적 프로그래밍에 대해 작성하면서
리액트의 장점은 JSX로 UI단, useState나 다른 걸로 상태단
이렇게 관심사를 분리한 코드를 작성할 수 있다는 것이다.
토스의 use-overlay 같은 라이브러리를 보면 컴포넌트 단에서는
어떤 UI를 보여줄지만 처리하고 상태관리를 훅으로 분리해서 관리한다.
이런 코드 작성법이 곧 테스트하기 좋은 코드와도 연결된다.
코드리뷰할 때도 이런 부분을 염두에 두고 하면
규모가 커져도 사이드 이펙트 우려가 작아지지 않을까 싶다.
커밋메세지 Conventional Commits : 느낌표의 의미
컨벤셔널 커밋이라는 커밋 메세지 규칙이 있다.
이런 형식으로 작성하는 규칙이다.
<type>[optional scope][!]: <description>
feat(login): add kakao social login
feat: add user signup feature
이 중에 느낌표는 어떤 의미인가?
feat!: remove login endpoint
!는 브레이킹 체인지가 포함되었음을 의미한다.
즉 기존에 있던 기능에 영향을 줄 수 있다는 의미
커밋메세지를 작성할 때도 이런 규칙을 잘 지켜야 할 것이다.
브레이킹 체인지란
기존 코드에 문제가 발생할 수 있는 변경을 의미한다.
예시
- 함수 시그니처 변경
- API 경로 변경
- 사용자가 의존하던 기능 제거
- 리턴값 타입 변경
- 필드 이름 변경 등
브레이킹 체인지는 주의 깊게 다뤄져야 한다.
+자동화된 릴리즈 로그 (semantic-release)에서는 주버전 업데이트를 유발한다.
단위 테스트
단위테스트는 테스트 중에서 가장 작은 단위의 코드를 테스트한다.
함수, 컴포넌트, 훅 등 SRP 원칙에 따라 작성된 이 작은 친구들을 테스트한다.
테스팅에서 생각해야 할 것은
- 순수함수인가?
- 의존성이 분리되었는가?
의존성 분리는 스프링을 배웠다면 스프링 대표 특징 중에 의존성 주입 개념이 있다.
즉 의존성은 외부에서 주입해줘야 한다. 직접 하지 않는다.
// 안 좋은 예
function saveUser(user) {
localStorage.setItem('user', JSON.stringify(user));
}
// 좋은 예
function saveUser(user, storage = localStorage) {
storage.setItem('user', JSON.stringify(user));
}
로컬 스토리지는 외부 저장소이다. 여기에 직접 접근하지 않도록 한다.
프론트엔드 테스트 도구
도구 | 용도 |
---|---|
Jest | 테스트 러너, 단위 테스트 주로 사용 |
React Testing Library | React 컴포넌트 테스트 |
Vitest | Vite 기반 프로젝트에서 빠르고 가벼운 테스트 러너 |
Cypress | E2E 테스트 |
Playwright | E2E + 컴포넌트 테스트 가능 |
+또 Storybook도 많이 쓴다. 테스팅과는 약간 결이 다를 수도 있는데
UI 컴포넌트를 테스팅 및 확인해보는 용도로 쓰는데 디자이너와의 협업에 유용하다. (사실 안써봄..ㅠ)
아무래도 공통 컴포넌트가 디자인 시스템이 잘 갖추어져 있다면 컴포넌트 단에서 바로 시각적으로 확인하면 전체적인 디자인 일관성에 큰 도움이 될 것 같다.
E2E 테스트
단위 테스트가 가장 소규모의 단위라고 하면 End-to-End 테스트는 실제 사용자가 사용할 때의 전체 시나리오를 테스트한다.
로그인이나 회원가입 흐름을 검증할 때, 복잡한 폼 입력하고 전송할 때, 페이지 이동+필터+검색 등 뭔가 사용자 시나리오를 기반으로 테스팅한다.
나중에 해봐야겠다.
테스팅을 위해 기존 코드를 바꿔야 할까?
사실 제일 좋은 건 처음부터 테스팅을 생각하고 코드 짜는 건데 그건 쉽지 않다.
테스팅을 위해 코드를 수정하면 결국 유지보수하기도 쉬워지기 때문에 너어엉무 그렇지 않다면? 수정하자
그렇다면 처음부터 코드를 짤 때 어떤 기준에 따라 짜야 할까
테스팅하기 좋은 코드의 기준
순수성
멱등성은 무엇인가?
- 같은 입력이면 항상 같은 결과값이 나온다.
- 부작용=사이드 이펙트가 없거나 동일해야 한다.
멱등성이 보장되는 순수함수가 좋다.
순수함수를 방해하는 2가지 요소
- 제어할 수 없는 값에 의존하는 경우
사용자 입력이나 개발자가 제어할 수 없는 값에 의존하는 함수는 테스트가 어렵다.
(환경변수, 전역변수, 외부 라이브러리 내부 상태, 랜덤값 등)
예제
// ❌ 테스트하기 어려운 예시: 현재 시간에 의존
function getGreeting() {
const hour = new Date().getHours();
return hour < 12 ? "Good morning" : "Good afternoon";
}
// ✅ 개선된 예시:
// 시간을 파라미터로 받음
function getGreeting(currentHour) {
return currentHour < 12 ? "Good morning" : "Good afternoon";
}
// 랜덤 함수를 주입받음
function generateId(randomFn = Math.random) {
return `user_${randomFn().toString(36).substr(2, 9)}`;
}
- 외부에 영향 주는 코드
외부 API, 데이터 베이스 등에 의존하는 경우
이 경우 테스트할 때도 외부 환경에 의존해야 한다.
예제
// ❌ 안좋은 예시 : 여러 책임이 한 함수에 섞임
async function saveUser(userData) {
// 비즈니스 로직 + 외부 저장이 섞여있음
const user = {
id: Date.now(),
name: userData.name,
email: userData.email.toLowerCase(),
createdAt: new Date()
};
await database.save('users', user);
console.log('User saved:', user.name);
return user;
}
// ✅ 좋은 예시
// 순수한 비즈니스 로직
function createUser(userData) {
return {
id: Date.now(),
name: userData.name,
email: userData.email.toLowerCase(),
createdAt: new Date()
};
}
// 외부 효과는 별도 함수로
async function saveUserToDatabase(user, database) {
await database.save('users', user);
return user;
}
// 조합 (필요시)
async function registerUser(userData, database) {
const user = createUser(userData); // 테스트하기 쉬움
await saveUserToDatabase(user, database); // 모킹 필요
console.log('User saved:', user.name);
return user;
}
::: note
핵심: 계산(순수함수)과 저장(side effect)을 분리하기! 🎯
:::
또 데이터베이스가 필요하면 테스트가 어렵다.
테스트를 하려면 모킹이든 데이터베이스의 스키마에 맞는 목업 데이터들이 필요해진다.
페이지네이션 같은 것까지 고려하면 100개 이상의 아이템이 필요한 DB를 사용할 수도 있는데 이런 건 테스트에 시간이 소요
외부와의 연동이 필요한 경우 항상 async
가 필요한 경우이며 이는 테스트하기가 어렵다.
즉, async
함수를 얼마나 핵심 로직에서 벗어나게 하느냐가 프로젝트 전체의 테스트 용이성을 결정한다.
참고
- 테스트하기 좋은 코드 - 테스트하기 어려운 코드
- 매일메일 : 테스트하기 쉬운 코드의 조건들에 대해 설명해주세요.