불안정한 테스트
고친 사람 github-actions[bot]
불안정한 테스트는 같은 코드를 두고 어떤 때는 통과를, 어떤 때는 실패를 알리는 테스트입니다. 코드를 한 줄도 안 고쳐도 결과가 오락가락합니다. 테스트가 코드 밖에서 흔들리는 무언가에 기대고 있어서 생깁니다. 이런 테스트는 실패 알림 전체에 대한 믿음을 깎습니다.
쉽고 빠른 이해
불안정한 테스트는 돌릴 때마다 결과가 오락가락하는 테스트입니다. 백그라운드 작업이 끝나기를 0.1초만 기다리고 결과를 확인하는 테스트가 그렇습니다. 서버가 한가할 때는 통과하고 붐빌 때는 실패합니다.
테스트의 쓸모는 「실패하면 코드가 틀린 것」이라는 약속에 있습니다. 결과가 코드 말고 다른 것 때문에 바뀌면 이 약속이 깨집니다. 실패를 봐도 코드가 틀렸는지 운이 나빴는지 가릴 수 없게 됩니다.
어떻게 생기나:
- 테스트가 걸린 시간, 현재 시각, 네트워크, 다른 테스트가 남긴 데이터 같은 외부 요인에 기댑니다
- 그 요인이 실행마다 조금씩 달라집니다
- 어느 실행에서 그 차이가 판정이 갈리는 선을 넘으면 실패합니다
무엇이 나빠지나 — 사람들이 실패를 보면 다시 돌려 보기만 합니다. 그러다 진짜 결함이 낸 실패도 같이 흘려보냅니다. 다시 돌리는 시간만큼 변경을 병합하는 일도 늦어집니다.
상세
테스트는 코드를 돌려 보고 결과가 기대와 같은지 확인하는 코드입니다. 결과를 기대와 견주는 줄을 단언이라 부릅니다. 단언이 어긋나면 테스트는 실패를 알립니다.
테스트가 믿을 만하려면 같은 코드를 언제 돌려도 같은 판정이 나와야 합니다. 같은 입력에 늘 같은 결과를 내는 성질을 결정성이라 합니다. 결정성이 있어야 실패가 곧 「코드가 틀렸다」는 신호가 됩니다.
불안정한 테스트는 이 결정성이 깨진 테스트입니다. 코드는 한 줄도 안 고쳤습니다. 그래도 어떤 실행은 통과하고 어떤 실행은 실패합니다.
영어로는 flaky test 라 합니다. 실무에서는 「플래키 테스트」라고도 부릅니다. 이 절은 백그라운드 작업을 기다리는 테스트 하나로 이 문제를 따라갑니다.
가끔 실패하는 테스트 하나
주문을 받으면 확인 메일을 보내는 서비스가 있다고 합시다. 메일은 비동기로 보냅니다. 비동기 작업은 부른 쪽이 끝을 기다리지 않고 바로 다음 줄로 넘어가는 작업입니다.
테스트는 주문을 넣은 뒤 메일이 나갔는지 확인하고 싶습니다. 발송이 언제 끝날지 모르니 잠깐 기다렸다가 확인합니다.
svc.place(order); // 곧바로 돌아온다
Thread.sleep(100); // 0.1초 기다린다
assertTrue(sent(order)); // 참 또는 거짓
첫 줄은 주문을 넣고 곧바로 돌아옵니다. 메일 발송은 다른 스레드에서 따로 돕니다. 스레드는 한 프로그램 안에서 따로 나아가는 실행 흐름입니다.
셋째 줄의 판정은 발송이 0.1초 안에 끝났느냐에 달렸습니다. 한가한 개발자 노트북에서는 발송이 0.1초보다 한참 먼저 끝납니다. 그러면 테스트는 통과합니다.
같은 테스트를 지속적 통합 서버에서 돌립니다. 지속적 통합(CI, Continuous Integration)은 커밋이 올라올 때마다 빌드와 테스트를 자동으로 돌리는 방식입니다. 이 서버가 여러 빌드를 한꺼번에 돌려 붐비면 발송이 0.1초를 넘기기도 합니다.
sequenceDiagram
participant 테스트
participant 서비스
participant 발송 as 발송 스레드
테스트->>서비스: 주문을 넣는다
서비스->>발송: 메일 발송을 맡긴다
서비스-->>테스트: 곧바로 돌아온다
Note over 테스트: 0.1초 기다린다
테스트->>서비스: 메일이 나갔는지 묻는다
Note over 발송: 붐비면 아직 보내는 중이다
서비스-->>테스트: 아직 안 나갔다
Note over 테스트: 단언이 어긋나 실패한다
확인이 발송보다 먼저 옵니다. 서비스 코드에는 틀린 데가 없습니다. 판정이 코드가 아니라 서버가 얼마나 붐비는지에 달려 있을 뿐입니다.
결과를 흔드는 외부 요인
앞의 예에서 판정을 흔든 것은 걸린 시간이었습니다. 테스트를 흔드는 요인은 이것 말고도 몇 가지로 모입니다. 공통점은 코드와 입력이 같아도 실행마다 달라진다는 것입니다.
| 외부 요인 | 실행마다 달라지는 까닭 | 흔히 보이는 실패 |
|---|---|---|
| 걸린 시간 | 기계가 붐비는 정도가 매번 다르다 | 고정된 시간만 기다리고 확인하다 실패한다 |
| 현재 시각 | 돌리는 날짜와 시각이 매번 다르다 | 월말이나 자정 무렵에만 실패한다 |
| 난수 | 뽑히는 값이 매번 다르다 | 드물게 뽑히는 값에서만 실패한다 |
| 네트워크와 외부 서비스 | 응답이 늦거나 끊길 수 있다 | 상대 서버가 잠깐 멈추면 실패한다 |
| 다른 테스트가 남긴 데이터 | 실행 순서가 바뀌면 남은 데이터도 바뀐다 | 혼자 돌리면 통과하고 함께 돌리면 실패한다 |
| 스레드가 도는 순서 | 어느 스레드가 먼저 도는지가 매번 다르다 | 두 스레드가 같은 값을 고칠 때 가끔 틀린다 |
| 실행 환경 | 기계마다 시간대와 비어 있는 포트가 다르다 | 노트북에서는 통과하고 CI 서버에서는 실패한다 |
다른 테스트가 남긴 데이터 때문에 생기는 실패는 따로 이름이 있습니다. 테스트끼리 데이터베이스나 전역 변수를 나눠 쓰면 앞 테스트가 뒤 테스트의 판정을 바꿉니다. 이것을 테스트 순서 의존성이라 부릅니다.
스레드가 도는 순서 때문에 생기는 실패도 이름이 있습니다. 결과가 스레드들이 도는 순서에 따라 달라지는 상태를 경쟁 상태라 합니다. 이 결함은 테스트 코드에 있을 수도 있고 서비스 코드에 있을 수도 있습니다.
외부 요인은 테스트가 바깥과 많이 닿을수록 늘어납니다. 단위 테스트는 함수 하나를 떼어 시험하므로 닿는 바깥이 적습니다.
통합 테스트는 여러 부품을 데이터베이스까지 붙여 함께 시험합니다. 데이터베이스와 네트워크를 지나므로 흔들릴 곳이 많습니다.
종단 간 테스트는 사용자가 하듯 시스템 전체를 처음부터 끝까지 거칩니다. 닿는 바깥이 가장 많아서 흔들릴 곳도 가장 많습니다.
재현되는 조건
불안정한 테스트는 세 조건이 함께 설 때 생깁니다.
- 테스트의 판정이 코드와 입력 말고 다른 요인에 기댑니다
- 테스트가 그 요인을 정해 주지 않아서 실행마다 달라집니다
- 그 차이가 어느 실행에서 판정이 갈리는 선을 넘습니다
하나만 빠져도 판정은 한 가지로 굳습니다.
flowchart TD
A{"판정이 코드 밖의 요인에 기대나"} -->|안 기댄다| X["늘 같은 판정"]
A -->|기댄다| B{"테스트가 그 요인을 정해 주나"}
B -->|정해 준다| X
B -->|안 정한다| C{"그 차이가 선을 넘는 실행이 있나"}
C -->|없다| X
C -->|있다| F["불안정한 테스트"]
앞의 예에 대 보면 이렇습니다. 판정은 발송에 걸린 시간에 기댑니다. 그 시간은 테스트가 정하지 않고 서버가 붐비는 정도가 정합니다. 0.1초라는 기다림이 선입니다. 붐빈 실행에서 발송이 그 선을 넘습니다.
차이는 대개 선에 크게 못 미쳐서 선을 넘는 실행은 드뭅니다. 앞의 예에서도 발송은 보통 0.1초보다 한참 먼저 끝납니다. 선을 넘는 실행이 백 번에 한 번이면 개발자가 몇 번 돌려서는 실패를 못 봅니다. 그래서 여러 사람이 하루에도 여러 번 돌리는 CI 서버에서 먼저 드러납니다.
무엇이 나빠지나
불안정한 테스트가 끼치는 손해는 그 테스트 하나에서 끝나지 않습니다. 가장 크게 나빠지는 것은 테스트 전체에 대한 믿음입니다.
실패 알림이 헛걸음으로 끝나는 일을 몇 번 겪으면 사람들은 실패를 보고도 원인을 찾지 않습니다. 「또 그 테스트겠지」 하고 다시 돌립니다. 다시 돌려서 통과하면 그대로 병합합니다.
이 버릇이 굳으면 진짜 결함이 낸 실패도 같은 방식으로 넘어갑니다. 테스트는 결함을 잡았습니다. 그 신호를 사람이 버린 것입니다. 잡힌 결함이 이렇게 배포까지 갑니다.
불안정한 테스트가 여럿이면 한 번에 모두 통과할 확률이 빠르게 떨어집니다. 테스트 스위트는 한 번에 함께 돌리는 테스트 묶음입니다. 스위트 안에 백 번에 한 번꼴로 실패하는 테스트가 N개 있다고 합시다. 스위트 전체가 한 번에 통과할 확률은 0.99를 N번 곱한 값입니다.
| 흔들리는 테스트 수 N | 스위트가 한 번에 통과할 확률 |
|---|---|
| 1 | 0.99 |
| 10 | 약 0.90 |
| 50 | 약 0.61 |
| 100 | 약 0.37 |
테스트 백 개가 저마다 드물게 흔들려도 스위트는 셋 중 둘꼴로 실패합니다. 그때마다 빌드를 다시 돌려야 합니다. 그동안 병합하려던 변경이 줄을 섭니다.
가려내는 법
실패가 코드 결함인지 불안정함인지는 같은 코드를 여러 번 돌려 보면 갈립니다.
커밋 하나를 고정하고 그 테스트만 수십 번 돌립니다. 결과가 한 번이라도 달라지면 그 테스트는 불안정합니다. 매번 실패한다면 불안정한 것이 아니라 코드나 테스트가 틀린 것입니다.
CI 기록에서도 찾을 수 있습니다. 같은 커밋에서 통과와 실패가 둘 다 찍힌 테스트가 후보입니다. 다시 돌려서 통과한 테스트를 따로 모아 두면 이 목록이 저절로 쌓입니다.
원인을 좁힐 때는 흔드는 요인을 하나씩 바꿔 봅니다. 혼자 돌려 통과하고 함께 돌려 실패하면 테스트 순서 의존성을 의심합니다. 기계를 일부러 붐비게 했더니 실패가 늘면 걸린 시간에 기대는 테스트입니다.
깨지기 쉬운 테스트와의 경계
이름이 비슷한 깨지기 쉬운 테스트와 헷갈리기 쉽습니다. 깨지기 쉬운 테스트는 동작이 아니라 내부 구현에 묶인 테스트입니다. 겉 동작은 그대로 두고 속만 고치는 리팩터링에도 실패합니다.
둘은 「무엇이 바뀌었을 때 실패하나」에서 갈립니다.
| 코드를 안 고쳤을 때 | 겉 동작은 두고 속만 고쳤을 때 | |
|---|---|---|
| 불안정한 테스트 | 가끔 실패한다 | 가끔 실패한다 |
| 깨지기 쉬운 테스트 | 늘 통과한다 | 늘 실패한다 |
| 믿을 만한 테스트 | 늘 통과한다 | 늘 통과한다 |
깨지기 쉬운 테스트는 결정성을 지킵니다. 같은 코드에는 늘 같은 판정을 냅니다. 불안정한 테스트는 코드가 그대로여도 판정이 흔들린다는 점에서 다릅니다.
다루는 법
고치는 길은 대개 하나로 모입니다. 테스트를 흔드는 외부 요인을 테스트가 직접 쥐게 만드는 것입니다.
| 흔드는 요인 | 테스트가 쥐는 방법 |
|---|---|
| 걸린 시간 | 고정된 시간을 기다리는 대신 조건이 설 때까지 짧게 거듭 확인한다 |
| 현재 시각 | 코드가 시계를 직접 읽지 않고 인자로 건네받게 한다. 테스트는 고정된 시각을 가리키는 시계를 건넨다 |
| 난수 | 난수를 만드는 시작값을 고정해 늘 같은 값이 나오게 한다 |
| 네트워크와 외부 서비스 | 진짜 서버 대신 정해진 답을 주는 가짜로 바꾼다 |
| 다른 테스트가 남긴 데이터 | 테스트마다 데이터를 새로 준비하고 끝나면 지운다 |
조건이 설 때까지 거듭 묻는 방식을 폴링이라 합니다. 폴링에도 기다림의 상한은 둡니다. 결함이 있어 조건이 끝내 안 서면 테스트가 영영 안 끝나기 때문입니다.
난수를 만드는 시작값을 시드라 부릅니다. 난수 생성기는 같은 시드를 받으면 같은 순서로 값을 냅니다. 실패한 실행의 시드를 기록해 두면 그 실패를 다시 만들 수 있습니다.
외부 서비스 대신 쓰는 가짜를 테스트 더블이라 부릅니다. 진짜 결제 서버나 메일 서버 대신 테스트가 정한 답을 돌려줍니다. 상대 서버가 멈춰도 테스트 판정은 흔들리지 않습니다.
원인을 바로 못 고치면 그 테스트를 스위트에서 따로 빼 두기도 합니다. 빼 둔 테스트는 실패해도 병합을 막지 않습니다. 대신 고칠 때까지 그 테스트가 지키던 동작은 아무도 확인하지 않습니다.
실패하면 저절로 다시 돌리게 하는 재시도 설정도 흔합니다. 이 설정은 실패를 통과로 바꿔 줄 뿐 흔드는 요인을 없애지 않습니다. 서비스 코드에 경쟁 상태가 있어 테스트가 그 결함을 가끔만 드러내는 경우도 함께 가려집니다.
관련 항목
불안정한 테스트가 나타나는 테스트 종류
단위 테스트 · 통합 테스트 · 종단 간 테스트 · 인수 테스트 · 회귀 테스트 · 테스트 스위트
테스트 판정을 흔드는 바깥 원인
테스트 순서 의존성 · 공유 픽스처 · 벽시계 · 시간대 · 난수 생성기 · 네트워크 · 타임아웃 · 비동기
불안정한 테스트 뒤에 숨는 동시성 결함
경쟁 상태 · 데이터 경쟁 · 데드락 · 동시성 · 스레드
흔드는 요인을 테스트가 쥐게 하는 수단
테스트 더블 · 목 객체 · 스텁 · 페이크 · 폴링 · 시드 · 의존성 주입 · 테스트 격리
불안정한 테스트와 헷갈리는 테스트 결함
깨지기 쉬운 테스트 · 과잉 명세 · 과도한 모킹 · 테스트 냄새 · 간헐적 실패
불안정한 테스트의 판정을 이루는 부품
테스트 · 단언 · 테스트 러너 · JUnit · 결정성 · 비결정성
불안정한 테스트가 비용을 치르게 하는 개발 파이프라인
다른 이름: flaky test · flakey test · 플래키 테스트 · 플레이키 테스트