Effective Unit Testing 정리

November 30, 2019

프로젝트가 끝나거나 새로운 프로젝트가 시작하기 전에는 "다음에는 꼭 테스트 코드를 작성하겠다"라고 다짐하지만 마음대로 되지 않았다. 가장 많이 했던 핑계는 "시간이 부족해서"였던 것 같다. 사실은 시간이 있었어도 어떻게 하면 테스트 코드를 잘 작성할 수 있는지 몰랐기 때문에 테스트를 잘 짜기는 힘들었을 것 같다. 그러던 중에 팀의 동료분에게 "Effective Unit Testing"이라는 책을 추천받아 읽게 되었다.

이 책은 좋은 테스트 코드를 작성하는 방법을 다루는 책이다. 유닛 테스트, E2E 테스트, 통합 테스트 등 테스트의 종류에도 여러 가지가 있는데, 그중에서 유닛 테스트를 작성하는 방법에 대해서 다룬다. 아쉬운 점은 테스트 코드에 대한 예제가 Java로 작성된 점이다. 하지만 이 책의 내용은 Java에 국한된 것은 아니고, 나와 같은 프론트엔드 개발자들도 충분히 읽을 수 있는 책이라고 생각한다.

이 글에서는 책을 읽으면서 개인적으로 생각했을 때 기억하고 싶은 내용을 정리했다.

1. 기반 다지기

좋은 테스트의 이점

테스트 코드를 어떤 목적으로 작성하는지에 따라 얻을 수 있는 테스트 가치는 두 가지로 나뉜다.

먼저 테스트 코드의 목적이 품질 검증일 때는 테스트 코드를 통해 프로그램의 신뢰성을 높이는 것에 집중한다. 이 목적은 프로젝트의 테스트 커버리지가 낮을 때 유용한 목표이다. 만약 테스트 코드의 커버리지가 95% 정도로 높아졌을 때 테스트를 작성하는 노력에 대비해 얻을 수 있는 가치는 적은 것처럼 보일 수 있다.

이때 테스트 코드의 작성으로 얻을 수 있는 가치를 설계의 관점으로 전환하는 것이 필요하다. 이러한 개발 방법론은 테스트 주도 개발(TDD)로 널리 알려졌다. TDD는 구현 코드를 작성하기 전 실패하는 테스트를 작성하고, 그 테스트를 통과하기 위한 구현 코드를 작성하고, 작성했던 코드를 리팩토링하는 과정을 반복하는 방법론을 말한다. 그리고 코드를 리팩토링하는 과정에서 프로그램을 설계한다. TDD는 테스트 코드를 통한 결과보다 그 과정에서 얻을 수 있는 이점이 많다.

이 책에서는 TDD에 관해서 설명하기보다 "효율적인 테스트", "좋은 테스트"에 대해서 더 집중한다.

좋은 테스트란?

테스트 코드도 소스 코드이기 때문에 좋은 코드와 나쁜 코드로 구분할 수 있다. 저자가 경험했던 "좋은 테스트 코드"의 조건은 다음과 같다.

가독성이 좋은 코드인가?

읽기 쉬운 코드가 유지보수하기 좋다는 말이 있다. 읽기 쉬운 코드는 읽는 사람이 코드를 이해하는 데 필요한 시간을 줄여주며, 디버깅을 용이하게 해준다. 구현 코드와 마찬가지로 테스트 코드도 여러 사람에 의해 수정될 수 있는 코드이다. 가독성이 없는 코드는 테스트 코드를 이해하기 어렵게 하고, 테스트 코드를 수정하는 것을 어렵게 한다.

구조화가 잘 되었는가?

하나의 파일에서 너무 많은 테스트 코드가 있다면 테스트 코드의 전체적인 구조를 파악하기 힘들다. 이 경우 가능하면 작은 단위로 나눠야 하지만 무작위로 나누는 것은 오히려 여러 파일을 오가면서 테스트 코드를 읽어야 하는 불편함이 있다. 간단한 방법은 추상화된 개념을 단위로 테스트 코드를 쪼개는 방법이 있다.

