예전부터 테스트를 작성하다보면 어떤 테스트가 좋은 테스트인지 궁금했습니다. 하지만 좋은 테스트에 대해 감히 내가 정의할 수는 없었습니다. '좋은' 이라는 단어가 상대적이어서 그럴수도 있고 단어 자체가 주는 부담감 또는 누구에게 좋은 테스트가 누구에겐 좋지 않을 수도 있는 케바케의 문제가 있을 수 있다고 생각해서 였습니다(케바케 보다는 상황에 따라서 라는 말을 더 선호합니다). 하지만 이 책에서는 좋은 테스트를 주제로 책을 시작하고 있습니다. 테스트를 왜 작성해야 하는지, 좋은 테스트란 무엇인지 쉽게 잘 설명해주어 이 글을 기반으로 정리용 글을 쓰기로 했습니다.
테스트의 가치란?
1. 테스트는 실수를 바로 잡아줍니다.
새로운 기능을 개발하면 작성한 코드가 기대했던 대로 동작하는지 확인을 해야 합니다. 그리고 기능을 리팩터링 했을때 역시 예전과 동일하게 동작하는지 확인을 해야 합니다. 테스트 코드가 없다면 코드를 하나씩 실행하면서 개발중에 실수를 했는지 확인하는 과정과 실수를 수정하는 작업이 필요합니다. 그리고 대개 이러한 작업은 한번으로 끝나지 않고 여러번 반복하고 나서야 올바른 결과물이 나오게 됩니다. 만약 실수하기 쉬워 보이는 코드에 단위 테스트를 추가한다면 기능을 올바르게 만들었는지, 리팩터링을 잘 했는지 바로 확인할 수 있고 빠르게 수정을 할 수 있습니다.
2. 테스트는 설계를 도와줍니다.
테스트를 통해 실사용에 적합한 설계를 이끌어 낼 수 있습니다. 예를 들면 테스트 주도 개발(TDD)을 통한 개발 방법이 있을 것 같습니다. 실패하는 작은 테스트를 만들고 그 테스트를 성공할 만큼의 작은 코드를 만듭니다. 그리고 다시 실패하는 테스트를 만들고 성공할 만큼의 작은 코드를 만듭니다. 이렇게 개발을 하면 실사용에 적합한 코드만 구현을 하게 되면서 좀 더 깔끔한 코딩이 가능합니다. 기능 구현에 필요한 코드만 남기 때문에 군더더기를 제거한 코드만 존재하게 됩니다. 테스트가 설계를 도와준다는 말이 TDD 의 가치라고 볼 수도 있겠지만 TDD 도 테스트가 주도하는 방법론이기 때문에 테스트가 설계를 도와준다고 말할 수 있을 것 같습니다.
하지만 무조건 테스트가 존재한다고 위에 나온 테스트 가치를 실현할 수 있는 것은 아닙니다. 잘못 만들어진 테스트 코드는 오히려 개발자 생산성에 악영향을 끼칠 수 있습니다. 극단적이고 간단한 예를 들면 아래와 같은 사칙연산 계산기 테스트 코드가 있다고 해봅시다.
public class CalculatorTest {
private Calculator calculator = new Calculator();;
@Test
void plus() {
assertThat(calculator.plus(1,2)).isEqualTo(3);
}
@Test
void minus() {
assertThat(calculator.minus(2,1)).isEqualTo(1);
}
static class Calculator {
public int plus(int x, int y) {return x + y;}
public int minus(int x, int y) {return Math.abs(x - y);}
}
}
동료 개발자가 사칙연산에 대한 테스트는 통과했으니 다음 개발을 하면 된다고 인수인계 해주었습니다. 순진한 우리는 그 말을 믿고 자동화된 케이스를 실행해보고 모든 테스트가 성공했다는 결과가 나오니 이어서 개발을 하지만 사실 이 사칙연산 계산기는 곱셈과 나눗셈에 대한 테스트가 작성되어 있지 않습니다(코드도 없긴 합니다). 또한 minus 테스트 메서드는 단순한 뺄셈이 아닌 값의 차이를 구하고 있기 때문에 우리가 기대했던 사칙연산 계산기의 동작과 다른 동작을 검증 하고 있습니다. 그래서 이 계산기의 테스트 코드를 믿고 개발하다가는 잘못된 길로 빠질 수 있습니다. 이 예는 단순한 예제이지만 복잡한 로직에서는 무엇이 잘못된 건지 파악하기 어려워 점점 미궁속으로 빠질 수도 있습니다.
이처럼 테스트가 잘못 만들어졌거나 또는 테스트 케이스가 부족해 신뢰할 수 없는 테스트가 만들어지면 차라리 테스트가 없는게 좋을 수도 있습니다. 좋지 못한 테스트의 한 예로 볼 수 있습니다. 그렇다면 좋은 테스트란 뭘까요?
좋은 테스트란?
어떤 테스트 코드가 좋은 테스트코드인지 판단하는 기준은 사람마다 다를 것 입니다. 왜냐하면 '좋다' 라는건 보통 개인 취향에 좌우되고 이 책의 필자도 개인 중 한 사람일 뿐이기 때문입니다. 하지만 이 책의 필자는 훌륭한 소프트웨어 전문가들과 함께하며 경험한 테스트 코드에 관한 견해를 잘 설명해주었고 저 역시 필자의 견해에 동의하기에 글로 남기고자 합니다.
1. 읽기 쉬운 코드가 유지보수도 쉽다.
우리는 대부분의 경우 타인에 의해 구현된 코드를 물려받아 일을 하게 됩니다. 만약 이 코드가 너무 난해해서 이해하기 어려울 뿐만 아니라 읽기도 버거운 코드라면 어떻게 될까요? 이 코드를 분석하고 정리하고 이해하는데만 오랜 시간을 지불해야 합니다. 이처럼 읽기 어려운 코드는 이해하는데만 해도 많은 에너지가 소비되기 때문에 유지보수가 쉽지 않습니다. 가독성과 결함 밀도는 반비례하다는 연구 결과도 있다고 합니다. 즉, 읽기 어려운 코드일수록 결함이 발생활 확률이 높다고 합니다.
테스트 코드도 코드입니다. 가독성이 좋지 않으면 이 테스트 코드가 무엇을 테스트 하는지 파악하기 어려워지고 테스트 코드를 작성하지 않는 상황까지 이어질 수도 있습니다. 만약 기능의 변경이 생겨 테스트 코드도 수정을 해야 하는 상황이 온다고 했을 때, 가독성이 나쁜 테스트 코드는 읽기 어려워 수정하기 어려울 것이고 잘못된 변경을 하여 신뢰성 떨어지는 테스트를 만들어 낼 수도 있습니다. 그렇기 때문에 테스트도 관리되어야 하는 대상임을 인지하여 읽기 쉬운 테스트 코드를 작성해야 합니다.
@Test
void test() {
int i = 10_000;
int k = 10;
TaxCalculator calculator = new TaxCalculator();
assertThat(calculator.calculate(i, k)).isEqualTo(11_000);
}
위와 같은 테스트 코드는 테스트 메서드 이름만 봐서 무엇을 테스트 하려는지 알 수 없습니다. 그리고 테스트 내에서 사용하는 i,j 변수명도 무엇을 나타내는지 알 수 없습니다. 검증 결과 값이 무엇을 나타내는지도 명확하지 않아 가독성이 떨어지는 테스트 입니다. 메서드명, 변수명을 바꾸는 것 만으로도 가독성을 향상시킬 수 있고 BDD(Behavior Driven Delveopment) 스타일로 작성하는 것도 고려해 볼 수 있을 것 같습니다.
2. 구조화가 잘 되어 있다면 이해하기 쉽다.
코드의 길이가 길거나 구조가 이해하기 어려운 코드는 누구도 손대고 싶어하지 않을 것 입니다. 왜냐면 어디서부터 어떻게 손을 대야 하는지 알 수 없기 때문입니다. 간단한 요구사항의 변경이라고 들었지만 막상 코드를 변경하려고 하면 모든 코드를 뜯어봐야 어디를 바꿔야 하는지 간신히 찾을 수 있습니다. 깊은 생각 없이 기능 구현에 급급한 코드가 보통 이렇게 됩니다. 하지만 클래스, 메서드에 대한 설계 과정을 거쳐 코드를 작성하면 코드의 양이 줄어들고 좀더 구조를 갖춘 코드가 만들 수 있습니다. 구조화된 코드는 코드를 쉽게 이해할 수 있게 만들어 일을 수월하게 할 수 있게 만들어줍니다.
구조를 잘 갖춘 코드는 테스트 코드에도 영향을 줍니다. 구조를 잘 갖춘 코드를 작성하려고 노력한 코드는 아마도 적은 양의 일을 수행하는 코드가 만들어 질 것 입니다. 그렇게 되면 테스트 해야 하는 범위를 좁힐 수 있고, 해당 코드의 동작 방식을 파악하거나 변경을 하기 위해 작은 테스트를 실행할 수 있게 됩니다. 읽기 쉽고, 찾기 쉽고, 이해하기 쉽도록 한 가지 기능에 충실한 테스트가 만들어 지게 되고, 해당 코드의 동작방식을 이해하기 쉬운 테스트 코드가 만들어 집니다. 이렇게 만들어진 테스트 코드는 아래와 같은 이점이 생깁니다.
- 현재 작업과 관련된 테스트 클래스를 찾을 수 있다.
- 그 클래스에서 적절한 테스트 메서드를 고를 수 있다.
- 그 메서드에서 사용하는 객체의 생명주기를 이해할 수 있다.
이러한 이점이 있기 때문에 쉽지 않겠지만 구조화된 코드를 만들어야 한다고 생각합니다.
3. 엉뚱한 걸 검사하는 건 좋지 않다.
코드를 분석할 때 테스트 코드가 존재한다면 코드 분석 전에 테스트를 먼저 실행해보곤 합니다. 테스트가 성공하면 테스트의 이름을 보며 어떤 테스트가 성공했는지 확인을 하고 다음 단계로 넘어갑니다. 여기서 메서드의 이름을 너무 믿어버리는 실수(?)를 합니다. 보통은 테스트의 이름을 보면 그 테스트가 검사하는 내용을 알 수 있기 때문에 테스트를 실행시키고 성공하는 결과만 보고 넘어갑니다. 하지만, 실제로는 이름과 전혀 관련이 없는 것을 검사하는 경우가 종종 있습니다. 우리는 테스트를 통해 특정 기능이 잘 실행된다는 것을 확신하지만, 실제로는 잘 동작하지 않는 것이므로 실제 기능이 동작할 때 문제가 생길 수 있습니다. 올바른 것을 검사하는 것 못지않게 올바른 것을 똑바로 검사하는 것도 중요합니다. 특히 유지보수 관점에서는 어떻게 구현했느냐가 아니라 의도한 대로 구현했느냐를 검사하는 것이 더 중요할 수도 있습니다.
4. 독립적인 테스트는 혼자서도 잘 실행된다.
테스트는 다른 요소에 영향을 받지 않고 독립적으로 잘 생행되어야 합니다. 요소라고 할 수 있는 것들을 예로 들면 아래와 같습니다.
- 시간
- 임의성
- 동시성
- 인프라
- 기존 데이터
- 영속성
- 네트워크
테스트 코드를 이러한 요소들과 격리 시켜야 합니다. 그렇지 않으면 테스트를 실행하고 관리하기가 힘들어지기 때문입니다.
테스트 하려는 코드에 외부 시스템에 환율을 조회하는 API 를 호출하는 기능이 포함되어 있다고 생각해 보겠습니다.
외부 서비스로부터 환율을 조회한 후 테스트를 성공합니다. 하지만 만약에 외부 서비스에 이상이 생겨 환율 API 호출이 실패한다면 어떻게 될까요?
외부 API 호출에 실패하니 테스트도 실패하게 됩니다. 이 테스트를 성공시키기 위해선 외부 서비스가 정상적으로 돌아오길 기도하는 수 밖에 없겠네요. 이런 상황을 외부 요소에 종속되었다고 말합니다.
이런 종속성은 '우리가 제어할 수 없다'는 특징이 있습니다. 테스트 실행 시점의 시스템 시간이나 난수 발생기의 값도 이러한 제어할 수 없는 종속성의 한 예가 될 수 있습니다. 이러한 종속성 때문에 발생하는 불규칙한 테스트 실패를 원하는 사람은 아무도 없을 것 입니다. 오히려 반대로 코드를 우리가 꽉 잡고 모든 것을 원하는 대로 제어하길 바랄 것 입니다. 이러한 종속성을 해결하기 위해선 테스트 더블(Test Double) 로 교체하거나 원하는 대로 동작하는 환경에 코드를 고립시켜야 합니다.
이러한 외부 종속성 외에도 발생할 수 있는 종속성이 있습니다. 바로 테스트 간 상호의존성 입니다. 테스트 전체를 돌릴 때는 성공했지만, 하나만 단독으로 실행하면 실패하는 경우가 있습니다. 이런 테스트는 다른 테스트가 먼저 실행되어 시스템을 원하는 상태로 변경해 놓았다고 가정합니다. 만약 이런 가정이 깨지는 순간 지옥의 디버깅이 시작될 수 있습니다.
외부 종속성 뿐만 아니라 테스트 코드 간의 종속성은 가능한 피하는게 최선이고, 아니면 작고 격리된 단위로 구분해서 복잡한 상황으로부터 테스트를 보호해야 합니다.
지금까지 좋은 테스트를 만드는 요소에 대해 정리해봤습니다. 책에는 이 요소 외에 더 나와 있지만 이 요소들 보다는 덜 중요해 보여 정리하지 않았습니다. 이들 대부분은 좋은 테스트를 만드는데 절대적인 진리라고 생각하고 따르기 보다는, 이런 요소를 참고하여 자신의 상황에 맞는 코드를 만들어 내는게 중요하다고 생각합니다.
참고 : Effective Unit Testing(한빛미디어)의 1,2 장
Keyword : TDD, BDD, 테스트 더블