팀 분들과 함께 NextStep - 이펙티브 코틀린 강좌를 수강하고 있다.
최근에 과제 회고를 처음 진행했는데, 이때 나온 주제가 테스트 하기 좋은 코드였다.
이 주제는 사실 이미 너무 많이 회자된 주제이긴하다.
대표적으로 아래 2개가 가장 대표적이다.
이들을 다 보았다면, 굳이 이 글을 볼 필요는 없다.
이미 잘 정리된 글과 영상이지만, 현재 팀의 분들을 위해서 조금 정리하게 되었다.
뒤에 이어 쓸 Active Record vs Data Mapper의 빌드업이기도 하다.
이 글에서는 테스트하기 어려운 코드 대해 이야기해보려고 한다.
1. 테스트 하기 좋은 코드?
여기서 이야기하는 테스트는 단위 테스트를 의미한다.
E2E 테스트는 여기서 이야기하는 테스트와 구현, 설계와는 다르다.
테스트 코드 작성의 어려움을 팀원들을 보면 본인이 테스트 코드를 작성해본 경험이 부족해서 혹은 테스트 프레임워크 (JUnit, Jest, Mocha 등) 숙련도가 떨어지기 때문이라고 생각하는 경우를 종종 본다.
하지만 테스트 코드 작성 경험, 테스트 프레임워크 숙련도로 인한 어려움은 생각보다 큰 이유가 아니다.
경험상 대부분은 테스트를 작성하기 어려운 구현체 때문이다.
테스트하기 어렵도룩 구현되었기 때문에 테스트 코드 작성이 어려운 것이지, 도구의 숙련도의 문제인 경우가 별로 없다.
좀 더 극단적으로는 테스트 코드 작성이 쉽게 구현된 코드라면 별도의 Mock 라이브러리 등의 도움 없이도 테스트 코드 작성이 쉽다.
그렇다면 2가지가 궁금할 수 있다.
- 테스트 코드가 구현 코드의 설계를 바꿀 정도로 중요한것인가?
- 테스트 하기 좋은 코드란 어떤 코드인가?
두번째 질문은 앞으로 자세한 설명을 할 예정이기 때문에,
여기서는 첫번째 질문에 대해서 먼저 이야기하고 싶다.
마침 이번주 NextStep 과제 회고 시간에 같은 질문이 나왔다.
"테스트를 위해서 원본 코드의 구현과 설계를 고치는게 맞는건가요?"
이 질문의 의도가 무엇인가 곰곰히 생각을 해보면 다음과 같다.
"테스트는 구현의 보조적인 수단인데, 이를 위해 구현부 설계가 교체되는게 옳은것인가" 하는 것이었다.
이 질문에 대해서는 명확하게 답할 수 있다.
"테스트를 위해 구현 설계가 변경될 수 있다.
테스트 코드는 구현의 보조적인 수단이 아니며, 같은 레벨로 봐야한다.
좋은 디자인으로 구현된 코드는 대부분 테스트 하기가 쉽다.
테스트 하기 어렵게 구현 되었다면, 코드 확장성 / 의존성 등 코드 디자인, 설계가 잘못되었을 확률이 굉장히 높다."
테스트 코드는 구현의 보조 수단이 절대 아니다.
오히려 구현 설계 Smell을 맡게 해주는 좋은 수단이다.
실제로 이 설계 문제를 만나서 작성한 글도 있다.
지금 테스트 코드 작성이 어렵다면, 구현부의 코드를 다시 한번 점검해보자.
테스트 작성이 어렵도록 구현된게 아닌지 말이다.
2. 테스트하기 좋은 코드 vs 테스트하기 어려운 코드
그렇다면 테스트하기 좋은 코드란 무엇일까?
경험상 몇번을 수행해도 항상 같은 결과가 반환되는 함수 (멱등성이 보장되는 순수함수
) 가 테스트하기 좋은 코드였다.
몇번을 수행해도 항상 같은 결과가 나오기 위해서는 아래 2가지 요소를 최대한 피해해야만 한다.
2-1. 제어할 수 없는 값에 의존하는 경우
아래와 같이 개발자가 제어할 수 없는 값에 의존하는 함수인 경우는 테스트하기가 어렵다.
Random()
,new Date()
(LocalDate.now()
) 와 같이 실행할때마다 결과가 다른 함수에 의존하는 경우readLine
혹은inputBox
등 사용자들의 입력에 의존하는 경우- 전역 함수, 전역 변수 등에 의존하는 경우
- PG사 라이브러리등 외부 SDK에 의존하는 경우
이를테면 다음과 같은 도메인 로직의 경우 테스트 작성이 어렵다.
export default class Order {
...
discount() {
const now = LocalDateTime.now();
if (now.dayOfWeek() == DayOfWeek.SUNDAY) {
this._amount = this._amount * 0.9
}
}
}
- 주문일이 일요일이면 주문 금액의 10%를 할인 하는 함수이다.
여기서 테스트를 어렵게 만드는 부분은 어디일까?
바로 const now = LocalDateTime.now();
이다.
이 코드가 테스트를 어렵게 만드는 이유는 제어할 수 없는 값이 비지니스 로직에 사용되기 때문이다.
이 메소드의 테스트 코드를 작성한다고 해보자.
it('일요일에는 주문 금액이 10% 할인된다', () => {
const sut = Order.of(10_000, OrderStatus.APPROVAL);
sut.discount();
expect(sut.amount).toBe(9_000);
});
이 테스트는 매주 일요일에 수행할때만 통과할 수 있다.
언제 수행하냐에 따라 테스트 대상인 discount
의 결과는 달라진다.
테스트 대상인 discount
가 언제나 동일한 결과를 보장하지 못하기 때문에 테스트 코드 작성을 굉장히 어렵게 만든다.
이 코드를 테스트 하기 위해서는 LocalDateTime.now()
를 Mocking 해야만 수행가능한데, 이 역시 쉽지 않다.
2-2. 외부에 영향을 주는 코드
순수 함수를 만드는데 방해하는 두번째는 외부에 영향을 주는 코드이다.
console.log
,System.out.println()
과 같은 표준 출력- Logger 등을 사용하는 경우
- 이메일 발송, 메세지큐 등 외부로의 메세지 발송
- 데이터베이스 등에 의존하는 경우
- 외부 API에 의존하는 경우
이런식의 코드는 테스트를 하기 위해서는 항상 외부의 환경에 의존하게 된다.
다음의 코드는 테스트하기가 어렵다.
export default class Order {
...
async cancel(cancelTime): void {
if(this._orderDateTime >= cancelTime) {
throw new Error('주문 시간이 주문 취소 시간보다 늦을 수 없습니다.');
}
const cancelOrder = new Order();
cancelOrder._amount = this._amount * -1;
cancelOrder._status = OrderStatus.CANCEL;
cancelOrder._orderDateTime = cancelTime;
cancelOrder._description = this._description;
cancelOrder._parentId = this._id;
await getConnection()
.getRepository(Order)
.save(cancelOrder);
}
}
- 주문이 취소되면 원본 주문을 통해 취소 주문을 만들어 데이터베이스에 적재하는 함수이다.
여기서 테스트를 어렵게 만드는 부분은 await getConnection().getRepository(Order).save(cancelOrder);
이다.
의도한대로 취소 주문이 생성되었는지 확인 하기 위해서는 항상 데이터베이스가 필요하다.
데이터베이스가 필요하면 그때부터 테스트는 어려워진다.
- 매 테스트 마다 테이블 스키마가 존재해야하며
- 테스트를 수행하기 위해 기본적인 데이터 적재 / 테스트 환경 setup이 필요하며
- 테스트가 끝날때마다 사용된 테이블을 초기화하여 다음 테스트에 영향을 끼치지 않도록 해야한다
요즘의 테스트 환경은 고도화되어 위 사항들을 수행하는데 큰 어려움은 없지만, 그래도 Docker
, In Memory DB
등 외부의 환경을 구축해야만 테스트를 한다는 것은 테스트의 난이도를 높이게 된다.
특히, 데이터베이스를 사용하는 테스트들은 느린 테스트의 주범이다.
수백~ 수천개의 테스트 코드들을 수행하기 위해서 테스트 속도는 중요한데, 데이터베이스와 같이 외부환경을 사용하는 테스트가 많을수록 테스트 시간은 기하급수적으로 늘어난다.
이 함수는 여러 비지니스 로직을 포함하고 있다.
- 주문 취소 시간은 주문 시간 보다는 뒤어야만 한다.
- 주문취소금액은
원 주문 금액 * -1
이어야 한다 - 주문 상태는
OrderStatus.CANCEL
이어야 한다 - 주문 취소 시간은 입력 받은 값을 사용한다
- 나머지 상태는 원 주문을 따라간다.
- 생성이 끝난 주문 취소 객체는 데이터베이스에 적재한다.
- 이 부분은 비지니스 로직은 아니다.
- 다만, 현재 함수의 로직이라 이 자리에서 소개된다.
이 중 데이터베이스 적재를 제외한 나머지 로직들은 검증하기가 굉장히 쉽다.
하지만 데이터베이스 적재 로직으로 인해 검증을 하기가 어려워졌다.
위 코드를 보면 외부와의 연동이 필요한 경우 테스트 코드 작성이 어렵다는 것을 알 수 있다.
이를 통해 알 수 있는 것은 C#, TS와 같이 외부와의 연동이 필요한 경우 항상 async
가 필요한 경우이며 이는 테스트하기가 어렵다.
즉, async
함수를 얼마나 핵심 로직에서 벗어나게 하느냐가 프로젝트 전체의 테스트 용이성을 결정한다.
3. 마무리
이번 편에서는 테스트 하기 어려운 2가지 요소를 소개했다.
- 제어할 수 없는 코드
- 외부에 영향을 주는 코드
이들을 피할 수 있다면, 피하는 것이 가장 좋지만 어떻게 해야 피할 수 있을까?
이 코드들을 어떻게 개선해야만 하는지 다음 편에서 이야기해보려고 한다.