박종훈 기술블로그

유지보수 하기 쉬운 테스트 코드 만들기, 깨지기 쉬운 테스트 예방하기 - 구글 엔지니어는 이렇게 일한다

구글 엔지니어는 이렇게 일한다
12장 단위 테스트

관련글


이전 장에서는 구글이 테스트를 분류하는 두 가지 주요 축인 크기와 범위를 소개했습니다.

크기(size) 는 테스트가 소비하는 자원과 수행할 수 있는 작업을 뜻하며, 범위(scope)는 테스트가 검증하고자 하는 코드의 양을 의미합니다. 테스트 크기의 정의는 명확하지만 범위는 다소 모호한 면이 있습니다.

구글에서 말하는 단위 테스트(unit test) 는 단일 클래스나 메서드처럼 범위가 상대적으로 좁은 테스트를 뜻합니다. 단위 테스트는 일반적으로 크기가 작지만 반드시 그런 것은 아닙니다.

테스트의 가장 중요한 목적은 물론 버그 예방입니다. 그 다음으로 중요한 목적을 꼽자면 엔지니어의 생산성 개선입니다. 범위가 더 넓은 테스트들과 비교하여 단위 테스트는 생산성을 끌어올리는 훌륭한 수단이 될 수 있는 특성을 많이 지니고 있습니다.

  • 구글의 테스트 크기 정의에 따르면 단위 테스트는 대체로 작은 테스트에 속합니다. 작은 테스트는 빠르고 결정적이어서 개발자들이 수시로 수행하며 피드백을 즉각 얻을 수 있습니다.

  • 단위 테스트는 대체로 대상 코드와 동시에 작성할 수 있을 만큼 작성하기 쉽습니다. 따라서 엔지니어들은 커다란 시스템을 설정하거나 이해할 필요 없이 작성 중인 코드를 검증하는 데 집중할 수 있습니다.

  • 빠르게 작성할 수 있으므로 테스트 커버리지를 높이기 좋습니다. 커버리지가 높다면 엔지니어들은 기존 동작을 망가뜨리지 않으리라는 확신 속에서 코드를 변경할 수 있습니다.

  • 각각의 테스트는 개념적으로 간단하고 시스템의 특정 부분에 집중하므로 실패 시 원인을 파악하기 쉽습니다.

  • 대상 시스템의 사용법과 의돈한 동작 방식을 알려주는 문서자료 혹은 예제 코드 역할을 해줍니다.

단위 테스트는 장점이 많아서 구글에서 작성하는 테스트 대부분을 차지합니다. 경험상 구글은 단위 테스트를 80%, 그 외 범위가 더 넓은 테스트를 20% 비중으로 작성하도록 장려합니다. 작성하기도 쉽고 금방 결과를 알 수 있으므로 엔지니어들은 단위 테스트를 엄청나게 많이 실행하게 됩니다. 실제로 엔지니어 한 명이 매일 직간접적으로 단위 테스트 수천 개를 수행하는 일은 아주 흔합니다.

단위 테스트는 엔지니어의 일상에서 비중이 크게 때문에 구글은 ‘테스트 유지보수성(test maintainability)’을 상당히 중시합니다. 유지보수하기 쉬운 테스트는 ‘그냥 작송하는(just work)’ 테스트를 말합니다. 즉, 한 번 작성해두면 실패하지 않는 한 엔지니어가 신경 쓸 필요 없고, 혹 실패한다면 원인을 바로 알 수 있는 진짜 버그를 찾았다는 뜻입니다.

이번 장은 유지보수성이란 무엇인지와 높은 유지보수성을 달성하는 기법들에 많은 분량을 할애할 것입니다.

12.1 유지보수하기 쉬워야 한다

때때로 테스트는 원래 의도와는 정반대의 효과를 내기도 합니다. 코드의 품질을 의미있게 높여주지도 못하면서 생산성을 갉아먹습니다. 구글엔지니어도 매일 같이 씨름하는 문제입니다.

질 나쁜 테스트는 체크인되기 전에 수정되어야 합니다. 그렇지 않으면 미래의 엔지니어들을 방해할 것입니다.

구글 엔지니어들은 다양한 패턴과 관행을 발굴하여 공유하고 권장해왔습니다.

테스트는 깨지기 쉽게 작성되는걸 피해야 합니다. 불명확하게 작성되는 것도 피해야 합니다.

12.2 깨지기 쉬운 테스트 예방하기

** 깨지기 쉬운 테스트** 란 실제로는 버그가 없음에도, 심지어 검증 대상 코드와는 관련조차 없는 변경 때문에 실패하는 테스트를 말합니다. 이러한 테스트는 엔지니어가 진단하고 수정해야 합니다.

12.2.1 변하지 않는 테스트로 만들기 위해 노력하자

