티스토리 뷰

좋은 코드에 대한 고민

Testable Code

devhanyoung 2024. 4. 6. 21:43

 

유닛 테스트를 작성하다 보면, 내 코드가 테스트 불가능한(혹은 어려운) 경우가 있다.

이럴 때면 당혹감과 함께 여러가지 의문이 밀려든다.

'왜 내 코드는 테스트하기 어려울까?'

'어떻게 수정해야 테스트가 쉬워질까?'

'근데 테스트를 위해 구현부를 수정하는 게 좋은 선택일까?'

이 의문들을 해소하고자 구글링을 해보았고 유명인사 향로님의 블로그에서 좋은 글들을 발견했다.

 

1. 테스트하기 좋은 코드 - 테스트하기 어려운 코드

2. 테스트하기 좋은 코드 - 제어할 수 없는 코드 개선

3. 테스트하기 좋은 코드 - 외부에 의존하는 코드 개선

 

위 글들을 나의 언어로 조금 더 간결하게 정리해보고자 한다.

(참고로 여기서 다루는 테스트는 '유닛 테스트'이다)

 

목차는 다음과 같다.

1. 테스트하기 어려운 코드
2. 테스트하기 좋게 만드는 법
3. 테스트 용이성을 위해 프로덕션 코드를 수정해도 될까?

1. 테스트하기 어려운 코드

테스트 작성이 어려울 때 흔히 그 이유가 '테스팅 툴에 대한 숙련도나 테스트 작성 경험이 부족해서'라고 생각한다.

그런데 사실, 테스팅 툴 숙련도나 작성 경험은 큰 이유가 아니다.

대부분 경우 그 이유는 구현부 코드가 테스트에 부적합하기 때문이다.

테스트하기 어렵게 짠 코드가 테스트 작성을 어렵게 만든 것이다.

만약 구현부가 테스트 작성이 용이하도록 설계되었다면, Mock의 도움 없이도 테스트 코드를 쉽게 작성할 수 있다.

 

그렇다면 우리는 어떤 경우에 테스트하기 어렵다고 느낄까?

멱등성이 보장되는 순수함수가 아닌 경우에 테스트하기 어렵다고 느낀다.

쉽게 말해, 내 코드가 몇 번을 수행해도 항상 같은 결과를 반환하지 않는다면 테스트하기 어렵다고 느낀다.

이에 해당하는 두 가지 경우를 구체적으로 살펴보자.

 

1-1. 제어할 수 없는 값에 의존하는 경우

  • 실행 시마다 반환값이 다른 경우 (ex. Math.random(), new Date())
  • 사용자 입력에 의존하는 경우 (ex. readLine)
  • 외부 SDK에 의존하는 경우 (ex. PG사 라이브러리)

위의 경우처럼 개발자가 직접 제어할 수 없는 값에 의존하면 테스트하기 어려워진다.

 

우아한테크코스 1주차 미션에서 있었던 예시 하나를 살펴보자.

랜덤 값이 4 이상이면 차가 앞으로 전진해야 하는 로직이다.

class Car {
  ...
  go() {
    if (랜덤값뽑기() > 3) {
      앞으로전진();
    }
  }
}

여기서 Car의 go 메서드는 올바른 테스트가 불가능하다.

이를 테스트하면 매번 테스트 실행 결과가 다를 것이기 때문이다.

랜덤 값이 매번 다르게 반환될 것이기에, 운이 좋다면 테스트가 통과할 것이고 운이 나쁘다면 테스트가 실패할 것이다.

 

이 케이스에서는 "랜덤값뽑기"함수로 인해 테스트가 불가능해졌다.

제어할 수 없는 값으로 인해 매번 동일한 결과를 보장하지 못하기 때문이다.

 

1-2. 외부에 영향을 주는 경우

  • 출력 (ex. console.log)
  • 외부 메시지 발송 (ex. 이메일 발송, 메세지 큐)
  • 데이터베이스에 의존하는 경우
  • (프론트엔드)외부 API/cookie/localStorage를 사용하는 경우

위와 같이 외부 환경에 의존해야 하는 경우, 테스트가 어렵다.

예시를 살펴보자.

class Order {
  ...
  async cancel() {
    const cancelOrder = new Order();
    cancelOrder.amount = this.amount;
    cancelOrder.description = this.description;
    
    await DB에저장(cancelOrder);
  }
}

