많은 IT 회사의 채용 공고를 보면 테스트 코드 작성 유닛 테스트 코드, UI 테스트 코드 등 테스트 코드를 작성할 수 있는 개발자를 많이 원하고 있는 것 같습니다. 저는 모회사 과제 전형을 하면서 처음 접했는데요 빠르게 과제에 테스트를 적용하느라 왜 테스트 코드를 작성해야 하는가 어떤 테스트 코드가 좋은 테스트 코드인가에 대해서는 생각하지 않고 테스트 코드를 짰던 것 같습니다.
이후 리팩토링한 프로젝트에도 Jest와 React Testing Library를 이용해서 테스트 코드를 작성하고 있는데 테스트 코드를 작성하는 이유와 좋은 테스트 코드는 무엇인지 제대로 공부를 하고 작성해야 겠다는 생각에 이런 글을 작성하게 됐습니다.
회사의 테크 블로그와 구글링을 통해 공부한 내용을 정리해 보겠습니다.
테스트 코드는 무엇인가?
먼저 테스트 코드는 소프트웨어의 기능과 동작을 테스트 하는 데 사용되는 코드입니다. 테스트 코드를 통해 어플리케이션의 결함을 빠르게 찾아내고 수정할 수 있습니다. 테스트 코드는 대표적으로 단위(유닛) 테스트, 통합 테스트, E2E(End to End) 테스트가 있고, 각각의 범위에 맞춰서 테스트 코드를 작성하고 개발 과정 중에 예상치 못한 문제를 미리 발견할 수 있고, 위에서 말씀드렸다시피 코드 수정이 필요한 상황에서 유연하고 안정적인 대응을 할 수 있습니다. 테스트 코드를 작성하는 이유에 대해 자세히 알아보며, 더 깊이 알아보겠습니다.
테스트 코드를 작성하는 이유는 무엇인가?
1. 디버깅 비용 절감
실제로 자체 서비스 회사에서 서비스를 운영하고 유지 보수를 하는 과정 속에 개발자의 총 개발 소요 시간 중 온전히 요구사항에 대한 기능 개발을 하는 시간이 생각보다 많지 않다고 합니다.
즉 기능 개발 시간보다 어플리케이션에서 발생하는 버그에 대한 디버깅을 하는 시간이 더 많은 비중을 차지한다는 것인데요. 이 과정에서 테스트 코드가 없다면 문제 자체를 해결하는 것보다 어떤 코드 블록에서 문제가 발생했는지 찾는 과정에 더 많은 시간을 투자하게 됩니다. 이러한 테스트 코드를 매우 견고하게 작성하더라도 완벽하게 결함을 없앨 수 없는 것은 사실이지만, 테스트 코드를 작성함으로써 오류를 줄일 수 있고, 빠르게 대처할 수 있습니다.
테스트는 위에서 언급했듯이 테스트하는 범위와 비중에 따라 세 분류로 나누어지고, 각각의 역할을 가지고 있습니다.
- 단위(Unit) 테스트 : 도메인 모델과 비즈니스 로직을 테스트합니다. 하나의 기능을 하는 코드를 테스트하며, 해당 코드가 의도한 대로 작동하는지 확인합니다. 일반적으로 Class나 Method를 테스트하는 범위로 정해지며, 가장 핵심적인 테스트라고 할 수 있습니다. 예를 들어 장바구니에 담는 기능을 테스트한다고 하면 특정 작업을 했을 때 의도한대로 상품이 담기는 지 테스트 합니다.
- 통합(Intergration) 테스트 : 코드의 주요 흐름들을 통합적으로 테스트하며, 주요 외부 의존성(ex. DB)에 대해서 테스트합니다. 단위 테스트에서 검증된 개별 모듈들을 결합하여 그들이 예상대로 상호작용하고 있는지 확인합니다. 구체적으로 말씀드리자면 DB에 새로운 데이터가 추가됐을 경우 DB가 이를 올바르게 저장하고 프론트 단에서 데이터를 올바르게 표시하는지 확인합니다. Unit을 넘어 각기 다른 시스템이 잘 상호작용하는지 확인하는 작업이고, 더 많은 코드를 테스트 하기 때문에 에러 검출이 명확하지 않을 수 있습니다. 그렇기에 Unit 테스트에 초점을 두는 것이 좋다고 하네요.
- E2E 테스트 : 최종 사용자의 흐름에 대한 테스트이며, 외부로부터의 요청부터 응답까지 기능이 잘 동작하는지에 대해 테스트합니다. 즉 실제 사용자의 시나리오를 테스트하여 어플리케이션 자체가 의도한대로 동작하는지 확인합니다. 예를 들어 쇼핑몰 웹 어플리케이션에 대한 E2E 테스트를 작성한다고 했을 때, 고객이 상품을 선택하고, 결제한 후 배송받는 과정을 처음부터 끝까지 확인하는 것입니다.
이러한 테스트를 통해 결함을 최소화시켜 디버깅에 소모되는 시간을 줄여줌으로써 서비스 개발자가 비즈니스 개발에 집중할 수 있도록 하여 생산성을 향상시켜줍니다.
2. 코드 변경에 대한 불안감 해소
버그를 고치는 과정에서 다른 버스가 발생하는 경우가 있습니다. 이를 회귀 버그라고 하는데요. 이는 굉장히 자주 발생하는 문제입니다. 어플리케이션에서 기능은 단일 하나의 요소로 이루어지지 않고, 여러 요소들이 서로 상호작용하고 협력하여 만들어지기 때문에 하나의 기능을 수정하는 과정에서 협력하고 있는 다른 기능에도 영향을 주면서 회귀 버그가 발생하게 됩니다.
이러한 회귀 버그를 완벽하게 차단하고 예방하는 것은 불가능하지만, 우리는 이를 관리하고 대처해야합니다. 즉, 회귀 테스트를 해야하는 것이죠. 회귀 테스트는 기능 추가나 오류 수정으로 인해 새롭게 유입되는 오류가 없는지 검증합니다. 여기서 우리가 알 수 있는 것은 테스트 코드는 그때 당시의 기능을 만들기 위해서만 필요한 코드가 아니라 코드를 작성한 이후에 변경된 요구사항을 적용하거나 코드를 리팩토링하는 과정에서도 필요합니다. 서비스가 지속 가능하게 발전하기 위해 필요한 코드인 것이죠. 우리가 코드를 작성할 때 이 부분을 고려하고 짜야한다고 생각합니다.
3. 더 나은 문서자료
우리 개발자들은 좋은 코드를 작성하기 위해 가독성 좋은 코드를 작성하기 위해 노력합니다. 하지만 서비스 복잡도가 늘어나며 코드의 양이 많이지게 되고 자연스럽게 코드의 복잡도도 높아지게 됩니다. 이는 신규 입사자들이 코드를 이해하는데 많은 불편함을 겪게 합니다.(저도 빨리 신규 입사자가 되고 싶네요..ㅠㅠ TMI 죄송합니다..) 이를 대비하여 코드에 대한 문서화를 해두지만 코드처럼 문서를 유지 보수하지 않는 경우가 대다수이기 때문에 문서의 신뢰도는 시간이 지날수록 떨어지게 되어 바라던 효과를 내지 못합니다.
테스트 코드가 문서의 역할을 해준다면 위의 상황에서 발생하는 문제들을 모두 해결할 수 있습니다. 즉 테스트를 작성할 때 코드의 실제 동작을 기술함으로써 코드가 어떤 코드인지 이해할 수 있는 것이죠. 문서화 테스트를 통해 코드를 처음 접하는 사람들에게 기능과 코드를 이해할 수 있도록 도움을 줄 수 있습니다.
4. 좋은 코드는 테스트하기 쉽다.
변경하기 쉬운 코드가 좋은 코드 중 하나라고 할 수 있습니다. 즉, 약한 결합도를 가지고 있는 코드를 좋은 코드라고 할 수 있으며, 강결합이 되어있는 코드는 유지비용이 증감되기에 좋지 못한 코드라고 할 수 있습니다.
이러한 강결합으로 이루어진 코드는 테스트하기가 매우 어렵습니다. 외부의 영향을 받거나 내부적으로 의존성을 가지고 있기에 변경에 유연하게 대응하지 못하고 재사용하기도 어려운 코드입니다. 결론적으로 테스트 코드를 작성하면서 기존 강결합으로 이루어진 코드를 약한 결합도로 가진 코드로 변경할 수 있게 되기에 테스트 코드를 작성하며 자연스럽게 좋은 코드를 작성할 가능성이 높아지는 것입니다.
테스트 코드를 작성하기 쉽다고 모두 좋은 코드는 아니지만 테스트하기 어려운 코드라면 좋지 못한 코드일 가능성이 매우 높습니다.
5. 테스트 코드를 작성함으로써 안정감 있는 프로젝트를 진행할 수 있다.
코드를 실제 운영 환경에 배포했을 때 에러가 발생하지는 않을지 과연 자신이 작성한 코드가 정상적으로 운영이 되는지 생각하게 되면서 불안감에 빠지게 됩니다. 하지만 테스트 코드가 있다면 배포 전에 오류를 확인할 수 있기에 안정감 있는 프로젝트를 진행할 수 있고, 안정감 있는 우리는 볼 수 있습니다.
그리고 이는 자연스럽게 서비스 품질의 향상을 이끌어냅니다.
테스트 코드를 잘 작성하는 방법
이제 이러한 테스트 코드를 어떻게하면 잘 작성할 수 있는지 인프랩 블로그의 내용들을 정리하며 알아봅시다.
1. 테스트 코드는 DRY 보다는 DAMP 하게 작성하라.
개발 원칙 중 DRY(Don't Repeat Yourself) 원칙에 대해 아시나요? 개발자인 우리는 중복 코드를 싫어하기에 중복 코드를 최대한 없애려고 노력합니다. 하지만 중복을 줄이기 전에 DAMP(Descriptive and Meaningful Phrases) 하게 테스트 코드를 작성하는 것을 고려해야 합니다. DAMP 원칙은 의미있고 설명적인 구문을 사용하라는 원칙입니다.
즉, 테스트 코드의 중복을 줄이기 위해 테스트 간의 결합도를 높이는 것은 좋지 않은 방식이라는 것입니다. 여기서 중요한 점은 테스트는 서로 독립적이고 격리되어야 한다는 것입니다. 테스트가 서로 독립적이지 않다면, 테스트 코드를 수정하는 과정에서 다른 테스트의 구조까지 확인해야하는 불편함이 생길 것이고, 테스트에 대한 신뢰도도 떨어지게 됩니다.
DAMP 원칙을 지키면서 중복을 줄이는 방법은 테스트 픽스쳐 함수나 클래스 등을 사용하는 방식이 있습니다.
2. 테스트는 구현이 아닌 결과를 검증하도록 한다.
테스트 코드를 작성하는 과정에서 빠른 테스트를 작성하기 위해 모의 객체를 사용하는 경우가 있습니다. 모의 객체로 mock, spy, stub 등을 사용하는 경우 테스트 대상의 구현을 알아야만 테스트를 작성할 수 있는데요. 그런데 이 부분을 잘못 사용하여 테스트를 작성하는 경우가 종종 있습니다.
인프래랩 블로그의 예시를 톻해 이 경우를 알아봅시다.
export class JobApplicant {
updatePass() {
this.validateIsNotCancel();
this.validateIsNotFail();
// 유효성 검증 통과후 상태 변경
}
validateIsNotFail(): void {
// 검증
}
validateIsNotCancel(): void {
// 검증
}
}
위와 같이 지원자를 합격 상태로 변경하기 전에 지원자가 현재 불합격이거나 취소 상태가 아닌지에 대한 조건이 있다고 가정해봅시다. 해당 코드를 jest의 spyOn 메소드를 통해 작성해보면 아래와 같습니다.
it('지원자를 합격시킨다', () => {
jobApplicant = new JobApplicant
jest.spyOn(jobApplicant, 'validateIsNotCancel').mockReturnValue(undefined);
jest.spyOn(jobApplicant, 'validateIsNotFail').mockReturnValue(undefined);
jobApplicant.updatePass();
expect(joApplicant.validateIsNotCancel).toBeCalledTimes(1);
expect(joApplicant.validateIsNotFail).toBeCalledTimes(1);
expect(joApplicant.status).toBe(JobApplicantStatus.PASS);
});
위 테스트 코드처럼 상태 변경에 대한 검증 뿐만 아니라 Cancel과 Fail에 대한 메소드가 호출되었는지 검증을 해서 더 완벽한 테스트라고 생각할 수 있지만, 이 테스트는 깨지기 쉬우며 좋은 테스트가 아닙니다.
만약 Cancel에 대한 메소드의 네이밍이 변경되거나 Fail에 대한 메소드가 삭제되면 이 테스트는 깨집니다. 즉 구현에 의존적이며 테스트의 목적이 구현에 맞춰져 있는 테스트인 것이죠. 내부 구현이나 비공개 메소드들은 언제든지 바뀔 여지가 있는 코드이기 때문에 정보의 은닉을 위해 숨깁니다. 이를 굳이 꺼내서 테스트 하는 것은 좋지 않습니다.
테스트 코드는 내부 구현보다는 실행 결과에 집중하는 것이 리팩토링 내성을 높일 수 있습니다. 그리고 호출되었는지 검증하는 부분 또한 의미 없는 검증입니다. 이 테스트의 검증 목적은 합격 상태 변경에 대한 검증으로도 충분합니다. 호출에 대한 검증을 진행하며 오히려 테스트의 목적을 흐리게 하고 가독성을 떨어뜨립니다.
우리는 어떻게 라는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야합니다.
옳은 테스트 코드 ▼
it ('지원서를 합격시킨다', () => {
const jobApplicant = new JobApplicant();
jobApplicant.updatePass();
expect(jobApplicant.status).toEqual(JobApplicantStatus.PASS);
});
it.each([
['취소', JobApplicantStatus.CANCLE],
['불합격', JobApplicantStatus.FAIL],
])(
'%s 상태의 지원서는 합격시킬 수 없습니다.'
(_, status) => {
const jobApplicant = new JobApplicant();
jobApplicant.status = staus;
expect(() => jobApplicant.updatePass()).toThrowError();
},
);
3. 읽기 좋은 테스트를 작성하라.
테스트 코드는 메인 , 제품 코드를 위한 코드지만 가독성도 좋아야합니다. 불명확한 테스트 코드는 읽는 행위도 유지보수 하는 행위도 어렵게 만듭니다.
좋은 테스트 코드는 읽는 사람 입장에서 이 테스트를 이해하는 데 필요한 모든 정보를 테스트 케이스 본문에 담고 있어야합니다. 동시에 관련 없는 정보는 담지 말아야 합니다.
이렇게 가독성이 높은 코드를 작성하기 위한 방법 중 하나는 테스트의 구조를 잘 잡는 것입니다.
테스트 구조는 준비, 실행, 검증 3개의 구절로 나위어질 수 있습니다. 각각의 구절을 AAA 패턴 (Arrange-Act-Assert 주석으로 구분)이나 GWT 패턴(given-when-then 행위 주석으로 구분)으로 구간을 나누어 작성하는 것이 좋습니다.
그리고 테스트 안에 너무 많은 양의 코드가 있는 경우 재사용의 목적으로 모듈화(테스트 팩토리, 빌더, 헬처 메소드)해두면 쉽게 읽힐 수 있습니다. 모듈화에 대한 내용은 이후 학습하고 정리하여 올리겠습니다.
4. 테스트 명세에 비즈니스 행위를 담자.
테스트명을 작성할 때 명확한 의도가 들어나도록 작성해야 합니다. 테스트 코드는 코드를 처음보는 개발자 입장에서 코드를 설명하는 문서가 될 수 있기에 명확한 의도가 담긴 명세를 작성함으로써 코드에 대해 이해하기 쉬도록 유도합니다.
그리고 명확하게 의도가 들어나는 이름을 적기 위해선 개발자 용어가 아닌 비즈니스 행위가 담긴 이름을 작성하여 비개발자도 읽을 수 있도록 설명되어야 합니다.
[요약]
- 테스트 코드의 중복을 줄이는 것이 아니라 서술적이고 의미있게 작성해야한다.
- 테스트는 서로 독립적이고 격리되게 구현하여 테스트 수정 과정에 다른 테스트가 영향을 받지 않도록 해야한다.
- DAMP 원칙을 지키면서 중복을 줄이는 방법 중 하나는 테스트 픽스펴 함수나 클래스 등을 사용하는 것이다.
- 어떻게 하는 내부 구현 검증보다는 무엇을 이라는 결과 검증에 집중하여 테스트 코드를 작성해야 한다.
- 테스트 코드도 관리의 대상이기에 읽기 좋은 코드로 작성해야 한다. 이를 위해 테스트 구조를 잘 잡아서 작성한다.
- 테스트 안에 너무 많은 양의 코드가 있다면 코드 재사용의 목적을 모듈화함으로써 쉽게 읽힐 수 있도록 하자.
- 테스트 명세에 비개발자도 이해하기 쉽도록 비즈니스 행위를 담자.
이상입니다. 저 또한 테스트 코드를 작성함으로써 지속 가능하 서비스 및 프로젝트를 만들 수 있다고 생각합니다. 만약 시간이 촉박한 경우 테스트 코드 작성으로 인해 오버 엔지니어링으로 이어질 수도 있는데 이 경우 핵심 로직 위주로 테스트 코드를 작성하여 일정을 잘 맞출 수 있도록 해야합니다. 이 글을 읽는 여러분도 테스트 코드 작성을 고려하고 적용해 보면 좋을 것 같습니다 :)
정리하기 위해 참고한 사이트 및 블로그 :