TOC
- 참고
- 좋은 테스트의 특성
- 테스트를 신뢰할 수 있는지 판단하는 방법
- 테스트가 실패하는 이유
- 단위 테스트에서 불필요한 로직 제거
- 테스트가 통과하더라도 끝이 아니다
- 불안정한 테스트 다루기
- 총평
참고
본 내용은 단위 테스트의 기술을 읽고 정리한
내용입니다.
책의 내용과 함께 개인적인 의견과 생각, 학습을 담아 작성하였습니다.
좋은 테스트의 특성
- 신뢰성
- 신뢰할 수 있다 = 버그가 없고 올바른 대상을 테스트한다.
- 좋은 단위 테스트의 첫 번째 기준
- 신뢰할 수 없다면 실행하는 의미가 없다.
- 유지 보수성
- 코드가 조금만 바뀌어도 테스트를 수정해야 한다면 유지보수에 지쳐 손을 놓는다.
- 가독성
- 테스트가 잘못된 경우 문제를 파악할 수 있는 능력
테스트를 신뢰할 수 있는지 판단하는 방법
테스트를 신뢰하지 않는 상황
- 거짓 양성 : 테스트는 실패했지만 신경 쓰지 않음
- 거짓 음성 : 테스트가 통과했지만 의심스러운 경우
- 테스트가 가끔 통과할 때
- 테스트가 현재 작업과 관련 없다고 생각할 때
- 테스트에 버그가 있다고 느낄 때
테스트를 신뢰할 수 있는지 판단하는 방법
- 테스트가 실패했을 때 쉽게 넘어가지 않을 때
- 테스트가 통과한 뒤, 따로 수동으로 테스트하거나 디버깅하지 않을 때
테스트가 실패하는 이유
- 프로덕션 코드에서 실제 버그가 발견된 경우
- 테스트가 거짓실패를 일으키는 경우
- 기능 변경으로 테스트가 최신 상태가 아닌 경우
- 테스트가 다른 테스트와 충돌하는 경우
- 테스트가 불안정한 경우
테스트가 거짓 실패를 일으키는 경우
- 테스트 자체에 버그가 있는 경우
- 잘못된 테스트를 가볍게 보지 말라.
- 테스트가 통과되더라도 실제로는 문제가 있는 상황을 발견하지 못할 수도 있다.
테스트에 버그가 있는지 찾아내는 방법
다음 항목에 해당하면 거짓 실패
- 잘못된 항목이나 잘못된 종료점을 검증하는 경우
- 잘못된 값을 진입점에 전달하는 경우
- 진입점을 잘못 호출하는 경우
테스트 버그를 발견했을 때 해야 할 일
- 버그를 수정하고 테스트를 재실행한다.
- 통과했으면 일부러 버그를 넣어보자.
- 실패하지 않는다면 여전히 테스트에 문제가 있을 수 있다.
- 디버깅을 계속해도 실패한다면 프로덕션 코드에 문제가 있을 수 있다.
- 오히려 테스트를 통해 버그를 찾아낸 좋은 일
잘못된 테스트를 방지하는 방법
- 테스트 주도 개발(TDD) 방식으로 코드를 작성한다.
- 테스트가 실패해야 정상
- 그리고 이를 통과하도록 만든다.
- 처음부터 통과하면 테스트가 잘못된 거고, 계속 실패하면 테스트가 잘못된거다.
기능 변경으로 테스트가 최신 상태가 아닌 경우
두 가지 선택지가 있다.
- 테스트를 새로운 기능에 맞게 수정
- 새로운 기능을 대상으로 새 테스트 만들고 기존 테스트 삭제
아마 가장 많이 접하게 되는 테스트 실패의 원인일 것 같아요. 너무 빡빡하지 않으면서도 비즈니스적으로 중요한 테스트들을 짜는 연습을 해볼 필요가 있겠네요.
단위 테스트에서 불필요한 로직 제거
- 복잡해진 테스트는 디버그와 코드 검증에 더 많은 시간을 소모하게 한다.
- 테스트 코드에 복잡한 로직이 포함되면 그만큼 테스트 자체에 버그가 생길 확률이 높다.
- 실패한 테스트가 있으면 테스트 코드 자체의 로직을 점검하는 것도 중요하다.
단위 테스트에서 줄이거나 없애야 할 코드
- switch, if, else 문
- foreach, for, while 루프
- 문자열 연결(+ 기호) 등
- try/catch 블록
Assert문에서 로직: 동적 기댓값 생성
하지 마라.
- 테스트 대상과 기댓값을 같은 동적인 방식으로 처리하면 틀려도 동일한 버그를 발생시킨다.
- 즉, 정상으로 통과하게 되는데, 이건 신뢰성 문제이다.
- 테스트는 항상 실제 코드와는 다른 방식으로 기댓값을 설정해야 한다.
검증 단계에서 기댓값을 동적으로 생성하지 말고 되도록 하드코딩된 값을 사용하라.
테스트가 복잡해지면 생기는 문제들
- 테스트를 읽고 이해하기가 어렵다.
- 테스트를 재현하기가 어렵다.
- 테스트에 버그가 있을 가능성이 높아지거나 잘못된 것을 검증할 수 있다.
- 테스트가 여러 일을 하므로 이름 짓기가 어려워진다.
복잡한 테스트를 만들거라면?
간단한 테스트와의 격리
- 기존의 간단한 테스트를 대체하지 말고 새로운 테스트로 추가하라.
- 단위 테스트가 아닌 다른 테스트를 포함하도록 특정 프로젝트나 폴더에 포함시킨다.
- 복잡한 테스트는 필요할 때만 사용할 수 있게 된다.
테스트가 통과하더라도 끝이 아니다
잘못된 신뢰(false trust)
- 신뢰하지 말아야 할 테스트를 신뢰
- 하지만, 그 사실을 아직 모르는 상태
- 테스트를 검토하고 잘못된 신뢰를 찾아내는 것은 중요
테스트를 믿지 못하는 이유들
- 검증 부분이 없다.
- 테스트를 이해할 수 없다.
- 단위 테스트가 불안정한 통합 테스트에 섞여 있다.
- 테스트가 여러 가지를 한꺼번에 검증한다.
- 테스트가 자주 변경된다.
테스트를 이해할 수 없는 경우
- 이름이 적절하지 않은 테스트
- 코드가너무 길거나 복잡한 테스트
- 변수 이름이 헷갈리게 되어 있는 테스트
- 숨어 있는 로직이나 이해하기 어려운 가정을 포함한 테스트
- 결과가 불분명한 테스트(실패도 아니고 통과도 아닌 경우)
- 충분한 정보를 제공하지 않는 테스트 메시지
이해하지 못하면 그 테스트를 신경 써야 하는지 여부조차 알 수 없다.
단위 테스트가 불안정한 통합 테스트와 섞여 있는 경우
- 테스트의 혼재는 불안정한 테스트를 묵인하고 넘어가기 좋은 환경
- 불안정하다고 탓할 여지를 아예 없애는 게 낫다.
- 안정적인 테스트 영역(safe green zone)을 만들어라.
- 빠르고 신뢰할 수 있는 테스트만 포함한다.
- 네임스페이스와 폴더의 모든 테스트를 실행할 수 있어야 한다.
- 모든 테스트가 통과할 것이라는 믿음이 있어야 한다.
테스트가 여러 가지를 한꺼번에 검증하는 경우
describe('trigger', () => {
it ('works', () => {
const callback = jest.fn();
const result = trigger(1, 2, callback);
expect(result).toBe(3);
expect(callback).toHaveBeenCalledWith("I'm triggered");
})
})
이 테스트는 두 가지를 검증하고 있다.
- 테스트 이름이 모호해진다.
- 테스트 이름을 잘 짓는 것은 디버깅과 문서화에 매우 중요하다.
- 하나의 관심사만 테스트할 때는 이름 짓기가 쉽다.
- 각 검증은 별도의 테스트로 나누어서 실행하여 무엇이 실제로 실패했는지 명확히 확인하는 것이 좋다.
describe('trigger', () => {
it ('works', () => {
const callback = jest.fn();
trigger(1, 2, callback);
expect(callback).toHaveBeenCalledWith("I'm triggered");
})
if ("sums up given values", () => {
const result = trigger(1, 2, jest.fn());
expect(result).toBe(3);
})
})
테스트를 나눌 때 고려해야 할 점
첫 번째 검증이 실패했을 때, 다음 검증 결과가 여전히 중요하다면 각 검증을 서로 다른 테스트 2개로 독립적으로 진행하는 것이 좋다.
테스트가 자주 변경되는 경우
- 현재 날짜와 시간을 사용하는 테스트
- 난수, 컴퓨터 이름, 외부 환경 변수 값을 가져오는 테스트
- 동적으로 만든 값을 사용하는 경우
불안정한 테스트 다루기
코드에 변화가 없는데 일관성 없는 결과를 반환하는 테스트
테스트 수준에 따른 테스트 분류
- 테스트 수준이 낮은 단위 테스트, 컴포넌트 테스트
- 테스트가 모든 의존성을 완전히 제어할 수 있다.
- 따라서 변동 요소가 없다.
- 모드 실행을 완전히 예측할 수 있다.
- 테스트 수준이 높은 API 테스트, 통합 테스트
- 스텁과 모의 객체를 덜 사용하고
- DB, 네트워크, 환경 설정 등 실제 의존성을 더 많이 사용
- 제어할 수 없는 변동 요소가 많아진다.
- 최상위 수준의 테스트 E2E 테스트
- 모든 의존성을 실제로 사용
- 서드 파티, 보안 및 네트워크 계층, 환경 설정 등
불안정한 테스트를 발견했을 때 할 수 있는 일관성
불안정한 테스트를 없애는 것은 장기 목표로 삼아야 한다.
문제 정의하기
- '불안정'이 무엇을 의미하는지 명확히 정의하기
- 불안정하다고 판단된 테스트는 별도로 모아 실행한다.
- 불안정한 테스트를 하나씩 검토하며 수정, 리팩터링, 삭제 과정을 밟자.
수정
- 가능한 의존성을 제어하여 테스트 안정성을 높인다.
- ex. DB에 특정 데이터가 필요한 경우, 테스트 실행 시 데이터 삽입
리팩터링
- 의존성을 제거하거나 제어
- 더 낮은 수준의 테스트로 변환
- 불안정성 제거
- ex. 실제 서버 대신 스텁을 사용하여 네트워크 요청
삭제
- 테스트로 얻는 이점이 유지 보수 비용을 감당하고도 남는지 검토
- 오래되고 불안정한 테스트는 차라리 없애는 것이 나을 수도 있다.
- 오래되고 쓸모 없는 테스트를 그대로 놔두는 것은 비용이다.
상위 수준의 테스트에서 안전성을 유지하는 방법
어떤 환경에서도 테스트가 반복적으로 실행될 수 있도록 하는 것이 중요하다.
- 테스트가 데이터베이스나 네트워크 서비스 같은 외부 시스템을 변경했으면 변경한 내용을 롤백한다.
- 다른 테스트가 외부 시스템의 상태를 변경하지 않도록 한다.
- 외부 시스템과 의존성을 제어할 수 있어야 한다.
- 해당 시스템을 언제든지 다시 만들 수 있게 하기
- 제어 가능한 더미 시스템을 만들기
- 테스트 전용 계정을 만들어 안전하게 관리하기
외부 시스템을 다른 회사에서 관리하는 경우 외부 의존성을 제어하기가 힘들거나 불가능할 수 있다. 이 상황에서는 다음 방법을 고려해 볼 수 있다.
- 저수준 테스트가 이미 특정 기능이나 동작을 검증하고 있다면 일부 고수준 테스트를 삭제한다.
- 일부 고수준 테스트를 저수준 테스트로 바꾼다.
- 새로운 테스트를 작성할 때는 배포 파이프라
총평
기억하고 싶은 내용들
- 테스트를 신뢰할 수 없다면, 실행하는 의미가 없다.
- 검증 단계에서 기댓값은 최대한 하드코딩된 값 사용하기(틀려도 같이 틀려서 정상 처리됨)
- 테스트로 얻는 이점이 유지 보수 비용을 감당하고도 남는지 검토해볼 것.
- 오래되고 불안정한 테스트는 차라리 없애는 것이 나을 수도 있다.
공감하는 내용
본 장에서는 이름처럼 신뢰할 수 있는 테스트에 완전한 초점을 맞춘 것이 인상적이었습니다. 확실히 테스트는 그 결과를 신뢰할 수 있어야 의미가 있다는 저자의 의견에 적극 공감합니다.
따라하기 힘들 것 같은데
사실 말이 쉬운 것 같은 요소들이 많습니다. 통과한 테스트의 인자를 일부 고정해 일부러 틀리게 한다거나, 수준을 오르내리며 테스트를 쪼개고 붙이는 것들, 그리고 디렉토리를 옮겨 나누는 등 다양한 솔루션을 제안했는데요, 그런데 현실적으로는 조금 어렵지 않을까 싶었던 요소였습니다. 실력자들은 이런 걸 쉽게 할까요?