잘 나누어진 테스트 코드는 구현 코드를 파악하는 것에도 도움이 된다. 구현 코드가 어떻게 실행되는지 파악하기 위해서는 테스트 코드를 읽는 것이 도움이 된다. 그리고 수정이 필요한 기능이 있다면, 어떤 테스트 코드를 봐야 할 지 파악하기도 쉬워진다.

무엇을 검사하는가?

테스트가 무엇을 검사하는지 파악하기 위해서 테스트의 이름을 살펴보는 것이 도움이 될 것이다. 그런데 테스트의 이름이 잘못 작성되었다면 테스트를 읽는 사람에게 잘못된 이정표로 길을 안내하는 것이 된다. 읽는 것만 못한 테스트 코드가 될 수 있다. 당연하지만 테스트 코드는 테스트가 하는 일에 맞게 작성되어야 한다.

독립적으로 잘 실행되는 코드인가?

'시간', '네트워킹', '동시성(Concurrency)' 등과 관련이 있는 테스트 코드는 독립적이라고 볼 수 없다. 이런 요소들은 테스트의 실행 결과를 외부 요인에 결정되게 만든다. 테스트의 실행 결과가 매번 달라지거나, 테스트를 실행하기 위해 준비해야 할 것들이 많아져서 단위 테스트를 복잡하게 만든다. 테스트를 격리시켜 독립적으로 만들기 위한 방법은 여러 가지가 있는데, 가장 중요한 것은 "테스트 더블"이다.

믿을만한 코드인가?

테스트를 실행할 때 결과가 매번 달라서 꼭 테스트를 여러 번 실행해야 한다면, 그 테스트는 신뢰할 수 없는 테스트이다. 언제는 성공하고 언제는 실패하는 테스트는 구현 코드의 어느 부분에 문제가 있는지 파악하기 어렵게 한다. 그리고 테스트 케이스의 순서가 바뀌거나 다른 테스트 코드가 추가되면 실패하는 테스트도 신뢰할 수 없다. 테스트는 테스트가 실행되는 순서와 관계없이 잘 실행되어야 한다.

어떤 테스트 도구를 사용하는가?

테스트 더블은 스텁(Stuff), Mock 객체 등을 통칭하는 용어이다. 테스트 더블은 테스트를 위하여 진짜 객체를 대신하는 객체다. 테스트 더블을 사용하면 테스트 속도를 개선할 수 있고, 어떤 상황을 시뮬레이션하거나 테스트에 필요한 상태를 얻게 할 수도 있다. 또한 JUnit과 같은 테스트를 실행하는 도구도 중요하며, 테스트가 실패하면 빌드도 반드시 실패해야 하므로 빌드 도구를 어떤 것을 사용하는지도 중요하다.

테스트 더블의 위력

구현 코드를 격리

첫 번째 위력은 테스트 대상인 구현 코드를 격리할 수 있는 것이다. 테스트 대상 코드를 격리하는 것은, 테스트 대상 코드가 의존하는 것으로부터 분리하는 것에서 출발한다. 테스트 대상 외에 다른 것들은 테스트 더블로 대체하는 방법을 사용할 수 있다.

테스트 속도 개선

두 번째는 테스트의 속도를 개선한다. 테스트 대상에 집중하기 위해서 테스트를 실행하는데 시간이 오래 걸리는 종속 객체는 연산 없이 단순한 결과만 반환하는 가짜 객체로 대체할 수 있다. 이렇게 하면 테스트 코드의 피드백 과정을 빨라지게 할 수 있다.

예측 불가능한 요소 제거