"DB에저장"이라는 데이터베이스에 의존하는 함수로 인해 테스트가 어려워졌다.

데이터베이스에 의존하는 경우, 테스트가 어려운 이유는 다음과 같다.

  • 해당 스키마(여기선 Order)가 DB에 존재해야 한다.
  • 테스트 환경 DB setup이 필요하다.
  • 다음 테스트에 영향이 없도록 테스트 이후 DB 상의 수정을 초기화하는 추가 조치가 필요하다.
  • 테스트 속도가 느려진다.

이 중 특히 외부 환경을 구축해야 하는 점과 테스트가 느려진다는 점이 치명적이다.


2. 테스트하기 좋게 만드는 법

이번에는 테스트하기 어려운 코드를 테스트하기 좋은 형태로 바꾸어 보자.

테스트하기 좋은 코드는 앞서 말한 것처럼 '멱등성이 보장되는 순수함수'이다.

테스트하기 좋은 코드로 만들기 위해선, 테스트하기 어려운 코드(제어불가능한 값에 의존/외부에 의존)를 없애야 할 것이다.

 

그런데, 테스트하기 어려운 코드도 어찌됐건 필요하니 존재하는 것이다.

즉, 완전히 없애는 것은 불가능하다.

그렇다면 적절한 위치에 잘 옮기는 게 관건이다.

 

안쪽에 테스트하기 어려운 코드가 포함된다면 그 바깥쪽도 테스트하기 어려워진다.

그럼 테스트하기 어려운 코드의 최적의 위치가 어디일까?

당연히 가장 바깥쪽일 것이다(진입점, 일반적으로 controller 단).

우리는 테스트 용이성을 올리기 위해서 테스트가 어려운 코드를 최대한 바깥쪽까지 몰아내야 한다.

그렇게 함으로써 그 안쪽의 로직들의 멱등성이 보장되도록 만들어야 한다.

 

구체적인 사례를 살펴보자.

앞서 살펴봤던 예시 코드를 다시 가져와 테스트하기 좋은 코드로 바꿔보자.

 

2-1. 제어할 수 없는 값에 의존하는 경우

// 테스트하기 어려운 코드
class Car {
  /* ... */
  go() {
    if (랜덤값뽑기() > 3) {
      앞으로전진();
    }
  }
}
// 테스트하기 좋은 코드
class Car {
  /* ... */
  go(randomNumber) {
    if (randomNumber > 3) {
      앞으로전진();
    }
  }
}

랜덤 값을 뽑는 로직을 go 메서드 내부에 넣지 않고, 랜덤 값을 인자로 받는 형태로 수정했다. 이렇게 수정하면 랜덤 값을 뽑는 로직은 도메인 클래스(Car) 바깥에 위치하게 된다.

// 이와 같은 형태로 사용할 수 있다.
const car = new Car();
car.go(랜덤값뽑기());

// 이와 같은 형태로 테스트할 수 있다.
car.go(4이상의수)
expect(/* car의 주행거리 */).toBe(/* 1보 전진됨 */)

이로써 테스트 시에도 랜덤 숫자 대신 직접 지정한 숫자를 넘겨 쉽게 테스트할 수 있게 되었다.

 

한편, 이런 구조에서는 랜덤값뽑기 함수를 외부에서 직접 실행해주어야 하는 불편함이 있다.

이 불편함을 해소하고 싶다면, 인자의 default 값을 지정해주면 된다.

class Car {
  /* ... */
  go(randomNumber = 랜덤값뽑기()) {
    if (randomNumber > 3) {
      앞으로전진();
    }
  }
}

// 이와 같은 형태로 사용할 수 있다.
const car = new Car();
car.go();

2-2. 외부에 영향을 주는 경우

// 테스트하기 어려운 코드
class Order {
  /* ... */
  async cancel() {
    const cancelOrder = new Order();
    cancelOrder.amount = this.amount;
    cancelOrder.description = this.description;
    
    await DB에저장(cancelOrder);
  }
}
// 테스트하기 좋은 코드
class Order {
  /* ... */
  cancel() {
    const cancelOrder = new Order();
    cancelOrder.amount = this.amount;
    cancelOrder.description = this.description;
    
    return cancelOrder;
  }
}

DB에 의존하는 로직을 제거하고 취소 Order 객체를 반환만 하는 형태로 수정했다. 이렇게 수정하면 외부 의존(DB) 로직은 도메인 계층이 아닌 서비스 계층에 존재하게 된다.

