https://martinfowler.com/articles/nonDeterminism.html
위 글의 내용이 길어서 짧게 요약한다. 그러니 아래 내용은 원 저자의 경험이다.
비결정적 테스트란?
말 그대로 테스트 대상을 수정하지 않았는데도 테스트할 때마다 통과할 수도 실패할 수도 있는 테스트를 말한다.
비결정적 테스트는 왜 문제가 되는가?
비결정적 테스트가 있으면 테스트가 실패했을 때, 이게 코드를 잘못 수정해서 발생한 것인지 코드는 정상인데 테스트가 실패한 것인지 알 수 없다. 이렇게 되면 테스트를 신뢰할 수 없다.
만약 비결정적 테스트라는 걸 발견했다면?
격리하라. 메인 테스트 파이프라인에서 해당 테스트를 제외해야 한다.
비결정적 테스트의 원인을 파악하는 건 어려운 작업이다. 그래서 매번 발생할 때마다 테스트를 고치려고 당장 달려드는 건 원래 하던 일에 차질을 줄 수 있다. 그래서 이런 테스트의 원인을 당장 파악하기 어려우면 수정하는 걸 미뤄도 된다. 단 규칙을 정하자. 몇 개 이상 쌓이거나 며칠 이상이 지나면 더 이상 다른 할 일을 받지 말고 테스트를 수정하는 걸 권장한다. 개수와 타임아웃은 본인이 속한 팀의 재량이지만 테스트를 건강하게 유지할 수 있는 정도로 정하자.
비결정적 테스트의 원인들
테스트 환경을 분리하지 않음
예로 들어 DB에서 같은 테이블에 대해 여러 개의 테스트를 실행한다고 하자. 만약 앞의 테스트가 남긴 찌꺼기 데이터가 DB에 남아있다면 뒤의 테스트가 실패할 수도 있다. 이렇게 테스트마다 환경이 분리되지 않아서 테스트가 정확하지 않을 수 있다.
위에선 DB를 예로 들었지만 인 메모리에서도 일어날 수 있다. 예로 들어 ‘현재 로그인 유저’ 같은 싱글톤 객체가 대표적이다.
보통 이런 경우의 가장 권장되는 해결책은 매 테스트마다 환경을 초기화 하는 것이다. 하지만 테스트 환경을 만드는 비용이 크거나 시간이 오래 걸리면, 조금 위험하지만 ‘수정되지 않는 원본 환경’을 만들어두는 것도 해결책이 될 수 있다.
그리고 tear-down을 꼼꼼히 확인해보자. 만약 tear-down에서 오류가 발생하면 tear-down을 실행한 테스트는 통과했겠지만, 그 이후의 테스트가 실패할 수 있기 때문이다.
첨언으로, 만약 DB를 쓴다면 일부러 트랜잭션 내에서 테스트하고 마지막에 롤백 시키는 테크닉도 있다.
비동기 작업
비동기 작업은 언제 결과가 돌아올 지 모르기 때문에 테스트가 까다롭다. 비동기 요청 동안은 대기하고 있다가 응답이 들어왔을 때 테스트가 응답을 확인하는 게 이상적이기 때문에 이 글에선 두 가지 방법을 추천한다.
첫 번째는 비동기 호출에 콜백 추가하기 다. 애초에 비동기 호출된 곳에 작업이 끝나면 실행해줬으면 하는 걸 요청할 수 있다면, 언제 응답할지 걱정할 필요가 없다. 게다가 이후 소개할 두 번째 방법과 달리 테스트 코드만 짜면 된다. 편하다.
하지만 첫 번째 방법은 비동기 호출된 곳을 수정할 수 없다면 쓸 수 없는 방법이다. 예로 들어 외부 서비스의 API인 경우다. 그래서 두 번째 방법은 Polling을 하는 것이다. 응답을 짧은 인터벌을 두고 루프를 돌면서 확인하는 것이다. 그리고 미리 본인이 정한 타임아웃을 넘기면 테스트 실패를 알리면 된다.
이러면 외부 서비스를 쓰더라도 응답이 돌아온 즉시 테스트를 할 수 있다. 그리고 비동기 작업에 오류가 생겨서 테스트가 영원히 기다리는 일도 없다. 하지만 테스트 실패에 대해서 타임아웃 외에는 아는 게 없다는 문제가 있다. 그래서 콜백을 쓸 수 있도록 상대방을 설득해보라고 한다.
여기까지가 비동기 작업을 직접 테스트 한다면 취할 수 있는 방법이다. 하지만 시간이 오래 걸린다는 문제 때문에 직접 호출을 회피하는 방법도 소개한다. 예로 들어 mock 객체 같은 테스트 더블을 쓰는 방법이 있다. 그리고 본격적인 테스트 전에 외부 서비스가 작동하는지 점검하는 것도 쓸데없는 시간 낭비를 막아줄 수 있다. 이를 smoke test라고 한다. 마지막으로 비동기 환경이 필요없는 로직을 최대한 포함하지 않게 비동기 작업 코드를 수정한다면, 비동기 테스트를 해야 될 범위를 줄일 수 있다.
(이 문단의 내용은 다음에 설명할 ‘원격 서비스’와도 내용이 비슷하다.)
원격 서비스
원격 서비스는 우리 팀이 제어할 수 없는 시스템과 연동해야 할 경우를 말한다. 그래서 상대 쪽에서 시스템을 수정하면 우리 쪽 테스트가 실패할 수 있는 건 당연하다.
이에 대한 해결책은 ‘비동기 작업’에서도 말했던 테스트 더블을 쓰는 것이다. 단, 외부 서비스를 흉내내는 것인 만큼 실제 외부 서비스와 동일한 값을 반환해줘야 한다. 그래서 테스트 더블을 안전하게 쓰는 좋은 팁 두 개가 있다.
하나는 실제 서비스의 응답과 테스트 더블의 응답을 비교하는 Contract Test를 구현하는 것이다. 이 테스트는 가끔씩 실행해서 우리의 테스트 더블이 틀리지 않았는지만 확인해주면 된다.
나머지는 테스트 더블을 만들 때 실제로 왔던 응답을 가져다 쓰는 것이다. 이걸 self initializing fake 라고 부르는데, 말 그대로 첫 호출 때 실제 서비스의 응답을 저장해뒀다가 테스트할 땐 저장된 응답을 쓰는 거다. 매우 쉽고 당연하지만 좋은 방법이라 강조한다.
시간
테스트에서 시스템 시각을 그대로 사용해서 발생하는 문제다. 테스트 할 때마다 시각이 바뀌니 결과도 다를 수 밖에 없다. 그래서 해결책은 테스트 더블을 써서 고정된 시각을 받아오도록 테스트를 수정하면 해결된다.
만약 테스트에서 쓴 시각이 너무 오래 전에 정한거라 다른 시간적 요인이랑 충돌해서 실패할 수 있다. 이럴 때는 시각을 수정해도 된다. 단, 테스트 내에서 수정된 통제변인이 ‘시각’ 하나만 있어야 한다. 여러 통제변인이 변한다면 이 테스트가 실패하는 원인이 시각 때문인지 다른 변인 때문인지 알 수 없다.
정말 시스템 시간을 그대로 썼다가 프로그램에 문제가 생긴 걸 많이 봤다. 그러니 빌드 전에 시간을 직접 호출하는 부분이 있는지 코드 분석을 다시 해보자.
자원 누수
프로그램에서 자원 누수가 생기면 테스트가 임의로 실패할 수 있다. 만약 테스트가 비결정적 테스트가 아닌게 확실한데도 테스트가 실패한다면 자원 누수를 확인해보는 것도 좋다.
이런 누수는 보통 C++같이 메모리 관리가 없는 환경이거나 DB 커넥션이나 소켓 같은 걸 안 닫아서 생긴다. 그래서 이런 자원을 무분별하게 늘리고 있는지 확인하려면 크기가 1인 Resource pool을 통해 받도록 하면 된다. 그러면 받은 자원을 해제하지도 않았는데 추가로 받으려는 순간 바로 알 수 있어서 문제를 찾기 쉽다.
이 아이디어는 크게 보면 제한을 세게 줘서 오류가 발생하기 쉽게 하는 것이다. 그래서 이 원리를 쓰면 테스트 단계에서 오류를 막을 수 있다. 그래서 다른 예를 소개하자면, 임의의 파일명으로 임시 파일을 만드는 프로그램에서 누수를 빨리 발견한 사례가 있다. 이 때는 단일 파일명으로 임시 파일을 생성하도록 수정해서 테스트 하는 방법을 통해 이 원리를 활용했다.