세 번째는 예측 불가능한 실행 요소를 제거할 수 있다. 예를 들어 언제 실행하는지에 따라 결과가 달라지는 테스트는 비결정적인 테스트이다. 테스트 더블을 활용하면 테스트가 실행될 때는 시스템의 시간이 항상 일정한 값을 반환하게 동작을 변경할 수도 있다. 시간에 의존하는 테스트의 목적은 결국 특정 시간에 결과를 잘 반환하는지 확인하는 것이기 때문이다. 시스템 시간을 테스트 더블로 대체하게 되면, 테스트를 몇 번 실행하더라도 테스트의 결과를 예측할 수 있게 만들 수 있다.

시뮬레이션

네 번째는 특수한 상황을 시뮬레이션 할 수 있다. 예를 들어 서버에서 에러 응답을 반환하거나, 네트워크 연결이 갑자기 끊겼을 때 테스트 대상이 어떻게 대처하는지 검증하기 위한 테스트를 작성한다고 생각해보자. 이를 위해 인터넷 선을 뽑는 방법도 있지만, 네트워크 요청을 담당하는 객체를 가짜 객체로 변경하면 이 객체에서 예외를 던지게 만들고, 테스트 대상 코드가 어떻게 반응하는지 검증할 수 있다.

감춰진 정보 접근

다섯 번째로 감춰진 정보를 얻어낼 수 있다. private 필드를 테스트해야 한다면 private 필드에 접근하는 public 메서드를 구현 코드에 추가하는 방법도 있다. 하지만 오직 테스트 코드를 위한 메서드를 구현 코드에 추가하는 것은 좋지 않다. 이 경우 구현 코드를 확장하는 가짜 객체를 만들고, 그 객체에서 private 필드에 접근하는 public 메서드를 방법이 있다. 이 방법을 사용하면 구현 코드를 수정하지 않고 private 필드를 테스트할 수 있다.

테스트 더블의 종류

테스트 더블의 종류는 테스트 스텁, 가짜 객체, 테스트 스파이, Mock 객체가 있다.

테스트 스텁

테스트 스텁은 가장 짧고 단순한 형태의 테스트 더블이다. 항상 일정한 값을 반환하도록 만들어진 함수를 예로 들 수 있다.

가짜 객체

가짜 객체는 테스트 스텁보다는 정성이 들어간 테스트 더블이다. 가짜 객체는 영속성과 관련된 코드를 검사할 때 유용하다. 만약 테스트 코드가 실제 데이터베이스에 접근해야 한다면, 테스트 코드를 실행할 때도 데이터베이스에 접근하고 질의하는 것은 테스트 실행 속도를 느리게 만든다. 실제 데이터베이스가 아니라 인메모리 데이터베이스를 가짜 객체로 구현하면 훨씬 빠른 속도로 테스트 코드를 실행할 수 있다.

테스트 스파이

테스트 스파이는 프로그래머를 대신하여 테스트 대상에 잠입해 프로그래머가 궁금한 내용을 알려주는 역할을 한다. 예를 들어서 입력 파라미터로 사용되는 객체가 프로그래머가 원하는 정보를 제공해주는 API가 없다면, 그 객체를 테스트 스파이로 대체하면 된다.

Mock 객체

Mock 객체는 강력한 형태의 테스트 스파이라고 볼 수 있다. 특정 조건이 발생하면 약속된 행동을 하도록 할 수 있고, 예기치 않은 일이 발생했을 때 실패하도록 하는 정교한 테스트를 작성할 수도 있다. Mock 객체는 오픈소스로 공개된 성숙한 Mock 라이브러리를 사용하는 것이 좋다.

테스트 더블 활용 지침

테스트 더블도 용도에 맞게 잘 선택해야 한다. 간단한 원칙은 스텁은 질문하고 Mock은 행동하는 특성을 생각하는 것이다. 두 객체의 상호작용 결과로 어떤 메서드가 호출되어야 하는지 확인해야 한다면 Mock 객체를 써야 할률이 높다. 협력 객체는 원하는 값만 내려주면 되고 협력 객체가 테스트 대상 객체에 넘겨줄 응답도 테스트에서 통제할 수 있다면, 테스트 스텁을 사용하는 것이 좋다.

