Test?
테스트는 애플리케이션의 품질과 안정성을 높이기 위해, 사전에 결함을 발견하고 수정하기 위한 행위이다.
- 특정 모듈(ex.함수, 컴포넌트)이 의도한 사양대로 동작하는지 자동화된 코드로 검증하는 과정
- 개발 비용 증가하지만, 장기적으로는 유지보수 비용을 줄인다.
테스트 코드의 효과
좋은 설계를 고민하게 된다.
결합도(coupling): 어떤 모듈이 다른 모듈에 의존하는 정도
테스트 코드를 작성하다보면 결합도 문제를 마주하게 된다.
결합도가 높을수록 한 모듈을 수정했을 때 다른 모듈에도 영향을 줄 가능성이 높아지고, 테스트 관점에서도 여러 문제가 생긴다.- 특정 기능만 따로 검증하기 어렵다.
- 복잡한 구조로 인해 필요한 테스트가 누락될 수 있다
- 결합도가 높기 때문에 여러 테스트 코드를 계속 수정해야 한다.
결국 테스트를 작성하는 과정에서 올바른 설계에 대해 자연스럽게 고민하게 된다.
테스트 코드를 기반으로 안정적인 리팩토링을 가능하게 한다.
테스트가 존재하면 내부 구현을 변경하더라도 기존 동작이 유지되는지 즉시 확인할 수 있다.애플리케이션의 이해를 돕는 문서가 된다.
테스트 코드는 해당 코드가 어떤 입력을 받고, 어떤 결과를 기대하는지를 명확히 보여주는 실행 가능한 문서다.
올바른 테스트 코드 작성을 위한 규칙
1. 인터페이스를 기준으로 테스트를 작성하자
인터페이스: 서로 다른 클래스 또는 모듈이 외부와 상호작용하는 방식
- 내부 구현을 직접 검증하는 테스트 코드는 강한 의존성을 만들며, 구현 변경에 취약하다.
- 인터페이스를 기준으로 테스트를 작성하면 캡슐화를 위반하지 않으며, 구현 변경에도 안정적인 테스트를 유지할 수 있다.
잘못된 테스트 코드 (구현 중심 테스트)
it('isShowModal 상태를 true로 변경했을 때 ModalComponent의 display 스타일이 block이며, "안녕하세요!" 텍스트가 노출된다.', () => {
// 구현에 종속적인 코드와 복잡한 상태 변경 코드들이 발생할 수 있습니다.
SpecificComponent.setState({ isShowModal: true });
});- 어떤 상황에서 상태가 변경되는지 드러나지 않는다.
- 테스트 코드만 보고 무엇을 검증하는지 직관적으로 알기 어렵다.
- 내부 상태(isShowModal)나 변수명을 직접 사용하므로 구현 변경에 취약하다.
- 상태 이름이 바뀌는 순간 모든 테스트를 수정해야 한다.
즉, 구현 세부사항에 종속된 테스트다.
올바른 테스트 코드 (행동 중심 테스트)
it('버튼을 누르면 모달이 열린다.', async () => {
await user.click(screen.getByRole('button', { name: '모달 열기' }));
expect(screen.getByText('안녕하세요!')).toBeInTheDocument();
});- 실제 사용자 행동(버튼 클릭)을 기준으로 테스트한다.
- 내부 상태나 변수명을 알 필요가 없다.
- 구현 방식이 바뀌어도 동작이 동일하면 테스트는 깨지지 않는다.
- 테스트 이름만 봐도 어떤 기능을 검증하는지 명확하다.
테스트는 "어떻게 동작하는가"가 아니라 "무엇을 하는가"를 검증해야 한다.
2. 100% 커버리지보다 의미있는 테스트인지 고민하자
커버리지: 테스트 코드가 프로덕션 코드의 몇 %를 검증하고 있는지 나타내는 지표
구문(Statement), 분기(Branch), 함수(Function), 줄(Line) 등을 기준으로 계산한다.
커버리지는 참고 지표일 뿐, 목표가 되어서는 안 된다.
- 커버리지를 쫓다 보면 과도한 유지보수 비용이 발생한다.
- 수치가 높다고 해서 반드시 올바른 검증이 이루어졌다고 볼 수 없다.
- “코드를 실행했다”는 것과 “올바르게 검증했다”는 것은 다르다.
100% 테스트 커버리지에 의존하는 게 맞을까?
- 테스트 작성, 실행, 유지보수 측면에서 비용이 과도하게 증가할 수 있다.
- 100% 커버리지를 달성했더라도 잘못된 검증 로직으로 인해 실제 버그를 놓칠 수 있다.
// 함수의 모든 분기를 실행
function isLargerThan5(value) {
if (typeof value !== 'number') {
return false;
}
return value > 5;
}
// 아무것도 검증하지 않음
it('isLargerThan5 test', () => {
const result = isLargerThan5(100);
const result2 = isLargerThan5('hello');
});위 테스트는 커버리지는 올라가지만, expect가 없기 때문에 실제로는 아무것도 검증하지 않는다. 커버리지만을 위한 테스트는 의미 없는 테스트가 될 가능성이 높다.
it('숫자가 5보다 크면 true를 반환한다', () => {
expect(isLargerThan5(10)).toBe(true);
});
it('숫자가 5 이하이면 false를 반환한다', () => {
expect(isLargerThan5(3)).toBe(false);
});
it('숫자가 아니면 false를 반환한다', () => {
expect(isLargerThan5('hello')).toBe(false);
});위 테스트는 함수의 의도를 검증하고, 실패 조건이 명확하다.
커버리지는 방향을 제시하는 지표일 수는 있지만, 목표가 되어서는 안 된다.
100% 수치를 목표로 하기보다는 다음을 고민해야 한다.
- 이 테스트는 실제 버그를 방지할 수 있는가?
- 코드의 의도(명세)를 제대로 검증하고 있는가?
- 어느 범위까지 테스트하는 것이 비용 대비 효율적인가?
3. 테스트 코드도 유지보수의 대상이다. 가독성을 높이자.
테스트 코드 역시 장기간 유지보수해야 할 코드다. 따라서 가독성과 명확성이 중요하다.
- 테스트가 무엇을 검증하는지 명확하게 작성
- 테스트 이름은 동작을 설명하는 문장으로 작성
- 하나의 테스트에서는 가급적 하나의 동작만 검증
단일 책임 원칙(SRP, Single Responsibility Principle)
모든 클래스는 하나의 책임만 가져야 하며, 그 책임과 관련된 변경에만 영향을 받아야 한다.
이 원칙은 테스트 코드에도 적용된다.
하나의 테스트에 여러 동작을 함께 검증하면, 실패했을 때 무엇이 원인인지 파악하기 어렵다.
// bad - 하나의 테스트에서 여러 동작을 검증
it('폼 유효성 검사', () => {
expect(isValidEmail('')).toBe(false);
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('user@example.com')).toBe(true);
});
// good - 동작별로 테스트를 분리
describe('isValidEmail', () => {
it('빈 문자열이면 false를 반환한다', () => {
expect(isValidEmail('')).toBe(false);
});
it('이메일 형식이 아니면 false를 반환한다', () => {
expect(isValidEmail('invalid')).toBe(false);
});
it('올바른 이메일 형식이면 true를 반환한다', () => {
expect(isValidEmail('user@example.com')).toBe(true);
});
});하나의 테스트에 여러 검증이 들어가면, 실패했을 때 어떤 조건이 깨졌는지 테스트 이름만으로는 알기 어렵다. 테스트 역시 하나의 명확한 책임을 가져야 하며, 작고 명확할수록 읽기 쉽고 유지보수하기 좋다.