깨지기 쉬운 테스트를 피하는 패턴을 이야기하기에 앞서 한 가지 질문에 답해봐야 합니다. 테스트를 처음 작성한 후 얼마나 자주 변경할 거라 예상하나요? 기존 테스트를 갱신하느라 허비한 시간은 모두 더 가치 있는 작업에 쏟을 수도 있던 시간입니다. 따라서 이상적인 테스트라면 변하지 않아야 합니다. 한 번 작성한 후로는 대상 시스템의 요구사항이 바뀌지 않는 한 절대 수정할 일이 없어야 합니다.

현실에서 발생할 수 있는 기본적인 변경 유형 네 가지는 다음과 같습니다.

순수 리팩터링

외부 인터페이스는 놔두고 내부만 리팩터링한다면 테스트는 변경되지 않아야 합니다. 성능 최적화, 코드 가독성 개선 등이 여기 속합니다. 이 경우 테스트의 역할은 리팩터링 후에도 시스템의 행위가 달라지지 않았음을 보장하는 것입니다.

리팩터링 과정에서 테스트를 변경해야 한다면 원인은 둘 중 하나입니다. 첫 번째는 시스템의 행위가 달라졌다는 뜻입니다. 순수 리팩터링이 아니였던 것이죠. 두 번째는 테스트의 추상화 수준이 적절하지 않다는 뜻입니다. 테스트가 대상의 세부 구현 방식에 지나치게 의존하고 있었다는 이야기입니다.

새로운 기능 추가

새로운 기능이나 행위를 추가할 때는 기존 행위들에 영향을 주지 않아야 합니다. 새 기능을 검증할 테스트를 새로 작성해야 하며, 기존 테스트들은 변경되지 않아야 합니다. 새로운 기능을 추가했는데 기존 테스트를 변경해야 한다면 해당 테스트가 검증하는 기능에 의도치 않은 영향을 주었거나 테스트 자체에 문제가 있다는 뜻입니다.

버그 수정

버그 수정은 새로운 기능 추가와 비슷합니다. 버그가 존재한다는 것은 기존 테스트 스위트에 빠진 게 있다는 신호입니다. 따라서 버그 수정과 동시에 바로 그 누락됐던 테스트를 추가해야 합니다. 버그 수정 때도 통상적으로 기존 테스트들은 변겨오디지 않아야 합니다.

행위 변경

시스템의 기존 행위를 변경하는 경우로, 기존 테스트 역시 변겨오디어야 합니다. 앞의 세 경우 보다 일반적으로 비용이 더 드는 변경입니다. 시스템 사용자들은 현재 행위에 의존하고 있을 것입니다. 따라서 행위를 변경하려면 혼란에 빠지거나 업무가 중단되는 사용자가 없도록 조치해줘야 합니다. 이 경우 테스트를 변경한다는 것은시스템이 한 약속을 ‘의도적으로’ 변경한다는 뜻입니다 (앞의 세 유형에서는 ‘의도치 않게’ 변경한 것이었습니다.) 저수준 라이브러리라면 사용자의 시스템을 망가트릴 일이 없도록, 애초부터 행위를 변경할 일이 없게끔 설계하는 데 엄청난 노력을 기울입니다.


요점은 리팩터링, 새 기능 추가, 버그 수정 시에는 기존 테스트를 손볼 일이 없어야 한다는 것입니다. 이 원칙을 이해하고 잘 지키면 대규모 시스템을 다룰 수 있습니다. 즉 시스템을 확장할 때는 기존 테스트들을 일일이 손보는 게 아니라 확장한 부분과 관련된 소수의 테스트만 새로 작성하면 돕니다.

기존 테스트를 수정해야 하는 경우는 시스템의 행위가 달라지는 파괴적인 변경이 일어날 때뿐이다. 그리고 이런 상황에서의 테스트 갱신 비용은 모든 사용자의 코드를 갱신하는 비용보다 대체로 저렴합니다.

12.2.2 공개 API를 이용해 테스트하자

요구사항이 변하지 않는 한 테스트를 수정할 필요가 없게 하려면 어떻게 해야할까요? 이중 가장 좋은 방법은 ‘테스트도 시스템을 다른 사용자 코드와 똑같은 방식으로 호출하기’ 입니다. 내부 구현을 위한 코드가 아닌 공개 API를 호출하면 됩니다. 즉 테스트가 시스템을 사용자와 똑같은 방식으로 사용하는 것입니다. 그렇다면 정의상 테스트가 실패하면 사용자도 똑같은 문제를 겪습니다. 테스트가 사용자에게 유용한 예제 코드와 문서자료가 되어준다는 보너스도 얻을 수 있습니다.

그런데 어디까지가 ‘공개 API’이냐가 항상 명확한 것은 아니며, 이는 단위 테스트에서 말하는 ‘단위’가 무엇이냐를 규정하는 핵심적인 질문으로 이어집니다. 단위는 개별 함수처럼 작은 것을 가리킬 수도 있고 서로 관련된 여러 패키지나 모듈의 묶음처럼 넓은 것을 지칭할 수도 있습니다.