테스트 코드도 소스 코드기 때문에 프로그래머 사이에서 약속된 규약에 의해 작성하면 알아보기 쉽다. 가장 많이 사용되는 약속은 테스트를 준비하고, 시작하고, 단언하는 것이다.

테스트는 대상 코드의 동작을 확인해야 한다. Mock 객체를 작성하는 것에 너무 공을 들이다 보면 테스트 코드가 복잡해지면 테스트가 실패한 것이 대상 코드의 문제인지 테스트가 문제인지 파악하기 힘들게 만든다. 테스트 더블을 사용하기 전에 꼭 필요한 것인지 신중하게 선택하고 테스트를 작성해야 한다.

그리고 테스트 더블을 제공하는 라이브러리를 사용하는 것이 좋다. 어떤 것은 거대한 레거시 코드를 검증하기 위해 테스트 코드를 작성할 때 좋고, 어떤 것은 신규 프로젝트를 위해 테스트 코드를 작성할 때 좋은 것이 있다.

2. 테스트 냄새

좋은 테스트 코드를 작성하는 것 못지않게, 나쁜 테스트를 작성하지 않는 것도 중요하다. 구현 코드에서 나쁜 패턴이 의심되는 경우 "냄새가 난다"라고 표현하는 것처럼 테스트 코드에서도 "테스트 냄새"가 발생할 수 있다.

가독성

읽기 쉬운 테스트 코드가 이해하기도 쉽고 유지보수하기도 쉽다. 가독성을 떨어뜨리는 테스트 코드의 요소들을 정리하면 다음과 같다.

기본 타입 단언

기본 타입(Primitive Type)을 단언하는 것은 대상 코드가 테스트하는 것이 무엇인지 헷갈리게 만든다. 테스트 코드는 읽는 입장에서 이해할 수 있도록 높은 수준의 추상화가 되어야 한다. 테스트 코드를 읽는 목적은 테스트 대상이 어떻게 실행되고 어떻게 동작하는지인데, 너무 낮은 수준의 추상화로 테스트 코드를 이해하기 어렵게 만든다면 나쁜 테스트 코드이다. 단언문에 기본 타입의 값이 있다면 상수로 대체하는 등의 방법을 사용해야 하며, 기본 타입을 직접 사용하여 검증하는 것은 피해야 한다.

광역 단언

광역 단언은 테스트 코드에서 너무 많은 것들을 단언하는 것이다. 사소한 변경이 테스트를 깨뜨리면 테스트를 어렵게 만들고 테스트의 목적이 무엇인가를 희석한다. 테스트 코드에서 테스트가 실패하는 이유는 오직 하나여야 한다. 테스트가 실패했는데 단언이 너무 많아 도대체 무엇 때문에 테스트가 실패했는지 모르면 안 된다.

부차적 상세정보

광역 단언은 단언이 넘쳐흐르는 것이고, 부차적 상세정보는 정보가 넘쳐흐르는 것이다. 테스트 코드를 작성하는 사람이 너무 많은 정보를 주고 싶은 마음에 이것저것 테스트 코드에 넣다 보면 부차적인 정보가 넘쳐흐르게 된다. 테스트 코드를 작성할 때는 핵심이 아닌 설정들은 별도의 셋업 메서드로 추출해야 한다.

다중 인격

다중 인격 테스트는 테스트는 오직 한 가지만 검사해야 한다는 원칙에 위배되는 테스트 냄새이다. 하나의 테스트 클래스에서 너무 많은 것들을 테스트한다면, "무엇을 테스트 하려는 것인지"를 기준으로 테스트를 쪼개야 한다.

셋업 설교

