고민해보기

어떤 테스트코드를 작성하면 좋을까?

mopil 2023. 10. 29. 03:33
반응형

테스트코드는 "미래를 위한 투자"라고 생각한다.

당장 기능 구현에 꼭 필요한 부분은 아니지만, 추후 유지보수를 생각하면 테스트코드 작성은 필수적이라고 할 수 있다.

다만 대부분의 경우... 기능개발에 급급하여 테스트코드 작성에 신경을 못 쓰는 경우가 많다.

 

특히 신규 프로덕트를 개발할 때 테스트코드 작성을 하지 않을 가능성이 높다. 

 

따라서 시간은 좀 더 들더라도 미래의 나를 위해, 혹은 내 코드를 유지보수할 누군가를 위해 테스트코드를 작성하는 습관을 들이는 것이 좋다.

 

투자는 즉시 그 효용성을 직접적으로 확인하기 어렵다. 하지만 분명 미래에 가치가 생기는 시점이 존재한다.

 

# 왜 테스트 코드는 필요한가?

다음과 같은 이유로 테스트코드는 필요하다.

  • 이미 구현된 기능을 리팩토링 할 때 영향도 파악이 용이해진다.
  • 이미 구현된 기능에 신규기능을 추가할 때, 영향도 파악이 용이해진다.
  • 추후 유지보수성이 증진된다.
  • 테스트코드는 그 자체만으로 기능 명세서 역할을 할 수 있다.

 

그렇다면 우리는 어떤 테스트코드를 작성해야할까?

 

우선, 테스트는 신뢰성있고 속도있게 수행되어야 한다는 대전제가 있다.

 

# 어느 부분이 테스트가 필요할까?

본인이 짠 모든 코드는 테스트 코드가 필요하다. 물론 모든 부분을 커버하는 테스트코드를 현실적으로 작성하긴 어렵다. 따라서 우리는 필수적인 부분이라도 테스트를 하는 trade-off라도 필요하다.

 

전통적인 3-레이어-아키텍처 어플리케이션 (스프링 MVC)에서는 비즈니스 로직이 집약된 서비스 레이어가 주된 테스트 타겟이다.

 

구체적으로 필자가 생각하는 필수적인 테스트 타겟은 다음과 같다

  • 더티체킹을 위한 엔티티의 상태 변경 메소드
  • 서비스의 각 메소드
  • 비즈니스 로직 중 분기되는 조건
  • 복잡한 SQL 로직 (ex. QueryDSL)

 

다음은 테스트가 굳이 필요없는 부분이다.

  • 라이브러리나 프레임워크 부분 (ex. Spring Data JPA 리포지토리의 기본 CRUD 메서드)
  • 서비스 레이어의 private 메서드 (이는 큰 비즈니스 로직을 테스트하면서 포괄적으로 테스트 되도록 한다)
  • 외부 API (ex. MSA의 다른 서비스 API Call)
  • 간단한 CRUD 로직

 

# 통합테스트

스프링 부트 기준으로, @SpringBootTest 어노테이션을 통해 테스트 환경에서 실 환경과 비슷하게 모든 외부 요소들 (데이터베이스)를 연동하고 모든 스프링 빈을 띄운 후 테스트를 수행한다.

 

장점

  • 실 환경과 가장 비슷한 레벨에서 테스트가 가능하여 신뢰도가 높다.

단점

  • 관련된 모든 빈들을 띄우기 때문에, 속도가 느리다. 

 

속도 이슈는 CI/CD 관점에서 배포마다 테스트를 수행할 때, 빌드시간이 늘어나는 단점을 가지게 된다.

 

따라서 통합테스트는 최대한 지양하는 것이 좋다.

 

# 단위테스트

가장 작은 단위별로 쪼개서 외부 의존성 없이 테스트가 수행되도록 구현하는 방법이다.

Mock이나 Fake 객체를 활용하여 구현한다.

 

장점

  • 외부 의존성이 없기 때문에 독립적인 환경에서 수행가능하다.
  • 속도가 빠르다.

단점

  • 통합테스트 보다 테스트 신뢰성이 낮아진다. 

 

Mock vs. Fake

단위 테스트를 작성할 때 보통 Mock을 많이 사용한다. 예를 들어 서비스 레이어 로직을 테스트한다고 가정했을때,

리포지토리 의존성을 Mock으로 채워넣고 메서드 실행을 stubing을 통해 미리 개발자가 지정한다.

 

미리 개발자가 지정하기 때문에 논리 오류를 범할 가능성이 있고, 이는 테스트 신뢰성을 하락시킨다.

추가로 특정 상태를 추적하고 싶을 때 Mock은 거의 불가능하다. (ex. 엔티티의 변경 상태를 추적)

 

따라서 Mock 보다는 Fake 객체를 통해서 단위 테스트를 구현하는 것이 좋다.

 

Fake 객체는 인터페이스로 선언된 리포지토리를 InMemory 구현체를 만들어 테스트 환경에서 주입하는 방식이다.

 

이렇게 하면 stubing을 할 때 발생할 수 있는 논리 오류를 줄이면서, 상태 추적도 가능해진다.

 

만약 Fake를 적용한다면, 이를 위해 빈들을 구현체말고 인터페이스에 의존하도록 구현하는 것이 좋다.

 

// bad
class CacheRepository {
    fun set() {
        // logics...
    }
}

// good
interface CacheRepository {
    fun set()
}

class CacheRepositoryImpl : CacheRepository {
    override fun set() {
        // logics...
    }
}

// in test code
class InMemoryCacheRepository : CacheRepository {
    override fun set() {
        // logics...
    }
}

 

다음과 같이 작성하여 디폴트 구현체를 직접 명시하는 방법도 있다.

interface CacheRepository {
    fun set()
    
    @Component
    class Default : CacheRepository {
        override fun set() {
            // logics...
        }
    }
}

 

복잡한 로직이 존재하면 이를 함수형 인터페이스로 분리하고 하나의 역할만 갖도록 하는 것도 좋은 방법이다.

fun interface GetDateByComplexLogics {
    operator fun invoke()

    @Component
    class Default : GetDateByComplexLogics {
        override fun invoke() {
            // logics...
        }
    }
}
class MyService(
    private val getDateByComplexLogics: GetDateByComplexLogics
) {
    fun logic() {
        getDateByComplexLogics()
    }
}

 

 

그럼 어떨때 Mock을 써야하는가?

메서드가 단순히 호출되었는지 확인하기 위해서는 Mock 라이브러리에서 지원하는 메서드 (verify)를 활용해야하므로, 이런경우 선택적으로 사용한다.

 

# 결론

1. 테스트코드를 작성하는 것은 미래를 위한 투자이다. 

2. 통합테스트보다 단위테스트를 작성하도록 노력한다.

3. 단위테스트는 Mock보다 Fake 객체를 활용하도록 노력한다.

반응형