// 수정 이전의 Service 로직
class OrderService {
  /* ... */
  async cancel(orderId:number) {
    const order = await orderRepository.findById(orderId);
    await order.cancel(); // ⚠️ Order와 OrderService 모두 데이터베이스에 의존
  }
}

// 수정 이후의 Service 로직
class OrderService {
  /* ... */
  async cancel(orderId:number) {
    const order = await orderRepository.findById(orderId);
    const cancelOrder = order.cancel(); // 👍🏻 Order는 데이터베이스에 의존하지 않음
    await DB에저장(cancelOrder);
  }
}

변경된 코드에서는 Service만 데이터베이스에 의존한다.

즉, 테스트가 어려운 범위가 최소화되었다.

 

async/await이 포함된 로직은 외부에 의존하고 있는 로직이라고 보면 된다.

따라서, 도메인 로직에 async/await이 포함되어 있다면 그것을 어떻게 최대한 바깥쪽으로 몰아낼 수 있을지 고민해야 한다.

도메인 로직과 외부 의존성의 거리가 멀수록 테스트하기에 용이하기 때문이다.


3. 테스트를 위해 프로덕션 코드를 수정해도 되는가?

테스트를 위해 프로덕션 코드를 고치다 보면 한 가지 의문이 든다.

'테스트를 위해서 구현부를 고치는 게 맞는 걸까?'

구현의 보조적 수단처럼 보이는 테스트를 위해서 구현부를 수정하는 게 목적 전치로 느껴지기 때문이다.

 

그런데 결론부터 말하자면, 테스트를 위해서 프로덕션 코드를 수정해도 된다.

테스트 코드는 구현의 보조 수단이 아니다.

테스트 코드도 구현의 일부로 보아야 한다.

만일 테스트가 어렵다면, 현재 코드(구조 설계)에 smell이 존재한다는 뜻이다.

잘 디자인된 코드는 대부분 테스트하기 쉽고, 테스트하기 어려운 코드는 잘못 디자인됐을 가능성이 크다.

테스트는 우리의 구현부가 (불확실성이 배제되고 멱등성이 보장된) 순수함수로 이루어지도록 이끌어주는 역할을 한다.

 

물론, 단순히 테스트를 위해 private 메서드/프로퍼티를 public으로 바꾸는 것과 같은 트릭이 허용되어선 안 된다.

본문 내용을 이해했다면 어떤 방식의 수정이 건강한 수정인지 판단하는 게 어렵지 않을 것이다.

 

참고) 테스트가 구현의 일부라는 사실에 신빙성을 더하기 위해 재밌는 주장을 하나 소개하겠다. 얼마전 읽었던 '엘레강트 오브젝트'에서는  interface에 fake(동작이 구현된 테스트 더블) 클래스를 추가하는 것을 추천했다.

(여기서 나의 요지는, 이정도로 테스트가 중요하며 구현의 일부로 여겨질 필요가 있다는 것이다.)

// 엘레강트 오브젝트에서 소개된 예시 코드 (interface에 fake 객체 클래스를 추가)
// java
interface Exchange {
    float rate(String origin, String target);
    final class Fake implements Exchange {
        @Override
        float rate(String origin, String target) {
            return 1.2345;
        }
    }
}

(이와 관련된 자세한 내용이 궁금하다면, 엘레강트 오브젝트의 "2.8 모의객체 대신 페이크를 사용하세요"를 살펴보자)


짧은 소감

테스트가 단순히 동작 확인만을 수행하는 보조적인 도구가 아니라는 사실을 깨달을 수 있었다.

테스트는 code smell을 탐지해주는 고마운 동반자이다.

테스트에 대해 알아갈수록 테스트의 유용함이 더 크게 느껴진다.

 

한편으로는, 아직까지 테스트를 잘 활용하는 게 어렵고 막막하게 느껴지기도 한다.

테스트를 작성하다보면, 어디까지 테스트할지/어떻게 테스트할지 등 많은 고민이 생긴다.

테스트도 결국 생산성에 기여해야 의미가 있을 것인데, 솔직히 아직 생산성를 높일 정도로 잘 활용할 자신은 없다.

아마 테스트를 작성하는 방법이나 관리하는 방법을 숙달하지 못했기 때문인 것 같다.

 

이번 학습 내용을 바탕으로 테스트에 대해 더 공부하여 테스트를 제대로 활용해보고 싶다는 욕심이 생긴다.