셋업은 테스트를 실행하기 위해 너무 장황한 셋업들을 하는 경우를 말한다. 테스트를 이해하기 위해서는 테스트를 실행하기 위한 준비 과정인 셋업도 이해해야 한다. 하지만 셋업이 너무 장황해진다면 테스트를 이해하기 어렵게 된다. 테스트의 셋업이 너무 장황하다고 느껴진다면 별도의 메서드로 분리해야 한다. 그리고 그에 맞는 서술적인 이름을 사용해야 한다.

과잉 보호 테스트

테스트 대상의 결과를 검증하면서, 의미 없는 것들을 함께 단언하는 것을 말한다. 핵심 단언이 실패하면 당연히 실패하게 될 단언을 작성하는 것을 예로 들 수 있다. 반대로 핵심 단언이 성공하면 실패할 리가 없는 단언들이 있다면 테스트 냄새를 유발하는 나쁜 테스트다.

유지보수성

코드는 작성하는 일보다 읽을 일이 더 많다는 말이 있다. 보통은 코드를 읽은 후 코드를 작성하는 단계로 넘어간다. 누군가의 코드를 수정하던 새로운 것을 개발하든 그 코드는 결국에는 유지보수되어야 한다. 다음은 테스트 코드를 유지보수하기 어렵게 만드는 테스트 냄새들이다.

중복

중복은 필요 없는 것이 반복되는 것을 말한다. 중복의 유형은 상수 중복, 구조 중복, 의미 중복이 있다. 상수 중복은 특정 문자열이나 숫자가 테스트 코드에서 반복되는 것을 말하는데, 이 경우 지역 변수를 하나 선언하여 해결할 수 있다. 구조 중복은 동일한 코드가 여러 곳에 중복된 경우를 말하는데, 별도의 메서드로 분리하여 해결할 수 있다. 해결하기 힘든 의미 중복은 동일한 역할을 하지만 다른 코드를 가진 중복을 말한다. 구조 중복을 포함하고 있지만, 눈에 띄지 않기 때문에 알아채기 어렵다. 의미 중복은 우선 구조 중복으로 바꾸고, 구조 중복을 개선하는 것으로 해결한다.

양치기 테스트

양치기 테스트는 외부의 요소에 의존하는 테스트에서 쉽게 일어날 수 있다. 시간에 의존하는 테스트 코드는 테스트를 실행할 때마다 결과가 달라질 수 있다. 처음 테스트 코드를 작성했을 때는 테스트가 실패하다가 빌드할 때 운 좋게 성공해서 문제가 없는 코드라고 오해할 수도 있다. 이것은 시간에 의존하는 테스트는 시스템 시간을 테스트 더블로 대체하거나, 난수에 의존하는 코드는 가짜 난수 발생기 등을 사용하여 랜덤한 상황을 테스트해볼 수도 있다.

잠자는 달팽이

테스트 실행을 느리게 만드는 요소 중의 하나는 테스트 코드를 실행하는 동안 일정 시간 동안 메인 쓰레드를 Blocking하는 코드들이다. 비동기 로직들이 실행된 후의 시점을 알 수 없어서, 예측하는 시간 동안 메인 쓰레드를 Blocking하는 방법을 사용한 것인데, 이런 테스트는 정확하지도 않고, 테스트 실행 속도를 느리게 만든다. 비동기 로직의 실행 완료 시점을 알 수 없다면, 관련 라이브러리를 사용하는 것도 좋다.

픽셀 퍼펙션

컴퓨터 그래픽 분야의 테스트 코드에서 빈번하게 발견되는 냄새이다. 예를 들어 두 박스 사이의 연결 여부를 테스트하는 코드가 있다면, 두 박스 사이의 연결 여부를 알 수 있는 유일한 방법은 선이 그려져 있는지 코드로 확인하는 방법이다. 그런데 선이 그려져 있는지 여부를 픽셀 단위로 검사하는 테스트 코드가 있다면 그 테스트 코드는 실패하기 쉬운 테스트 코드이다. 입력을 조금만 다르게 줘도 실패하기 때문이다. 만약 두 박스 사이의 연결 여부를 검증한다면 점이나 좌표를 직접 단언할 필요는 없다. 대신 사용자가 정의한 메서드로 추상화해서 복잡한 것을 숨겨야 한다.