이런 맥락에서 ‘공개 API’란 이런 단위 코드 소유자가 서드파티에 노출한 API를 뜻합니다. 프로그래밍 언어에서 말하는 가시성(visibility)과는 조금 다릅니다. 자바를 예로 들면 public 이라 할지라도 실제로 외부에서 접근까지 허용할 의도로 만들어졌는지가 더 중요.

단위의 범위를 잘 정해서 어디까지가 공개 API인가를 정하는 일에 과학적인 정답은 없습니다. 그래도 다행히 쓸만한 경험법칙은 있습니다.

  • 소수의 다른 클래스를 보조하는 용도가 다인 클래스(예: Helper 클래스) 라면 독립된 단위로 생각하지 않는 게 좋습니다. 따라서 이런 메서드나 클래스는 직접 테스트하지 말고 이들이 보조하는 클래스를 통해 우회적으로 테스트해야 합니다.

  • 소유자의 통제 없이 누구든 접근할 수 있게 설계된 패키지나 클래스라면 거의 예외 없이 직접 테스트해야 하는 단위로 취급해야 합니다. 이때도 테스트는 사용자와 똑같은 방식으로 접근하는 것입니다.

  • 소유자만 접근할 수 있지만 다방면으로 유용한 기능을 제공하도록 설계된 패키지나 클래스 (예: 지원 라이브러리) 역시 직접 테스트해야 하는 단위로 봐야 합니다. 이 경우 지원 라이브러리의 코드를 라이브러리 자체용 테스트와 라이브러리 사용자용 테스트 모두에서 검사한다는 점에서 다소 중복이생길 수 있습니다. 하지만 유익한 중복입니다. 라이브러리 사용자(와 그 테스트) 중 하나가 사라지면 라이브러리의 테스트 커버리지가 낮아질 수 있기 때문입니다.


구글에서의 경험에 비춰보면 공개 API를 호출해 테스트하는 편이 내부 구현을 직접 테스트하는 것보다 나음을 이해하지 못하는 엔지니어도 종종 있습니다. 그래서 설득을 해야 했죠. 엔지니어 입장에서는 방금 작성한 코드를 바로 테스트하는 편이 그 코드가 시스템 전체에 어떤 영향을 주는지까지 파악하는 것보다 훨씬 쉽기 때문에 처음에는 거부감이 들 수 있습니다. 그럼에도 이 관행은 계속 장려할 가치가 충분합니다. 이 투자를 초기에 하지 않으면 그 후 여러번에 걸쳐 보수해줘야 하기 때문입니다. 공개 API로 테스트한다고 해서 깨지기 쉬운 테스트가 완벽하게 사라지는 것은 물론 아닙니다. 하지만 시스템의 유의미한 변경이 있을 때만 테스트가 실패하도록 만드는 방법 중 여러분이 할 수 있는 가장 효과적인 수단입니다.

12.2.3 상호작용이 아니라 상태를 테스트하자

테스트가 내부 구현에 의존하는 대표적인 유형이 또 있습니다. 이 유형은 테스트가 어떤 메서드를 호출하냐가 아니라 호출 겨로가를 어떻게 검증하냐에 관련됩니다.

시스템이 기대한 대로 동작하는지 검증하는 방법은 크게 두 가지입니다. 첫 번째는 상태 테스트(state test)로 메서드 호출 후 시스템 자체를 관찰합니다. 두 번째는 상호작용 테스트(interaction test)로 호출을 처리하는 과정에서 시스템이 다른 모듈(시스템)들과 협력하여 기대한 일련의 동작을 수행하는지를 확인합니다.

[참고자료] Testing on the Toilet: Testing State vs. Testing Interactions

많은 테스트가 상태와 상호작용 검증을 혼용할 것입니다.

대체로 상호작용 테스트는 상태 테스트보다 깨지기 쉽습니다. 이유는 공개 메서드 테스트보다 비공개 메서드 테스트가 깨지기 쉬운 이유와 같습니다. 우리가 진짜 원하는 것은 결과가 무엇(what) 이냐지만, 상호작용 테스트는 결과에 도달하기까지 시스템이 어떻게(how) 작동하냐를 확인하려 듭니다.

잠재적으로 문제가 도리 수 있는 상호작용 테스트가 만들어지는 가장 큰 원인은 바로 모의 객체 프레임워크(mocking framework)에 지나치게 의존하기 때문입니다. 모의 객체 프레임워크를 이용하면 테스트 대역을 쉽게 만들 수 있고, 테스트 대역을 자신을 향한 모든 호출을 기록하고 검증할 수 있게 해줍니다. 이런 편리함은 엔지니어에게 깨지기 쉬운 상호작용 테스트를 만들도록 유혹합니다. 그래서 우리는 진짜 객체가 빠르고 결정적이라면 테스트 대역을 지양하고 진짜 객체를 사용해야 합니다.