이 글에서는 서피스 테스트(surface testing) 라는 소프트웨어 테스트 스타일을 소개한다. 이 스타일은 내게 매우 잘 맞았고, 당신에게도 도움이 될 수 있다.
어떤 소프트웨어든 사용자에게 노출되는 특정 부분이 있다. 이 노출된 부분을 종종 “인터페이스(interface)”라고 부르지만, “인터페이스”라는 용어는 보통 그래픽 사용자 인터페이스(GUI)나 OOP 스타일의 인터페이스와 뒤섞여 쓰이기 때문에, 여기서는 “서피스(surface, 표면)”라는 용어를 쓰겠다. 서피스 테스트의 목표는 소프트웨어가 노출하는 서피스만을 통해 그 소프트웨어를 철저하게 테스트하는 것 이다.
예시를 보면 더 잘 이해할 수 있다. HTTP API를 구현하고 있고, 이에 대한 테스트를 작성하려 한다고 하자. 서피스 테스트를 사용하기로 결정했다면, API 테스트는 전적으로 HTTP 호출을 통해 수행한다. 예를 들어, 위젯을 GET 하는 엔드포인트와 위젯을 POST 하는 엔드포인트 두 개를 구현했다면, 서피스 테스트는 먼저 POST 호출을 통해 위젯을 생성하고, 이어서 GET 호출로 방금 생성한 위젯을 가져와 본다.
서피스 테스트는 다른 유형의 테스트와 극명하게 대조된다.
- 단위 테스트(unit testing): 이 스타일은 노출 여부와 상관없이 가능한 한 작은 하위 컴포넌트를 골라내어, 그것들을 고립된 상태에서 테스트한다. 이를 위해 보통 각 컴포넌트의 의존성을 모킹(mocking)해야 한다. 서피스 테스트는 테스트 대상 컴포넌트의 크기에 신경 쓰지 않으며, 고립된 상태로 테스트할 필요도 없다. 그리고 서피스 테스트에서는 의존성 모킹이 거의 필요하지 않다.
- E2E(end-to-end) 테스트: 이 스타일은 그래픽 사용자 인터페이스까지 포함하여 전체 시스템을 하나로 묶어 테스트하려 한다. 앱이 API를 노출하고 있더라도, E2E 테스트는 사용자 인터페이스를 대상으로 실행되는 경우가 많다. 반면 서피스 테스트는 그래픽 사용자 인터페이스와 API가 각각 하나의 서피스로 노출되어 있으므로, 이 둘을 분리해서 테스트해야 한다고 본다.
- 통합 테스트(integration testing): 이 스타일은 서로 다른 시스템들 사이의 상호작용만을 테스트하여, 각 시스템이 계약한 API를 잘 지키는지 확인하려 한다. 서피스 테스트는 상호작용 자체를 직접 테스트하지 않는다. 대신, 각 시스템이 노출하는 서피스를 통해 간접적으로 상호작용을 검증한다. 예를 들어, 시스템 A의 서피스 X가 시스템 B에 대한 호출에 의존하고 있다면, 서피스 X를 테스트하여 만족스러운 결과를 얻는 것만으로도 시스템 A와 B 사이의 상호작용이 함께 테스트된 셈이 된다.
서피스 테스트는 전통적인 테스트 피라미드를 휴지통에 던져 버리라고 제안한다. 테스트 피라미드는 대부분의 테스트를 단위 테스트로, 일부는 통합 테스트로, 극소수만 E2E 테스트로 두라고 조언한다. 이 조언을 따르면 결과는 대략 다음과 같을 것이다.
- 엄청나게 많은 단위 테스트가 생기고, 이들 대부분은 정교한 모킹을 필요로 한다(모킹은 보통 취약해서 계속 유지보수가 필요하다). 단위 테스트는 테스트 스위트가 실행한 코드의 라인 비율인 커버리지(coverage) 에 의존하는데, 이는 단지 코드 라인을 얼마나 돌렸는지를 말해줄 뿐, 모든 코드를 명확한 단언(assertion)과 함께 포괄적으로 검증하는지 여부를 대신해 주지는 못한다. 더 중요한 점은, 단위 테스트는 결코 시스템 전체가 제대로 작동하는지를 보장해 주지 못한다는 것이다.
- 실제 시스템 간 상호작용을 테스트하긴 하지만, 여전히 각 시스템이 전체적으로 어떻게 동작하는지는 보장하지 못하는 통합 테스트가 조금 있다.
- 시스템 전체를 검증하는 극소수의 E2E 테스트가 있다. 이 테스트들이 가장 가치 있는 이유는, 바로 시스템 전체를 검증해 주기 때문이다. 그러나 테스트 피라미드는 이 테스트들이 느리다는 이유로 그 사용을 최소화하라고 한다. 이 말은 부분적으로만 맞다. E2E 테스트에 대한 더 좋은 비판은, 이들이 실제 문제를 잘 찾아내면서도 정작 어디가 문제인지 알려 주는 데에는 그다지 좋지 않다는 점이다. E2E 테스트는 (프론트엔드, 백엔드 등) 각 레벨의 서피스를 각각 테스트하지 않고, 가장 윗단의 서피스만을 테스트하기 때문이다.
요약하자면, 테스트 피라미드를 따른다면 다음과 같은 테스트 스위트를 갖게 될 것이다.
- 방대하며, 작성 비용이 많이 든다.
- 취약하고, 유지보수 비용이 크다.
- 테스트 커버리지라는, 다소 오해를 부를 수 있는 지표로 측정된다.
- 시스템 전체를 거의 테스트하지 못한다.
서피스 테스트는 훨씬 더 직관적이다.
- 목록 작성(List): 시스템이 외부에 노출하는 부분들을 목록으로 만든다. 사용자 인터페이스, API 엔드포인트, 라이브러리를 작성 중이라면 이를 사용하는 메인 함수들 등이 여기에 해당한다. 이것이 바로 서피스다.
- 실행 환경 준비(Run): 시스템이 동작하는데 필요한 모든 것(DB, 외부 서비스 등)에 연결된, 실행 가능 상태의 시스템 버전을 준비한다. 로컬이든 원격이든 상관 없다. 중요한 것은 진짜와 동일한 환경 이어야 한다는 점이다. 라이브러리를 테스트 중이라면, 실제 사용하는 것과 똑같이 실행할 수만 있으면 된다.
- 테스트(Test): 고카페인 상태의 인간 테스터가 할 법한 방식으로, 시스템의 각 서피스를 테스트한다. 각 서피스마다, 잘못된(invalid) 데이터를 보내고, 이때 서피스가 그냥 진행하거나 크래시 나지 않고 적절한 에러를 반환하는지 확인한다. 그다음 올바른(valid) 케이스로 넘어가, 서피스가 요청을 제대로 처리하는지 확인한다.
- 단언(Assert): 각 테스트가 얻은 결과에 대해 엄격한 단언을 둔다. 예를 들어, 읽기(read) 작업이 200 코드를 반환했는지만 확인하는 것은 충분하지 않다. 실제로 반환된 응답 본문(body)이 기대한 내용과 최대한 세밀한 수준까지 정확히 일치하는지 확인해야 한다.
- 연결(Chain): 테스트를 논리적인 순서로 연결한다. CRUD 시스템을 만든다면, 생성(create) 테스트부터 시작해 읽기(read), 수정(update), 삭제(delete) 순으로 진행할 수 있다. 보통 수정이나 삭제가 성공했는지 확인하려면, 다시 읽기 연산을 수행해서 해당 수정이나 삭제가 실제로 반영되었는지 확인해야 한다. 이는 전혀 문제가 없을 뿐 아니라, 오히려 정석적인 방식 이다.
- 첫 에러 발생 시 중단(On first error, stop): 단 하나의 케이스라도 실패했다면 그 이후 테스트는 실행하지 말라. 코드든 테스트든, 그 에러를 다시는 발생하지 않도록 고치는 데 집중하라. 부분 성공이라는 것은 없다. 테스트 스위트는 전부 통과하거나 전부 실패하거나 둘 중 하나다. 이것이 바로 자동 활성화(auto-activation)의 구현이다.
테스트의 단언을 생각할 때, 이를 시스템에 대한 검증(validation)으로 여길 수 있다. 시스템이 사용자로부터 들어오는 입력을 검증해야 하듯, 테스트는 시스템이 내놓는 출력을 검증해야 한다. 시스템의 검증(validation)과 테스트의 단언(assertion)은 같은 동전의 양면이다.
그렇다면 서피스 테스트의 단점은 무엇일까?
- 시스템으로부터 정확히 무엇을 기대하는지 스스로 명확히 생각해야 한다.
- 뒷문(backdoor) 없이 시스템을 테스트해야 하며, 이는 단순히 DB나 모크를 확인하는 것보다 더 많은 단계를 요구할 수 있다.
- 테스트를 고립된 개별 단위가 아니라, 논리적으로 일관된 하나의 시퀀스로 생각해야 하고, 이는 테스트를 작성하기 위해 요구되는 이해 수준의 기준을 크게 높인다.
이 세 가지 “단점”은, 내게는 오히려 미덕이다. 운동이나 새로운 언어를 배우는 것처럼 어렵지만 가치 있는 다른 일들처럼, 서피스 테스트도 테스트 작업을 더 어렵게 만든다. 이 노력은 헛되지 않는다. 시스템 자체와 그에 대한 당신의 이해를 모두 개선시켜 주기 때문이다. 이는 모킹을 동반한 단위 테스트를 작성하는 데 드는 노력과는 극명하게 대조된다. 단위 테스트와 그 모크들은 보통 시스템의 고립된 일부분만 개선할 뿐, 전체 시스템에 대한 이해를 높이는 데에는 거의 도움이 되지 않는다.
서피스 테스트에는 다른 테스트 유형들에 비해 또 하나의 장점이 있다. 시스템을 갈아엎어 다시 작성하되, 기존 계약(contracts)은 그대로 유지하고자 할 때, 이전 시스템에서 사용하던 테스트를 새로운 시스템에 그대로 적용하면 된다. 서피스 테스트는 계약만큼이나 이식성이 좋다.
아직 다루지 않은 중요한 세 가지 포인트가 더 있다.
- 비(非) 서피스 테스트(Non-surface testing): 때때로, 서피스가 아닌 시스템 내부의 일부에 대해 직접 단언을 해야 할 때가 있다. 이런 경우는 흔치 않으며, 반드시 그 필요가 정당화되어야 한다. 예를 들어, 사용자를 영구 삭제하는 기능이 있다면, 삭제 이후 해당 사용자가 다시 로그인할 수 없는지(서피스를 통해) 확인하는 것뿐만 아니라, 테스트 스위트가 DB에 직접 접속해 해당 사용자 레코드가 실제로 사라졌는지 확인하는 것이 좋다. 테스트 스위트가 DB나 외부 서비스에 직접 접근해야 한다면, 그에 합당한 이유가 있는지 반드시 점검해야 한다.
- 모킹(Mock): 어떤 서피스는 요청을 처리하는 데 10초 이상 걸릴 수 있다. 이미지나 비디오 처리의 경우가 그럴 수 있다. 이런 상황에서는, 그 느리고 비싼 연산을 수행하는 하위 시스템을 우회하거나, 그 하위 시스템의 결과를 모킹하는 방법을 마련하는 것이 합리적이다. 테스트는 여전히 “진짜” 시스템을 상대로도 돌릴 수 있어야 하지만, 테스트 스위트를 돌릴 때마다 반드시 그렇게 할 필요는 없다. 다시 말하지만, 이는 예외이지 규칙이 아니다.
- 백그라운드 작업에 대한 반복 타임아웃 패턴(Repeated timeout pattern for assertions on background operations): 백그라운드에서 동작하는 작업이 있고, 테스트 스위트가 그 작업이 끝나기를 기다려야 하는 경우, 고정된 타임아웃을 사용하지 말라. 보통 느린 작업은 소요 시간이 좁은 범위 안에 깔끔하게 들어오지 않는다. 테스트에 고정 타임아웃을 쓰면, 어떤 때는 너무 오래 기다리게 되고, 또 어떤 때는 충분히 기다리지 못해 테스트가 실패한다. 느리고 요동치는 테스트를 좋아하는 사람은 없다. 대신 다음과 같이 하라: n 밀리초마다, 최대 m초 동안 어떤 호출을 재시도하는 retry 함수를 작성하라. 예를 들어, 백그라운드에서 특정 업로드가 진행되고, 업로드 완료 후 어떤 엔드포인트를 호출해 그 사실을 확인하고 싶다면, 이 retry 함수를 이용해 그 엔드포인트를 100밀리초마다 최대 30초 동안 호출해 보라. 그러면 업로드 진행 상황을 들여다보는 뒷문을 추가하지 않고도, 필요한 시간보다 조금 더 오래 기다리는 선에서 항상 테스트를 마칠 수 있다. 프로그래밍 초보라 하더라도, 이 retry 함수를 직접 구현해 볼 것을 권한다. 이 연습에서 많은 것을 배울 것이다.
끝으로, 모든 종류의 테스트에 공통으로 적용되는 한 가지 포인트가 있다. 시스템에서 버그를 하나 발견했다면, 사실 버그를 두 개 발견한 것이다. 첫 번째는 그 버그 자체이고, 두 번째는 그 버그를 잡아 냈어야 할 테스트의 부재다. 버그를 수정할 때에는, 그 버그를 야기한 정확한 조건을 검사하는 테스트를 반드시 함께 추가하라.
여기까지 읽었다면, 기꺼이 당신의 제안과 이의를 메일로 받고 싶다. 읽어 줘서 고맙다!