신뢰성

프로젝트를 처음 인수인계 받는 상황에서 테스트 코드는 동작을 이해하기 좋은 문서처럼 읽을 수 있다. 하지만 신뢰할 수 없는 테스트를 읽는다면 프로그램의 동작을 오해할 수도 있다. 신뢰성이 없는 테스트는 개발자의 시간을 뺏고, 발목을 잡기 마련이다.

주석으로 변한 테스트

통째로 주석으로 변한 테스트를 보면 일단 주석 처리된 이유가 가장 궁금할 것이다. 테스트가 깨졌지만 급해서 일단 넘어간 것인지, 필요할 때만 주석 해제하면 성공하는 건지 알 수 없기 때문에 테스트 코드를 읽는 사람을 힘들게 한다. 주석으로 된 테스트 코드를 이해하기 위해서는 히스토리를 아는 사람을 찾거나 테스트의 주석을 해제해서 실행해보거나 코드를 분석하는 방법밖에 없다.

오해를 낳는 주석

소스 코드에 주석이 달려있으면 읽는 사람 입장에서 참 고맙다. 그런데 잘못된 주석은 있는 것만 못한 주석이 된다. 주석의 내용이 실제 동작과 다른지 검증하는 것은 테스트 실행기에서 해주지 않는다. 가장 좋은 것은 주석이 필요 없는 테스트 코드를 작성하는 것이다. 설명이 필요한 코드는 별도의 메서드로 분리하고 메서드의 이름을 알아보기 쉽게 만든다. 그래도 주석이 정말 필요하다면, 주석은 "How"가 아니라 "Why"를 설명해야 한다. 코드의 동작을 일일이 설명하고 있는 주석은 나중에는 거짓말을 하는 주석이 될 확률이 높다.

절대 실패하지 않는 테스트

대상 코드의 예외 처리 로직을 검증하고자 한다면, try , catch 문으로 검증하게 될 것이다. 그런데 예외가 발생하지 않아 catch 블락으로 진입하지 않아도 단언이 실행되지 않기 때문에, 그 테스트는 성공한 테스트가 된다. 예외가 발생해야 하는데 예외가 발생하지 않은 테스트는 실패한 것이다. 이 경우 try 블락의 끝에 무조건 실패하는 단언문을 넣고, try 문이 끝까지 실행되지는 않는지 검증해야 한다. 만약 try 블락이 끝까지 실행된다면 테스트는 무조건 실패하는 단언문을 만나게 되고, 테스트가 원하는 바를 잘 검증할 수 있게 된다.

지키지 못할 약속

테스트 케이스의 이름을 보면 테스트가 하는 일을 파악할 수 있다. "주석으로 변한 테스트"와 비슷한 경우인데, 막상 테스트 코드를 보면 테스트의 이름과 실제 검증 로직이 다른 경우가 있다. 이 경우 테스트 이름을 작성할 때와 마지막으로 단언문을 작성하는 사이에 테스트의 목적이 달라진 경우이다. 테스트의 이름을 짓기 어려운 경우 TODO라는 이름으로 남겨두고 단언문을 작성한 이후, 테스트를 작성하는 방법도 있다.

플랫폼 편견

플랫폼에 의존하는 테스트 코드는 테스트를 어디서 실행하는지에 따라서 테스트의 실행 결과를 달라지게 만든다. 다른 플랫폼에서 실패하는 테스트지만, 작성자의 환경에서 성공하여 테스트가 성공했다고 믿게 만든다. 플랫폼별로 테스트를 해야 하는 상황이라면, 우선 대상 코드를 리팩토링하는 것을 검토해야 한다. 대상 코드 내부에 플랫폼에 종속된 로직을 제거하고, 플랫폼별로 달라지는 요소를 파라미터로 받을 수 있게 한다.