[12-17 하] 12. 자동 테스트 & 13. 테스트 피라미드
소프트웨어 공학에서 말하는 테스트는 소프트웨어의 품질과 기능을 확인하고, 버그를 찾아내는 과정을 말합니다. 크게 2가지로 분류하는데, 수동 테스트와 자동 테스트가 있습니다.
수동 테스트는 테스트 담당자가 소프트웨어를 직접 실행해보고 각각의 기능을 평가하여 구현된 기능이 요구사항에 부합하는지 검증하는 과정을 말합니다. 사용자 관점에서 소프트웨어를 다양한 시나리오를 토대로 실행하여, 검증 과정에서 사용자 경험을 직접 평가할 수 있죠.
자동 테스트는 테스트 스크립트, 도구를 사용해 자동으로 테스트하는 과정을 말합니다.
12. 자동 테스트
인수 테스트란 무엇일까요? 시스템이 비즈니스 요구사항을 만족해서 소유권을 넘기기 전에 수행하는 테스트 단계를 뜻합니다. 시스템을 인수하기 전에 시스템이 비즈니스 요구사항과 일치하는지 마지막으로 검증하는 테스트 단계라는 뜻이죠. 인수 테스트는 최종 단계에 하는 테스트이기 때문에 시스템을 고객에게 전달하기 전 '사용자 관점'에서 전체 시스템을 검증할 수 있는 좋은 기회입니다.
수동 테스트의 가장 큰 문제점은 테스트 자체가 소모적인 작업이라는 것입니다. 한 번 만들어진 수동 테스트는 누적됩니다. 시스템이 성장하면서 수동테스트는 절대 줄어들지 않고 늘어나기만 한다는 뜻이죠. 이를 대안으로, 자동 테스트로서 인수 테스트의 일부를 대체하는 것입니다. 자동 테스트를 실행하면 테스트의 성공/실패 여부를 확인할 수 있고, 테스트를 거친 코드의 비율을 나타내는 커버리지도 확인할 수 있습니다. 자동 테스트는 수동 테스트와 다르게 개발자에게 좋은 의미로 누적됩니다. 개발자의 부담을 오히려 줄여주는 방향으로 누적되죠.
12.1 Regression
소프트웨어 개발 분야에서 Regression이란 '시스템에서 정상적으로 제공하던 기능이 어떤 배포 시점을 기점으로 제대로 동작하지 않게 되는 상황'을 지칭합니다. 기능 개발이 제대로 돼 있지 않던 과거로 '되돌아갔다'라는 의미입니다. 버그의 일종이며, Regression bug라고 부르는 것이 좀 더 정확합니다.
공통 코드의 수정은 조심스러운데, 수정된 공통 코드가 처음 만들어진 개발 의도를 벗어나면 의존하는 클래스들의 기대를 배신할 수 있기 때문입니다. 이를 해결하는 방안은 테스트를 꼼꼼히 하면 됩니다. 테스트를 이용해 공통 코드가 초기 개발 의도를 지키고 있는지 능동적으로 감시하면 되죠. 회귀 버그를 탐지하고자 만들어진 테스트를 가리켜 회귀 테스트라고 합니다.
회귀 테스트도 중요하지만, 가급적 자동으로 이뤄져야 합니다. 직접 하는 것은 비효율적이고, 시스템이 쌓아온 기능을 사람이 테스트 하려면 발생하는 비용이 크고 부정확할 수 있죠.
코드 커버리지 100% -> 이러한 목적을 달성하려면 테스트의 코드 커버리지가 100%는 돼야 가능한 것 아닌가? 라고 생각할 수 있죠. 반은 맞고 반은 틀린 이야기입니다. 회귀 테스트에서 중요한 것은 '개발자가 코드 변경 이후에 느끼는 안정감이 어떠냐'이기 때문이죠. 어느 정도의 안정감을 주는 것만으로도 회귀 테스트는 가치가 있습니다. 중요한 기능, 중요하지 않은 코드를 나눠 일부 코드에만 회귀 테스트를 적용해도 되고, 시스템적으로 회귀 버그를 방지할 수 있는 것과 없는 것은 천지 차이죠.
12.2 의도
우리는 타인이 작성한 코드에서 어떻게 의도를 파악할 수 있을까요? '코드를 읽어보는 것만으로는 코드 작성자의 모든 의도를 파악하지 못할 수 있습니다'라는 이야기를 하고 싶고, 코드 작성자와 코드를 읽는 사람의 입장과 생각이 다르기 때문에 본래의 의도가 제대로 전달되지 않을 수 있습니다. 다음과 같은 악순환이 일어날 수 있죠
악순환 A
1) 불필요한 코드를 실수로 만듭니다.
2) 2년 뒤 코드를 확인해보니 불필요한 코드인 것으로 보여 이를 지우고 싶습니다.
3) 회귀 버그를 만들까봐 두려운 나머지 일단 그대로 둡니다.
악순환 B
1) 반드시 필요한 코드가 있습니다.
2) 2년 뒤 코드를 확인해보니 불필요한 코드인 것으로 보여 이를 지우고 싶습니다.
3) 코드를 지우고 회귀 버그가 만들어집니다.
테스트는 이 같은 상황을 예방하는 데 너무 유용하죠. 테스트는 개발자의 의도를 드러내는 최고의 방법이기 때문입니다. 테스트가 의도를 드러내는 방법이라는 사실이 이해된다면 또 다른 생각으로 이어질 수 있습니다. 코드를 작성하는 개발자는 코드에 요구되는 의도와 요구사항을 이미 알고 있고, 이를 미리 작성하도록 하면 어떨까요? 의도를 드러내는 테스트를 먼저 작성하고, 거기에 부합하는 내부 구현을 작성하게끔 하는 것입니다. 이가 바로 TDD(Test-Driven Development: 테스트 주도 개발)입니다.
테스트를 이용하면 객체에 할당된 책임과 의도를 기술할 수 있습니다. 어떤 객체의 책임과 의도를 테스트로 작성하는 과정에서 객체에 할당된 책임을 다시 볼 수 있죠. 테스트를 의도를 드러내는 수단으로 바라볼 수 있고, 더 나아가 '책임'을 드러내는 수단으로 바라볼 수 있게 된다면 OOP에서 말하는 책임 주도 설계에 한층 더 가까워질 수 있겠네요.
테스트가 의도와 책임이 될 수 있다는 것까지 이해했다면, 테스트는 책임에 대한 '계약'이라는 말도 이해할 수 있을 것입니다. 개발자는 의도를 드러내기 위해 메서드가 책임질 부분을 모두 테스트로 작성합니다. 이는 똑같은 방식으로 매번 검증하죠. 이는 마치 계약서를 구성하는 각종 조항과 같습니다. 테스트는 책임에 대한 계약입니다.
그렇다면 더 나아가 테스트가 '문서'처럼 사용할 수 있다는 이야기도 이해할 수 있을 것입니다. JavaDoc이 대표적인 예죠. 테스트는 그 자체로 문서가 될 수 있고, 개발자의 의도를 보여주는 수단이며, 계약의 내용을 가장 구체적이고 상세히 설명할 수 있는 수단입니다. 누구나 클래스에 할당된 책임이 무엇인지 테스트를 통해 확인할 수 있습니다.
12.3 레거시 코드
일반적으로 소프트웨어 공학에서 레거시 코드는 오래된 소프트웨어 시스템에 존재하는 코드를 가리킵니다. 현재의 기술 트렌드나 모범 사례와 동떨어진 코드를 지칭하는 데 사용되기도 합니다. 레거시(유산)라는 뜻에 어떤 코드를 레거시 코드라고 부르려면 얼마나 오래돼야 할까요? 기준은 누가 정하나요? '현재의 기술 트렌드를 따르지 못하는 코드다'라는 설명도 모호하죠.
마이클 페더스는 레거시 코드를 가리켜 테스트 루틴이 없는 코드라고 말합니다. 즉, 그의 정의에 따르면 하루 전에 작성한 코드일지라도 테스트가 없다면 레거시 코드로 분류될 수 있는 것입니다. 코드가 오래돼서 예상치 못한 오류를 탐지할 수단이 없어서 유지보수하기 어려운 것입니다. 테스트 코드는 이때 안전망 역할을 합니다. 테스트트 코드가 시간이 지나도 레거시로 있지 않을 수 있게 도와줍니다.
마이클 C. 페더스 - 내게 레거시 코드란 단순히 테스트 루틴이 없는 코드다.
마틴 파울러 - 리팩터링하기 전에 제대로 된 테스트부터 마련했다. 테스트는 반드시 자가진단하도록 만든다.
<<리팩터링>>의 저자 마틴 파울러는 진정한 의미의 리팩터링이란 테스트가 반드시 동반돼야 한다고 말합니다. 코드를 변경한 결과로 입력에 따른 출력이 달라진다면 기능을 변경한 것이지 리팩터링이 아니라고 말합니다.
13. 테스트 피라미드
테스트는 목적, 범위, 대상에 따라 여러 종류로 분류될 수 있습니다. 테스트를 분류하는 가장 일반적이고 대중적인 분류 체계는 '테스트 피라미드'라고 불리는 3단 분류 체계입니다. 테스트를 단위, 통합, E2E로 분류합니다. 테스트 피라미드의 y축은 테스트가 실제 사용자의 사용 사례와 얼마나 가까운지를 나타냅니다. 그래서 테스트 피라미드의 위쪽에 위치할수록 아래에 있는 테스트보다 사용자의 실제 환경에 가까운 테스트라고 볼 수 있죠.
단위 테스트 - 소프트웨어를 구성하는 가장 작은 단위를 검증하는 테스트를 의미합니다. 여기서 말하는 'Unit'이란 함수, 메서드, 클래스 같은 개별적이고 작은 코드 조각들을 지칭합니다. 객체나 컴포넌트에 할당된 작은 책임 하나가 예상대로 동작하는지 확인.
통합 테스트 - 여러 컴포넌트나 객체가 협력하는 상황을 검증하는 테스트를 말합니다. 독립적으로 만들었던 객체들이 상호작용하면서 생길 수 있는 상황을 검증합니다. 객체지향 관점에서 본다면 객체들의 협력이 제대로 이뤄지는지 평가하는 단계입니다. 애플리케이션 서비스 관점에서 본다면 비즈니스 프로세스의 흐름을 검사하는 테스트라 볼 수 있습니다.
E2E 테스트 - 실제 사용자 시나리오에서 시스템이 어떻게 동작하는지 검증하는 테스트를 말합니다. 백엔드 개발자 입장에서 E2E 테스트는 종종 API 테스트라고 불리기도 하죠. 백엔드 서버를 실행하고, 해당 서버에 필요한 하위 컴포넌트를 모두 구동한 뒤 API를 호출하는 방식으로 테스트를 작성하기 때문입니다. 개발자는 E2E 테스트를 통해 하위 컴포넌트와 레이어 사이의 상호작용을 확인할 수 있습니다. 애플리케이션을 위한 하위 시스템이 모두 통합됐을 때 생길 수 있는 문제를 찾을 수 있습니다.
이상적으로 각 테스트의 비율은 순차적으로 80%, 15%, 5% 정도가 좋다고 합니다. 테스트 체계를 만들기 위해서는 단위 테스트를 먼저 만들고, 이를 기반으로 통합, E2E 테스트를 만들어야 좋습니다.
가장 중요한 것은 단위 테스트입니다. 대부분의 개발자는 API 테스트부터 먼저 만들려는 경향이 있습니다. 잘못된 접근은 아니지만, 이런 접근은 복잡한 시스템의 동작을 지나치게 단순화해서 검증하려는 것이기 때문에 문제가 됩니다. API 테스트만으로 모든 테스트를 대변하게 만드는 것은 지나치게 비용이 많이 들고 불안정합니다. 통합 테스트나 단위 테스트가 뒷받침돼야 합니다. 어쩌면 비결정적 테스트(어떤 때는 성공, 어떤 때는 실패)가 만들어질 수 있습니다. 테스트 안티패턴 중 하나이고, 아무런 변경 사항도 없는데, 테스트를 실행할 때마다 다른 결과를 보여준다면 신뢰할 수 없기 때문이죠.
13.1 구글의 테스트 피라미드
구글의 테스트 피라미드 모델에서는 대형, 중형, 소형 테스트라는 용어를 사용합니다. 테스트의 크기에 따라 테스트를 분류하는 것인데, 여기서 말하는 테스트의 크기란 테스트를 실행하는 데 사용되는 리소스의 크기를 뜻합니다.
1) 소형 테스트 - 단일 서버, 단일 프로세스, 단일 스레드에서 동작하며, 디스크 I/O, 블로킹 호출이 없는 테스트를 의미합니다.
2) 중형 테스트 - 단일 서버에서 동작하되 멀티 프로세스, 멀티 스레드를 이용할 수 있는 테스트를 의미합니다.
3) 대형 테스트 - 멀티 서버에서 동작하는 테스트를 의미합니다.
13.2 테스트 분류 기준
새로운 테스트 분류 체계에 사용할 테스트 분류 기준이 무엇이 되면 좋을까요? 구글의 정답을 아래와 같죠.
1) 테스트가 결정적인가?
2) 테스트의 속도가 빠른가?
바로 테스트의 결정성과 속도입니다. 왜냐하면 이 두 조건을 만족하는 테스트가 일반적으로 좋은 테스트라고 불리기 때문이죠. 결정적이라는 말은 '일관된'이라는 표현으로 이해해도 좋습니다. 테스트는 일관돼야 한다는 말과 같고, 같은 코드를 대상으로 실행하는 테스트는 항상 같은 응답을 해야 한다는 의미입니다.
인메모리 관계형 데이터베이스 H2 - H2는 메모리상에서 데이터를 관리하는 인메모리 관계형 데이터베이스입니다. 인메모리 데이터베이스란 주로 메모리에 데이터를 저장하고 관리한다는 의미입니다. 때문에 처리 속도가 빠르고, 프로그램도 경량화돼 있습니다. 대신 H2의 데이터 저장소가 메모리라는 점 때문에 휘발될 위험성도 있습니다. H2는 프로덕션 단계에서 사용할 만큼은 아닙니다. 하지만 스프링, JPA 환경에서 테스트에 사용하기 적합하죠. 빠르게 동작, 테스트에 임베디드 돼서 검증을 독립적으로 수행할 수 있기 때문입니다. H2를 내장한 테스트는 별도의 데이터베이스 서버를 두지 않아도 테스트를 수행할 수 있습니다.
H2를 연동한 테스트는 비결정적으로 동작할 가능성이 있는 테스트입니다. 테스트가 현재 개발중인 코드베이스에만 영향을 받는 것이 아니기 때문입니다. H2 프로세스의 상태, 프로세스 간 통신 상태에도 영향을 받습니다. 여러 테스트가 동시에 단일 H2 서버에 접속해 테스트를 진행할 경우 데이터가 섞이고, 예상하지 못한 방향으로 실행될 수 있습니다.
결정적인 테스트는 버그 상황을 재현하기 쉽습니다. 버그가 발생했을 때 원인이 되는 입력이 무엇인지 바로 확인이 가능하기 때문이죠. 데스크톱 PC 성능마다 다르겠지만, H2를 이용한 테스트는 H2를 이용하지 않는 테스트보다 몇 배 느린 경향이 있습니다. 데이터베이스를 연동한 테스트는 그렇지 않은 일반 테스트에 비해 상대적으로 많이 느린 편입니다.
스프링 컨테이너가 컨테이너를 초기화하는 시간, H2라는 데이터베이스가 구동되거나 종료되는 데 걸리는 시간 때문입니다. 테스트는 어떨 때 비결정적으로 변하고, 속도가 느려질까요?
비결정적 테스트가 만들어지는 이유
1) 테스트가 병렬 처리를 사용할 경우
2) 테스트가 디스크 I/O를 사용할 경우
3) 테스트가 다른 프로세스와 통신할 경우
4) 테스트가 외부 서버와 통신할 경우
속도가 느린 테스트가 만들어지는 이유
1) 테스트가 블로킹 호출을 사용할 경우
2) 테스트가 디스크 I/O를 사용할 경우
3) 테스트가 다른 프로세스와 통신할 경우
4) 테스트가 외부 서버와 통신할 경우
구글이 테스트 분류를 위해 리소스를 기준으로 삼은 것은 단순히 '리소스를 적게 투입한 테스트일수록 좋은 테스트라서'가 아닙니다. 이 기준의 배경에는 좋은 테스트는 '결정적이어야 한다'. '빨라야 한다' 같은 특징이 있기 때문이죠.
소형 테스트의 표면적인 목표는 테스트를 단일 서버, 프로세스 등 만드는 것이라 볼 수 있지만 실질적인 목표는 테스트를 결정적이고 빠른 테스트로 만드는 것에 볼 수 있습니다. 특히 결정적이라는 장점 덕분에 소형 테스트는 또 다른 장점이 있습니다. 소형 테스트는 몇 개가 이쓴 모든 테스트를 완전히 병렬로 실행할 수 있습니다. 가뜩이나 빠른 테스트를 묶어서 실행할 수 있기 때문에, 소형 테스트의 실행 속도는 빨라지죠.
의미론적으로 단위 테스트와 거의 유사합니다. 테스트 분류 중 가장 좋은 유형의 테스트이며, 시스템에 존재하는 대부분의 테스트는 소형 테스트로 작성되는 것이 좋죠. 하지만 다른 2가지 테스트도 필요합니다. 예로, 시스템이 JPA와 연동된 상태에서 데이터를 실제로 잘 가져올 수 있는지 테스트하고 싶을 수 있습니다. 그래서 아시다시피 이럴 때 H2 같은 설루션을 사용합니다.
하지만 테스트에 H2 사용한다는 것은 H2를 위한 별도의 프로세스를 실행하겠단 의미입니다. 이는 그래서 소형 테스트가 아닙니다. 그러므로 H2를 이용한 테스트는 중형 테스트입니다. 더불어 결과도 항상 같은 것이라고 보장할 수 없죠. 멀티 스레드, 프로세스 환경에서 테스트가 어떻게 상호작용하고 동작할지 알 수 없기 때문입니다.
중형 테스트는 통합 테스트에 대응되는 개념이라고 볼 수 있습니다. 하지만 하나의 제약이 남아있는데, 단일 서버에서 동작해야 한다는 점입니다. 그러므로 중형 테스트에서는 네트워크 호출이 불가합니다. 대형 테스트부터는 단일 서버 제약이 사라지고, 테스트하는 데 여러 서버를 띄워야 한다면 이는 대형 테스트에 속합니다.
예로 개발자는 'API 서버가 실제로 머신에서 구동했을 때 제대로 동작하는가?"를 테스트하고 싶을 수 있습니다. 머신에 API 서버를 실제로 구동하고 Postman 같은 도구를 이용해 네트워크 통신을 통해 테스트해야 합니다. 이렇게 실행하는 테스트를 대형 테스트라고 부릅니다. 대형 테스트는 결정적이지 않고, 느린 테스트가 되겠지만 전체적인 시스템의 안정적인 동작을 확인하려면 필요합니다. 대형 테스트는 전통적인 테스트 피라미드에서 E2E 테스트에 해당하는 경우입니다.
13.3 소형 테스트의 중요성
기억해둘 만한 테스트 관련 인사이트
1) 시스템에는 소형 테스트가 많아야 한다.
2) 중형 테스트나 대형 테스트가 소형 테스트보다 많아지는 것은 바람직하지 않은 현상입니다.
3) 소형 테스트는 단일 스레드, 단일 프로세스, 단일 서버에서 실행되며, 디스크 I/O, 블로킹 호출이 없는 테스트입니다.
4) H2를 이용한 테스트는 중형 테스트입니다.
소형 테스트가 중요합니다. 앞에서 말한 결정적, 속도 외에 한 가지 더 장점이 있습니다. '트랜잭션 스크립트'에서 소개했던 트랜잭션 스크립트 같은 코드가 나올 확률이 줄어듭니다. 애플리케이션 서비스는 리포지터리와 통신하는 경우가 많은데, 애플리케이션 서비스의 테스트는 중형 테스트가 될 확률이 높다는 뜻입니다. 이를 실행하는 가장 효율적인 간단한 방법은 트랜잭션 스크립트가 갖고 있던 비즈니스 로직을 도메인으로 옮기는 것입니다. 비즈니스 로직을 도메인으로 옮기고, 도메인을 테스트하는 것만으로도 일이 간단해집니다.
@SpringBootTest 같은 스프링 애너테이션의 도움 없이 객체를 실제로 인스턴스화하고 메서드를 실행하기만 하면 되니까요. 그래서 능동적인 도메인을 만들수록 테스트가 쉬워지고 소형 테스트에 가까워집니다. 소형 테스트에 집중하면 도메인에 비즈니스 로직을 넣게 됩니다.
마지막으로 소, 중, 대형 테스트는 어떤 컴포넌트를 테스트하느냐에 따라 결정되는 것이 아니라는 점입니다. 예로, 저장소와 통신해야 하는 애플리케이션 서비스는 무조건 중형 테스트가 될 수밖에 없는 것처럼 보이지만 그렇지 않아요. 서비스 컴포넌트의 테스트도 소형 테스트로 만들 수 있습니다. 컨트롤러 컴포넌트의 테스트도 소형 테스트로 만들 수 있습니다. 소, 중, 대형 어느 부분을 테스트하느냐보다 어떻게 테스트하느냐에 따라 결정된다는 것이 중요합니다. 어떻게 이를 바꿀 수 있을까요? 이 주제에 대해서는 다음장에 설명할 '테스트 대역'이라는 것을 사용하면 해결할 수 있습니다.