본문 바로가기
source-code/software

단위 테스트 - 고전파와 런던파, 그리고 개인적인 견해

by mattew4483 2024. 3. 28.
728x90
반응형

Backgrounds

사내 package를 개발하며 테스트 코드를 작성하던 도중, 이런 일이 있었습니다.
 
입력받은 데이터를 서비스 내부 정책에 따라 필터링하는 모듈이 존재했고,
해당 모듈은 내부 정책 관련 로직을 처리하는 다른 모듈을 의존성으로 주입받고 있었습니다.

class Module {
	filterPoliciy: Policy
	
	constructor (filterPoliciy: Policy) {
            this.filterPoliciy = filterPoliciy
    }
    
    filter(data:Data): Data | null {
    	// 해당 메서드 내부적으로 Policy interface 사용
        ...this.filterPoliciy.isIdExist() 
    }
}

 
아래와 같은 형태로 테스트 코드를 작성했습니다.

describe('정책에 맞는 data를 필터링 합니다', () => {
    it('정상 동착', () => {
        const policy = new MainPolicy()
        const module = new Module(policy)

        const data:Data = [{id:0}]
        const filteredData = module.filter(data)

        expect(filteredData).toEqual(data);
    })
}

사실 위 테스트 코드도 아무런 문제 없이 단위 테스트를 수행합니다.
 
하지만 문득 이러한 의문이 들었습니다.

'해당 테스트는 module class의 filter메서드가 정상 동작하는지 확인해야 하는데...
만약 주입받은 MainPolicy 모듈에 에러가 존재해 테스트가 실패하면 어쩌지?'

 

Problems 1

위 테스트 코드에서 테스트 대상인 Module은 Policy라는 interface에 의존하고 있습니다.
실제 사용처에서는 MainPolicy라는 모듈을 사용하므로, 테스트 코드에서도 해당 구현체를 그대로 import 해 사용했죠.
 
그런데 이 경우...
MainPolicy 모듈에 버그가 존재한다면, 해당 모듈과 관련된 모든 테스트가 실패해버리고 맙니다!

describe('정책에 맞는 data를 필터링 합니다', () => {
    it('정상 동착', () => {
        const policy = new MainPolicy()
        const module = new Module(policy) // 테스트 실패 시, MainPolicy와 Module중 무엇이 문제인지 보장할 수 없다

        const data:Data = [{id:0}]
        const filteredData = module.filter(data)

        expect(filteredData).toEqual(data);
    })
}

즉 Module에 대한 테스트 코드가 정작 Module과는 관계없는 외부 요인으로 인해 실패하는 것이며,
이는 자연스레 테스트 실패 원인 파악의 어려움과 디버깅 난이도의 상승으로 이어지고 맙니다.
 

Solutions

Module에 MainPolicy 구현체를 직접 주입하는 것이 아니라
해당 테스트에서 동작을 정의한, Stub을 주입해 주는 형태로 위 문제를 해결할 수 있습니다!

describe('정책에 맞는 data를 필터링 합니다', () => {
    it('정상 동착', () => {
        const policyStub:Policy = {
            isIdExist: () => true // 해당 it 테스트 케이스에서 Policy 인터페이스의 isIdExist 동작 정의
        }
        const module = new Module(policyStub)

        const data:Data = [{id:0}]
        const filteredData = module.filter(data)

        expect(filteredData).toEqual(data);
    })
}

 
즉 실제 구현체인 MainPolicy 인스턴스가 아닌,
stub을 사용해 해당 테스트 케이스 내부에서 그 동작을 제어하는 것이죠.
(Policy interface에 대한 stub을 작성하고, 각 it 테스트 케이스마다 필요한 메서드를 Mocking 해도 좋을 것 같습니다)
 
→ 이를 통해 해당 테스트 코드의 성공/실패는 온전히 테스트 대상인 Module에 의해서만 결정될 수 있겠죠!
 

Problems 2

하지만, 위 방법이 정답이다! 라고 얘기하기엔... 곧바로 아래와 같은 문제점들이 떠올랐습니다.

의존 모듈의 동작은, 해당 모듈의 테스트가 책임지면 되는 게 아닌가?

stub으로 변경한 가장 큰 이유는 테스트 실패 시, 그 원인을 파악하기 어렵다는 점이었습니다.
즉 테스트 실패의 원인이 테스트 대상 모듈인지, 의존성으로 주입받은 모듈인지 알 수가 없다는 것인데...
 
사실 정상적인 상황이라면 의존성으로 주입받는 모듈(위 경우 MainPolicy) 역시 테스트가 작성되어 있을 테고,
버그가 존재할 경우 해당 테스트가 실패했을 것이며
→ 이를 디버깅하면, 자연스레 모듈을 주입하는 쪽의 테스트도 통과하게 되겠죠!
 
그러므로...
문제라 생각했던 '테스트 실패 원인을 파악하기 어려운 상황'은 사실 극히 드문 것이 아닐까요?
 

결국 중요한 건 애플리케이션의 동작인데, 이것이 누락된 게 아닌가?

위 케이스의 경우, 실제 애플리케이션단에서는 MainPolicy를 Module에 주입해 사용하고 있습니다.
 
그렇다면...
정말 테스트가 필요한 건 'MainPolicy를 주입받은 Module'이 테스트 케이스를 통과하는가 이지 않을까요??
 
stub을 주입받은 모듈이 테스트를 아무리 통과한다고 해도
결국 애플리케이션에서는 MainPolicy를 주입해 Module을 사용할 텐데...
그렇다면 테스트 코드에서도 이를 구현하는 것이 올바른 테스트 방향이지 않을까요?
 

고전파와 런던파

이러한 의문점을 해결하기 위해 동료 개발자들과 얘기를 나누던 도중
실제 위와 같은 논쟁이 컴퓨터 공학에서 활발히 진행되었음을(되고 있음을) 알게 되었습니다.
 
가장 대표적인 것이 고전파와 런던파의 테스트에 있어서 격리의 범위와 방법에 대한 논쟁입니다.
https://jonghoonpark.com/2023/10/05/단위-테스트의-두-분파

단위 테스트의 두 분파 (고전파와 런던파)

2장 단위 테스트란 무엇인가 단위테스트 (블라디미르 코리코프)

jonghoonpark.com

 
https://kukim.tistory.com/107

단위 테스트란 무엇일까? 런던파와 고전파의 차이점 🆚

이 글은 책 Unit Testing(단위 테스트) 2장과 하단 Reference 참고했습니다. 잘못된 내용이 있다면 편하게 말씀해주세요 🙏🏻 목차 - 런던파? 고전파? - 단위 테스트 정의 - 런던파의 테스트 격리 - 고전

kukim.tistory.com

즉 저의 경우
MainPolicy를 Module에 직접 주입 → 고전파
Policy를 구현한 stub을 Module에 주입 → 런던파 의 시각으로 볼 수 있었죠.
 

Thinkings

사실 언제나 그렇듯, 은빛 탄환은 존재하지 않습니다. 정답이란 건 없다!
→ 서비스, 애플리케이션 별 상황에 맞는 방법을 채택하는 방법뿐인데...
 
사실 개인적으로는,
아래와 같은 이유들로 인해 런던파에 시각에 좀 더 손을 들어주고 싶었습니다. (하하!)
 

구현이 아닌, 추상에 의존하라

의존성 역전 원칙은 언제나 유효합니다.
 
다시 위 사례를 살펴보자면...

class Module {
    // Policy라는 interface에 의존
	filterPoliciy: Policy
	
	constructor (filterPoliciy: Policy) {
            this.filterPoliciy = filterPoliciy
    }
    
    filter(data:Data): Data | null {
    	// 해당 메서드 내부적으로 Policy interface 사용
        ...this.filterPoliciy.isIdExist() 
    }
}

 
Module class는 Policy라는, interface에 의존하고 있습니다.
→ 즉 Module은 런타임 전까지는 해당 inteface에 대한 실제 구현체가 무엇인지는 알 수도, 알 필요도 없는 것이죠.
 
이러한 관점에서 봤을 때, 테스트 코드도 이러한 논리를 따라야 하지 않을까요?

describe('정책에 맞는 data를 필터링 합니다', () => {
    it('정상 동착', () => {
    	let policy:Policy
        // policy = new MainPolicy() // 테스트 성공
        // policy = new FailPolicy() // 테스트 실패 => ??!?

        const module = new Module(policy)

        const data:Data = [{id:0}]
        const filteredData = module.filter(data)

        expect(filteredData).toEqual(data);
    })
}

고전파의 시각처럼 Module이 실제 구현체를 주입받을 경우
어떤 구현체를 주입했느냐에 따라, 테스트의 성공/실패 여부가 달라지는 아이러니한 일이 발생하고 맙니다!
 
의존성 역전 원칙에 따른 설계가 이뤄졌다면, 각 모듈은 interface에 의존하고 있을 것이며
따라서 테스트 코드 역시 구현체의 인스턴스보다 해당 inteface의 stub의 의존하는 것이 의미상 적절하지 않을까요?
 

애플리케이션의 동작은, 애플리케이션 동작 테스트가 검증한다

stub을 사용했을 경우, 물론 실제 애플리케이션단 사용처의 동작을 검증할 수는 없습니다.
하지만 이는... 사실 바람직한 테스트라 할 수 있지 않을까요?
 
작은 코드 단위의 여러 테스트가 존재하며
이러한 작은 코드들이 합쳐진 애플리케이션의 동작은 → 해당 사용처에 대한 테스트로 검증해야만 합니다!
 
즉 위 예시에서 MainPolicy를 주입받은 Module이 테스트 케이스를 통과하는지는...

// 실제 application 사용처
const MyData = () => {
 // ...
 const mainPolicy = new MainPolicy()
 const filterModule = new Module(mainPolicy)
 filterModule.filter(data)
 // ....
}

// App.test.ts
it('main policy에 맞는 데이터가 필터링된다', () => {
	const datas = MyData()
	expect(datas).toEqual() // MainPolicy를 주입받은 Module을 사용하는 MyData에 대한 테스트
})

해당 모듈을 사용하는 곳에 대한 테스트 코드가 책임지면 되는 것이죠!

728x90
반응형