[단위 테스트의 기술] 7장 : 신뢰할 수 있는 테스트

구범모·2025년 1월 8일
0

이 글은 단위테스트의 기술을 읽고 정리한 글입니다. 개인적으로 도움이 되었던 장을 정리하여 업로드합니다.

개인적으로 지금까지 읽었던 내용 중, 단위테스트 관련해서 가장 많은 영감을 준 장이었다.
이 장은 테스트 신뢰성을 높이기 위한 방법들을 소개한다.

테스트가 실패하는 이유

1. 프로덕션 코드에서 실제 버그가 발견된 경우

  • 이런 경우에는 테스트를 수정할 필요가 없다. 오히려 테스트 덕에 프로덕션 코드의 버그를 발견할 수 있어서, 테스트의 순기능을 다 했다.

2. 테스트가 거짓 실패를 일으키는 경우

  • 프로덕션 코드에서는 버그가 없지만, 테스트 작성 자체를 잘못했기 때문에 테스트가 실패한다. 아래는 거짓 실패의 사례이다
    1. 잘못된 항목이나 잘못된 종료점을 검증하는 경우
    2. 잘못된 값을 진입점에 전달하는 경우
    3. 진입점을 잘못 호출하는 경우
  • 잘못된 테스트를 방지하기 위해서는, 아래 두가지를 실천함으로써 이룰 수 ㅇ
    • TDD 실천
    • 테스트 내부의 복잡한 로직 제거

3. 기능 변경으로 테스트가 최신 상태가 아닌 경우

  • 말그대로, 프로덕션 코드가 변경되었지만 테스트는 해당 변경사항을 반영하지 않아 실패하는 경우이다.

단위 테스트에서 불필요한 로직 제거

단위 테스트가 복잡해 질 수록, 테스트는 디버깅과 코드 검증에 더 많은 시간이 소요게 된다.

아래의 항목들이 단위 테스트 내에서 생기게 된다면, 테스트 자체의 버그 가능성이 올라가게 되므로 지양한다.

  1. switch, if/else
  2. foreach, for, while loop
  3. 문자열 연결(+ 기호)등
  4. try/catch 블록

불필요한 로직 1 : Assert문에서 동적 기댓값 생성

// 프로덕션 코드
const makeGreeting = (name) => {
	return "hello" + name;
}

// 테스트 코드
decribe("makeGreeting", () => {
	it("returns correct greeting for name", () => {
		const name = "abc";
		const result = trust.makeGreeting(name);
		**expect(result).toBe("hello" + name);** // 프로덕션 코드와 동일한 로직을 포함한다.
	})
})

위 사례를 보자.

위 코드는 makeGreeting() 함수에서 리턴되는 로직을 Assert문에서도 그대로 사용하고 있다.

만약 기존 함수 로직에 버그가 있다 해도, 테스트에서도 동일한 로직을 사용중이므로 버그를 사용하지 못하고, 테스트가 가짜로 성공하게 된다.

따라서 단위 테스트를 작성할 때는, 실제 코드와는 다른 방식으로 기댓값을 설정해야 버그를 잡아낼 수 있다.

검증(Assert)단계에서 기댓값을 동적으로 생성하지 않고, 하드코딩된 값을 사용하여 버그를 잡아내 보자.
하드코딩을 함으로써 코드의 중복과 복잡도는 조금 높아질 수 있지만, 단위테스트에서 가장 중요한 것은 테스트의 신뢰성이기 때문에, 해당 부분은 감수하는 것이 맞다. (DRY vs DAMP in Unit test)

불필요한 로직 2 : 다른 형태의 로직

테스트 코드 내에서 불필요한 로직이 작성되면 테스트가 복잡해지고, 이는 아래와 같은 문제가 생길 수 있다.

  • 테스트를 읽고 이해하기 어렵다
  • 테스트 재현이 어렵다
  • 테스트에 버그가 있을 가능성이 높아지거나, 잘못된 것을 검증할 수 있다.
  • 테스트가 여러 가지 일을 하므로, 이름 짓기가 어려워 진다(→ 테스트 가독성이 떨어진다.)

