최근 읽은 책인 '심플 소프트웨어'에서 테스트에 대해 언급된 내용 중 일부를 발췌하여 정리해둔다.
기존의 과학적 방법은 우주를 테스트한 후 테스트 결과를 바탕으로 자신이 세운 가설을 정교하게 다듬는 순서로 진행된다. 어떻게 보면 소프트웨어 테스트는 이와 정반대로 진행된다.
...(중략)...
소프트웨어 세계에서는 '실험(테스트)'이 가설(테스트를 통해 하려던 단언문)을 증명하지 못하면 테스트하고 있는 시스템을 수정한다. 즉, 테스트가 실패하면 테스트를 수정하지 않고 소프트웨어를 수정한다.
현실에서는 시스템의 행동을 제대로 이해하기 위해 온갖 종류의 테스트를 여러 단계에 걸쳐서 해야 하는 게 보통이다.
테스트의 목적은 시스템에 대한 지식을 전달하는 것이고 각 지식은 각기 다른 가치를 지닌다. 테스트 시 시간과 노력을 어디에 기울여야 할지 알고 싶다면 해당 정보의 가치를 정확하게 판단해야 한다.
얻고자 하는 지식이 무엇인지 정확하게 알아야 효과적이고 유용한 테스트를 만들 수 있다.
테스트는 무언가를 알기 위해 하는 것이므로 반드시 무언가를 단언해야 한다. 그리고 그 단언문(assertion-어떤 명제가 참인지 확인하기 위해 작성하는 코드. 보통 명제가 거짓이면 테스트가 실패했다고 본다)은 참인지 거짓인지 확인되어야 한다. 테스트를 통해 그 단언문의 주장이 참인지 거짓인지를 배운다.
단언문이 없으면 테스트가 아니다.
모든 테스트는 정의에 따라 범위가 정해진다. 테스트 하나에 테스트할 내용이 너무 많다고 느낀다면 그 테스트에 너무 많은 내용을 담으려 했을 가능성이 크다. 이럴 때는 테스트를 여러 개로 분리해야 한다.
테스트를 설계할 때는 테스트 대상과 테스트에 속하지 않는 부분을 정확히 구분해야 한다.
모든 테스트에는 가정이 내재되어 있다. 해당 테스트의 범위 안에서 유효한 결과를 내는 데 꼭 필요한 전제라고 보면 된다. '알 수 없음'이라는 결과가 나왔을 때 테스트가 실패했다고 해서는 안 된다. 이를 두고 실패했다고 결론을 내린다는 건 얻은 지식이 없는데 있다고 주장하는 것이다.
모든 테스트는 통과, 실패, 알 수 없음, 이 세 가지 중 적어도 한 가지 결론을 도출해야 한다.
각 테스트에는 범위와 가정이 존재할 수밖에 없기 때문에 테스트 슈트(test suite-소프트웨어가 목표한 동작을 정상적으로 수행하는지 확인하기 위한 테스트의 집합)를 설계해야 한다. 각각의 테스트는 그 테스트의 범위와 가정 내에 있는 지식만 전달한다.
전체 테스트를 조합했을 때 원하는 지식을 빠짐없이 얻을 수 있어야 한다.
E2E(End to end) 테스트는 시스템 로직을 관통하는 하나의 '경로'를 완료하는 방식으로 진행하는 테스트다. 전체 시스템을 작동시키고 사용자가 입력하는 시작 지점에서 몇 가지 동작을 실행한 후 시스템이 생산하는 결과를 확인하는 것이다. 세워둔 목표를 달성하는 과정에서 내부적으로 어떤 작업이 일어나는지와 상관없이 오로지 입력과 결과만 보면 된다. 이는 모든 테스트에서 대체로 마찬가지다. 하지만 E2E 테스트에서는 시스템으로 들어간 입력과 거기서 생산되는 결과의 양극단만 확인한다.
가능한 한 '실제'와 비슷한 시스템을 테스트하면 단언문에 관해 매우 정확한 지식을 얻을 수 있다는 생각이 E2E 테스트의 바탕이 된다. 또한 테스트 과정에서 발생하는 모든 인터랙션과 복잡성 역시 E2E 테스트가 처리한다.
E2E 테스트의 단점은 이 테스트만으로는 시스템에 대해 알고자 하는 모든 것을 알아내기 어렵다는 것이다. 복잡한 소프트웨어 시스템에서는 인터랙션하는 컴포넌트 개수와 코드 내 경로가 일으키는 조합 확산 때문에 원하는 모든 경로와 모든 단언문을 확인하는 게 어렵거나 불가능하다. 또한 시스템 내부가 조금만 바뀌어도 테스트를 많이 수정해야 하기 때문에 E2E 테스트는 유지하기도 어렵다.
E2E 테스트는 테스트가 많이 부족한 초기에 임시방편으로 쓰기 좋다. 전체 시스템을 조립한 후에 제대로 작동하는지 확인하는 용도로 써도 좋다. 테스트 슈트에서 중요한 위치를 차지하고 있긴 하지만 장기적으로 볼 때 복잡한 시스템에 대해 충분한 지식을 얻을 수 있는 테스트 방식은 아니다.
E2E 테스트로만 확인할 수 있는 방식으로 설계된 시스템은 코드 아키텍처에 광범위한 문제가 있다고 볼 수 있다.
두 개 이상의 '컴포넌트'를 한 시스템에서 '조립'한 후에 어떻게 작동하는지 보는 것이 통합 테스트다. 컴포넌트란 코드 모듈이 될 수도 있고, 시스템이 의존하는 라이브러리 혹은 데이터를 제공하는 원격 서비스가 될 수도 있다. 같은 시스템 내 다른 부분과 개념적으로 분리될 수 있는 시스템의 일부라면 무엇이든 컴포넌트라 볼 수 있다. 두 개 이상의 컴포넌트를 함께 사용할 때 정상적으로 작동하는지 명확하게 확인하는 테스트가 통합 테스트다.
E2E 테스트에서는 전체 시스템을 하나의 '블랙박스'로 여기고 테스트를 진행하는 데 비해 통합 테스트에서는 컴포넌트의 분리를 중요시한다.
통합 테스트에서는 테스트 경로가 일으키는 조합 확산 문제가 E2E 테스트처럼 심각하지 않다. 테스트 대상인 컴포넌트가 일으키는 인터랙션이 복잡해서 통합 테스트를 실행하기 어렵다면 둘 중 하나 혹은 둘 다 리팩토링해서 더 단순하게 만들어야 한다는 신호일 수 있다.
통합 테스트만으로 시스템을 테스트하는 건 적절하지 않다. 컴포넌트의 인터랙션만으로 전체 시스템을 분석하려면 시스템 작동 방식 전반에 대해 이해하기까지 엄청나게 많은 수의 인터랙션을 테스트해야 하기 때문이다.
E2E 테스트만큼은 아니지만 통합 테스트도 유지하기가 쉽지 않다. 컴포넌트에 변화가 생기면 그 컴포넌트와 인터랙션하는 모든 컴포넌트 테스트를 업데이트해야 하므로 유지 보수 부담이 있다.
하나의 컴포넌트가 정상 작동하는지 확인하는 테스트가 단위 테스트다. 동작이 명확하게 정의된 컴포넌트를 대상으로 정의된 내용이 잘 지켜지는지 확인할 때 단위 테스트를 쓰면 좋다. 컴포넌트 내부에 있는 코드의 작동 방식은 확인하지 않는다.
단위 테스트는 하나의 클래스/모듈에 있는 한 가지 함수의 한 가지 동작을 테스트한다. 하나의 클래스/모듈이 테스트 대상이라면 그 모듈에서 확인하고 싶은 모든 동작을 확인할 수 있도록 단위 테스트 세트를 만들어서 실행한다. 하지만 이는 그 시스템의 공개 API만 테스트한다는 뜻이다. 단위 테스트는 컴포넌트의 내부 구현이 아니라 동작을 테스트해야 한다.
이론적으로는 시스템에 있는 모든 컴포넌트의 동작이 문서에 잘 정의되어 있으면 각 컴포넌트가 문서에 나온 대로 동작하는지 테스트하기만 해도 시스템 전체의 동작을 테스트한 셈이 된다. 한 컴포넌트의 동작을 수정할 때는 그 컴포넌트와 관련된 테스트 세트만 업데이트하면 된다.
단위 테스트는 시스템 컴포넌트가 합리적으로 잘 분리되어 있고 각 컴포넌트의 동작을 완전히 정의할 수 있을 정도로 단순할 때 가장 큰 효과를 낸다.
이론적으로만 보자면 시스템 내 각 컴포넌트가 잘 분리되어 있고 모든 함수가 약속대로 잘 동작한다면 통합 테스트나 E2E 테스트를 굳이 하지 않아도 된다.
개별 컴포넌트 단위로 테스트를 진행하면 시스템이 바뀔 때 통합 테스트나 E2E 테스트에 비해 업데이트해야 하는 테스트의 수가 적다는 장점이 있다. 하지만 테스트 중인 컴포넌트를 고립시키기 위해 테스트를 더 복잡하게 만든다면 유지 보수해야 하는 테스트 코드가 더 추가되는 셈이고 그랬다가는 앞서 이야기한 장점이 사라져버린다.
컴포넌트와 내부 의존성의 관계가 복잡할 때도 있는데 실제 의존성을 테스트하지 않는다면 실제 동작을 테스트했다고 보기 어렵다. 이런 일은 개발자가 실제 객체에 '가짜' 객체를 동기화하지 못할 때 일어나기도 하지만 '실제' 객체의 기능을 전부 제공하는 완벽한 '가짜' 객체를 만드는 데 실패했을 때도 일어난다.
테스트에 너무 많은 '가짜' 객체를 더해야 한다는 건 테스트를 '변칙적인' 방법으로 해야 한다는 뜻이 아니라 시스템 코드 수준에서 손봐야 할 시스템 설계상의 문제가 있다는 뜻이다. 해당 컴포넌트가 너무 복잡하게 얽혀 있을 수도 있고, '어떤 컴포넌트가 어떤 컴포넌트에 의존해도 되는지' 혹은 '어떤 컴포넌트가 시스템 레이어를 구성하는지'에 대한 원칙이 제대로 정의되지 않았을 수도 있다.
테스트 대상인 컴포넌트와 시스템 내 (그 컴포넌트가 의존하는 것까지 포함해서) 다른 모든 컴포넌트가 완전히 분리되도록 테스트 코드를 직접 작성해야만 진정한 '단위 테스트'일까?
시스템이나 환경이 변하지 않는 한 테스트 결과는 변하지 않아야 한다. 시스템이 그대로인데 어제 통과한 테스트를 오늘 통과하지 못한다면 그 테스트는 신뢰할 수 없다. 그렇다면 '실패'가 진짜 실패가 아니므로 사실 유효한 테스트라고 볼 수 없다. 즉, '알 수 없음'을 지식으로 가장한 것이다. 그런 테스트를 두고 '신뢰할 수 없다'거나 '비결정론적'이라고 표현한다.
테스트에 관한 모든 게 결정론적일 필요는 없다. 시스템이 변하지 않았다면 그 테스트의 단언문은 항상 참 혹은 항상 거짓이어야 한다.
코딩하던 중 새 코드가 실제로 잘 동작하는지 확인하고자 코드를 실행해보는 것이 테스트의 아주 중요한 용도다. 테스트가 느려질수록 테스트를 이런 용도로 활용하기가 점점 더 불편해진다. 느려진 후에도 그런 용도로 테스트를 계속 활용한다면 테스트에 드는 시간이 점점 더 길어지면서 코드 작성 속도도 점점 더 느려질 것이다.
이렇게 느린 속도를 피할 수 있다면 '가짜'를 테스트해도 괜찮다. 단, 다른 모든 가짜와 마찬가지로 이렇게 가짜를 쓰면 테스트의 유효성에 어떤 영향을 미치는지, 가짜 동작을 어떻게 적절히 유지 보수할것인지를 이해하고 있어야 한다. 필요하다면 '느린' 테스트 슈트를 별도로 만드는 것도 좋은 방법이다. 이 테스트 슈트는 코드 편집 중에 쓰지 말고 코드를 버전 제어 시스템에 체크인한 후에 자동 실행되게 하거나 개발자가 코드 체크인 직전에 실행하도록 한다. 그렇게 하면 편집하는 동안에는 빠른 테스트를 개발자들이 편하게 이용하는 동시에 시간을 조금 더 들여서 실제 시스템 동작을 조금 더 꼼꼼히 점검하는 과정도 놓치지 않을 수 있다.
일반적으로 여러 테스트를 '중첩'해도 괜찮다. 빠뜨리는 영역이 생기는 것보다는 중첩되는 게 낫다. 단, '가짜'로 분리하는 방법이 유용할 때도 있다. 상황에 맞게 판단하되 앞서 언급한 '가짜' 인스턴스 설계에서 오는 단점을 최대한 줄여야 한다. 특히 테스트에 결정론과 속도, 이 두 가지 속성을 더할 때는 가짜를 쓰는 게 유용하다.
테스트를 도와주는 도구들은 실제 테스트를 거친 줄이 아니라 코드가 실행된 줄을 알려준다는 걸 기억해야 한다.
그 코드의 동작에 관한 단언문이 없다면 실제 테스트는 거치지 않은 것이다.
테스트의 전반적인 목표는 시스템에 관해 유효한 지식을 얻는 것이다.