좋은 테스트의 조건 - 신뢰성
좋은 테스트란
좋은 테스트는 신뢰성, 유지 보수성, 가독성을 만족해야 합니다. 이번 글을 시작으로 각 조건에 대해 정리를 해보도록 하겠습니다.
- 신뢰성 - 버그가 없고 올바른 대상을 테스트하는 것
- 유지 보수성 - 변경에 대응이 잘 되는 것
- 가독성 - 잘 읽을 수 있고, 잘못된 경우 문제를 파악할 수 있는 것
테스트가 실패하는 이유
테스트가 실패했다면 그 이유로 타당한 경우는, 실제로 프로덕션 코드에서 버그가 발생했을 때 뿐이어야 합니다. 다른 이유의 실패는 테스트를 신뢰할 수 없게 만드는 원인이 됩니다. 다른 이유들에는 어떤 것들이 있는지 살펴보겠습니다.
거짓 실패
실제 버그는 없지만 테스트 자체에 버그가 있는 경우입니다.
- 종료점의 예상 결과를 잘못 설정
- 테스트 대상 시스템(SUT, 대상 코드 & 모듈)을 잘못 사용 - ex. 잘못된 값을 진입점에 전달/ 진입점을 잘못 호출
- 테스트 환경 설정 잘못 설정
- 테스트해야 할 것 잘못 이해
최신 상태가 아닌 테스트
기능이 변경되었다면 테스트가 현재 기능과 맞지 않기 때문에 실패할 수 있습니다. 이때는 테스트를 새로운 기능에 맞게 수정하거나, 새로운 기능에 대한 테스트를 만들고 기존 테스트를 삭제할 수 있습니다.
다른 테스트와 충돌하는 경우
만약 두 테스트가 충돌한다면, 둘 중 하나의 테스트는 필요하지 않다는 것을 의미합니다. 프로덕트 오너에게 기능과 요구사항을 물어보고, 쓸모없어진 테스트는 삭제합니다.
테스트가 불안정한 경우
테스트가 어떨 때는 성공하고, 어떨 때는 실패하는 경우입니다. 단위 테스트에서는 스텁이나 목을 사용할 수 있지만 수준이 높은 테스트라면 불안정한 테스트가 될 가능성이 큽니다. 이는 이어서 자세히 살펴보겠습니다.
불안정한 테스트
불안정한 테스트란 코드에는 변화가 없지만 테스트가 일관성 있는 결과를 반환하지 못하는 경우를 말합니다. 테스트 수준이 높을수록 실제 의존성을 더 많이 사용하기 때문에 불안정성을 증가시킵니다. 높은 수준의 테스트에서는 서드 파티, 보안 및 네트워크 계층, 환경 설정 등 실제 프로덕션 환경에 유사하게 설정하기 때문에 불안정성을 야기하는 요소들이 많을 수밖에 없습니다.
불안정한 테스트를 발견했을 때 할 수 있는 일
그렇다면 불안정한 테스트를 발견했을 때 무엇을 해야 할까요? 가장 먼저 문제를 정의해야 합니다. 어떤 "불안정함"이 발생한건지 명확하게 합니다. 이후, 불안정한 테스트를 분리하여 따로 모아줍니다. 그 다음 의존성을 제어하거나 테스트에 필요한 데이터를 데이터베이스에 삽입하는 수정, 낮은 수준의 테스트로 변환하여 불안정성을 제거하는 리팩터링, 해당 기능을 잘 검증하는 테스트가 있다면 오래된 테스트를 삭제하는 작업을 진행해줍니다.
상위 수준의 테스트에서 안정성을 유지하는 법
상위 수준의 테스트를 진행할 때, 안정성을 유지하려면 다음 과정을 생각해줍니다.
- 고수준 테스트 일부가 저수준 테스트로 대체 가능한지 살펴봅니다.
- 저수준 테스트가 이미 특정 기능이나 동작을 검증하고 있다면 일부 고수준 테스트를 삭제합니다.
- 다른 테스트가 외부 시스템 상태를 변경하지 않도록 합니다.
- 외부 시스템과 의존성을 제어할 수 있게 합니다.
- 테스트가 데이터베이스나 네트워크 서비스 같은 외부 시스템을 변경했다면 변경한 내용을 롤백합니다.
단위 테스트에서 불필요한 로직 제거
테스트에 로직을 많이 넣을수록 버그가 생길 확률은 증가합니다. 단위 테스트에 아래 로직이 포함되어 있다면 불필요한 로직일 수 있으므로, 줄이거나 없애는 것이 좋습니다.
- switch, if, else 문
- forEach, for, while 푸르
- 문자열 연결(+기호) 등
- try/catch 블록
대상 코드의 로직을 동일하게 사용하는 경우
문자열을 연결하는 "hello" + name 을 반환하는 makeGreeting() 함수를 대상으로 하는 테스트 코드를 살펴보겠습니다. 첫 번째 테스트에서는 대상 함수에서 사용된 로직을 그대로 사용하는데, 이렇게 되면 예상한 기댓값과 다른 결과가 나와도 검증을 못할 수 있습니다. 중간에 띄어쓰기가 안 되더라도 로직을 그대로 사용하기 때문에 테스트는 그대로 통과하는 것이죠. 그래서 기댓값은 동적으로 생성하지 않고, 가능하면 하드코딩된 값을 사용하는 것이 좋습니다.
단위 테스트가 아닌 수준 높은 테스트의 경우, 동적 생성이 불가피할 수 있지만 그럼에도 동적 생성은 가능한 한 피하는 것이 좋습니다. 테스트 코드에서는 코드가 중복되는 것보다 신뢰성을 확보하는 것이 훨씬 중요하기 때문입니다.
// 로직 그대로 사용
describe("makeGreeting with the same logic", () => {
it("returns correct greeting for name", () => {
const name = "abc";
const result = trust.makeGreeting(name);
expect(result).toBe("hello" + name);
});
});
// 하드 코딩
describe("makeGreeting with hard cording", () => {
it("returns correct greeting for name", () => {
const result = trust.makeGreeting("abc");
expect(result).toBe("hello abc");
});
});
불필요한 로직이 포함된 경우: 반복문/ if…else 문
반복문, forEach메서드를 통해서 여러 입력값을 한번에 테스트하거나, if…else 문을 사용하면 버그가 발생할 확률을 높아지고, 테스트 이름 또한 모호하게 설정됩니다. 그래서 각 케이스를 개별적인 테스트로 분리하여 불필요한 문을 제거하고 이름을 명확하게 하는 것이 좋습니다.
// 불필요한 로직 포함
describe("isName", () => {
const namesToTest = ["firstOnly", "first second", ""];
it("correctly finds out if it is a name", () => {
namesToTest.forEach((name) => {
const result = isName(name);
if (name.includes(" ")) {
expect(result).toBe(true);
} else {
expect(result).toBe(false);
}
});
});
});
// 테스트 분리
describe("isName", () => {
describe("only first", () => {
it("returns false", () => {
const nameFirstOnly = "firstOnly";
const result = isName(nameFirstOnly);
expect(result).toBe(false);
});
});
describe("right name", () => {
it("returns true", () => {
const nameRight = "first second";
const result = isName(nameRight);
expect(result).toBe(true);
});
});
describe("empty string", () => {
it("returns false", () => {
const nameEmpty = "";
const result = isName(nameEmpty);
expect(result).toBe(false);
});
});
});
테스트가 통과하더라도 끝이 아니다
테스트가 통과했을지라도 아래와 같은 경우에 해당된다면 한번 더 주의 깊게 살펴볼 필요가 있습니다.
- 검증 부분이 없는 경우
- 이해할 수 없는 테스트가 있는 경우
- 단위 테스트가 불안정한 통합 테스트와 섞여 있는 경우
- 테스트가 여러 가지를 한꺼번에 검증하는 경우
- 테스트가 자주 변경되는 경우
검증 부분이 없는 경우
예외가 발생하지 않는 테스트임을 표시하는 경우, 테스트 이름에 이를 잘 명시해줘야 합니다. 아래와 같은 코드를 쓸 수 있지만 이를 남용하는 것은 좋지 않습니다.
expect(() => someFunction().not.toThrow());
이해할 수 없는 테스트가 있는 경우
식별자, 테스트 메시지 등이 적절하지 않거나 정보가 불충분한 경우에는 명확하게 하고, 코드가 너무 길거나 복잡하다면 파악하기 쉽도록 코드를 쪼개고, 실패인지 통과인지 애매하여 결과가 불분명하다면 결과를 확실하게 알 수 있도록 해야 합니다.
단위 테스트가 불안정한 통합 테스트와 섞여 있는 경우
통합 테스트는 단위 테스트보다 의존성이 많아서 불안정할 가능성이 높습니다. 통합 테스트와 단위 테스트를 분리하여 안정적인 테스트 영역(safe green zone)을 만들고, 여기에는 빠르게 신뢰할 수 있는 테스트만 포함시킵니다.
테스트가 여러 가지를 한꺼번에 검증하는 경우
반환 값, 상태값 변경, 서드 파티 호출을 의미하는 종료점은 하나만 테스트해야 합니다. 여러 개를 테스트 할 경우, 명확한 이름을 짓기도 어려울 뿐더러, 앞선 검증이 실패했을 때 뒤의 검증 부분을 실행되지 않는다는 문제도 있습니다.
테스트가 자주 변경되는 경우
현재 날짜나 시간을 다루는 경우, 테스트를 실행할 때마다 다른 입력값을 가지므로 이를 스텁 등으로 해결하여 테스트를 해야 합니다.
참고자료
- 단위 테스트의 기술, 길벗
- 도서 코드 예제 깃헙