따라서, 불필요한 로직을 작성하지 말고(if/else문으로 테스트 내에서 분기를 나누지 말고), 각 로직마다 새로운 테스트를 추가하여 각각의 테스트에서 하나의 로직만 효과적으로 테스트를 하자.

불필요한 로직 3 : 다른 곳에 로직이 더 많이 포함된 경우

테스트에 필요한 헬퍼 함수, 직접 작성한 테스트 더블, 테스트 유틸리티 클래스에 로직을 추가할 수록 코드를 읽기 어렵게 만들고, 버그 가능성이 높아진다.

물론 그렇다고 위의 것들을 작성하지 말라는 것은 아니고, 복잡한 로직이 필요하다면 최소한 유틸리티 함수의 로직을 검증하는 몇 가지 테스트를 추가하자.

성공하지만, 신뢰성이 떨어지는 테스트들

1. 검증이 없는 경우

검증 단계가 없거나, 단순히 예외를 던지지 않는 것만 확인하는 테스트는 신뢰성이 비교적 떨어진다.(실질적인 가치가 없다.) 그러므로 지양하자.

2. 테스트를 이해할 수 없는 경우

  • 이름이 적절하지 않은 테스트
  • 코드가 너무 길거나 복잡한 테스트
  • 변수 이름이 헷갈리게 되어 있는 테스트
  • 숨어 있는 로직이나 이해하기 어려운 가정을 포함한 테스트
  • 결과가 불분명한 테스트(실패도 아니고, 통과도 아닌 경우)
  • 충분한 결과를 제공하지 않는 테스트 메시지

위와 같은 테스트는 성공한다 하더라도 신뢰성이 떨어질 수 있다. 9장에서 해결법을 알아본다.

3. 단위 테스트가 불안정한 통합 테스트와 섞여 있는 경우

통합 테스트는 단위 테스트와 비교해 보았을 때, 더 불안정하다(실제 db 연결, 네트워크 연결, 외부 api 실제 사용 등을 하므로.)

따라서 비교적 신뢰성이 더 높은 단위 테스트와 통합테스트를 분리하여, 더 빠르고 신뢰할 수 있는 단위테스트만 별도로 실행할 수 있는 안정적인 테스트 영역(safe green zone)을 만드는 것을 추천한다.

4. 테스트가 여러 가지를 한꺼번에 검증하는 경우

// 프로덕션 코드
const trigger = (x, y, callback) => {
	callback("I'm triggered"); // 종료점 1
	return x + y; // 종료점 2
}

// 테스트코드
decribe("trigger", () => {
	it("works", () => {
		const callback = jest.fn();
		const result = trigger(1, 2, callback);
		**// 하나의 테스트에서 두개의 종료점을 테스트**
		**expect(result).toBe(3);
		expect(callback).toHaveBeenCalledWith("I'm triggered");**
	}
})

위 코드는 하나의 단위테스트에서 두개의 종료점을 테스트하고 있다.

이렇게 되면 몇가지의 문제점이 생긴다.

  1. 테스트 이름이 모호해 진다. 이는 곧 테스트를 읽는 사람이 테스트 이름 뿐만 아니라, 테스트 내용까지 읽게 만든다.
  2. 첫번째 Assert문이 실패하면, 아래의 Assert문이 테스트되지 않고 테스트가 중단된다.
    따라서 아래 검증의 성공 실패 유무를 모르므로, 테스트의 신뢰성이 낮아진다.

따라서 종료점이 두개 이상일 때는, 테스트를 쪼개서 진행한다.

다만, 관심사가 같은 assert문은 아래의 예시처럼 하나의 테스트에서 진행해도 된다.

// 프로덕션 코드
const makePerson = (x, y) => {
	return {
		name: x,
		age: y,
		type: "person"
	};
};

// 테스트코드
decribe("makePerson", () => {
	it("creates person given passed in values", () => {
		const result = makePerson("name", 1);
		expect(result.name).toBe("name");
		expect(result.age).toBe(1);
	})
});

위의 예시에서는 name, age가 person객체의 일부로, 같은 관심사에 해당하기 때문에 함께 검증해도 좋다.

profile
우상향 하는 개발자

0개의 댓글