
테스트도 단일책임원칙을 지키자
ds_chanin
·2025. 2. 19. 23:20
들어가며
테스트 코드를 작성하는게 익숙치 않은 리뷰이들을 보면 간혹 테스트에서 여러가지를 한번에 테스트하는 모습을 보곤한다.
여러가지라 표현한 이유는 그 케이스가 하나의 양상을 띄는게 아니라서 그렇다.
동일한 케이스에 대한 반복을 하는 경우도 있고
해피케이스와 엣지케이스를 섞기도 하며
컨텍스트를 유지하며 일련의 흐름을 작성하기도 한다.
SRP(Single Responsibility Principle: 단일책임원칙)에 대해 한번쯤 들어봤으리라 생각한다.
어떠한 객체에게 하나의 책임만 있어야 한다고 하는데 사실은 단일 (변경)책임 원칙이다.
위키피디아를 보면 다음과 같이 적혀있다.
The single-responsibility principle (SRP) is a computer programming principle that states that
"A module should be responsible to one, and only one, actor."
하나의 이유에 의해서만 변경되어야 한다는 의미다.
이를 다르게 바라보면 하나의 역할만을 가져야 한다고 해석할수 있으니 뭐 비슷한 것 같긴하다.
아무튼~
이 원칙을 보통 운영코드에서는 다들 의식적으로 잘 지키는 것 같으나
아주 가끔 테스트 코드에서는 간과하곤 한다.
물로 나도 그렇게 잘 지킨다고 할 순 없으나 그래도 "이정도는 고쳐보는게 좋지 않나?" 싶은 부분에 대해 이야기하고자 한다.
해피케이스와 엣지케이스를 하나의 테스트에서 검증하는 경우
동일한 케이스에 대해 하나의 테스트에서 검증하는 경우는 그렇게 많지도 않고 그럴필요가 없다는 것은 대부분 납득할 것 같다.
그런데 해피케이스와 엣지케이스를 하나의 테스트에서 한번에 검증하는 경우가 종종 보인다!
AS-IS
class LottoTicketTest {
@DisplayName("로또 티켓 테스트")
@Test
void testLottoTicket() {
// ✅ 해피 케이스: 정상적인 로또 번호 입력
LottoTicket ticket1 = new LottoTicket(Arrays.asList(1, 2, 3, 4, 5, 6));
assertThat(ticket.getNumbers()).hasSize(6);
// ❌ 엣지 케이스: 중복된 숫자 포함
assertThatThrownBy(() -> new LottoTicket(Arrays.asList(1, 2, 3, 3, 5, 6)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("중복된 숫자가 포함되어 있습니다.");
// ❌ 엣지 케이스: 범위를 벗어난 숫자 포함
assertThatThrownBy(() -> new LottoTicket(Arrays.asList(0, 2, 3, 4, 5, 6)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("로또 번호는 1부터 45까지의 숫자여야 합니다.");
}
}
보시다시피 하나의 테스트 내에서 여러개의 케이스를 한번에 검증한다.
엣지 케이스에 대한 테스트가 늘어날때마다 계속 이 테스트 아래에 구문을 추가하는게 보기좋은 모양이라는 생각은 아무도 안할 것 같다~
그리고 테스트의 이름이 "로또 티켓 테스트"라는 애매모호하기 그지없는 이름이다. 무엇을 테스트하고자 하는것인지 알기 쉽지않다!
테스트 코드에 적어둔 여러개의 단언 구문은 정책이 변경됨에 따라 여기저기서 변경이 될 수 있다.
그리고 테스트가 실패하면 어떤 단언이 실패했고 어느 단언까지 성공했는지 파악하기 불편하다.
또한 이렇게 되면 테스트 하나가 정확히 무엇을 설명하고자 하는지 파악하기 어렵다.
테스트 코드는 문서로써의 역할도 수행해주어야 한다.
각각의 테스트 케이스를 분리해서 명확하게 테스트가 무엇을 검증하고자 하는지 나타내 주는것이 좋다.
TO-BE
class LottoTicketTest {
// ✅ 해피 케이스: 정상적인 로또 번호 입력
@Test
@DisplayName("로또 번호 6개가 정상적으로 입력되면 LottoTicket 객체가 생성된다.")
void shouldCreateLottoTicketWithValidNumbers() {
LottoTicket ticket = new LottoTicket(Arrays.asList(1, 2, 3, 4, 5, 6));
assertThat(ticket.getNumbers()).hasSize(6);
}
// ❌ 엣지 케이스: 중복된 숫자 포함
@Test
@DisplayName("중복된 숫자가 포함되면 예외가 발생해야 한다.")
void shouldThrowExceptionWhenNumbersContainDuplicates() {
assertThatThrownBy(() -> new LottoTicket(Arrays.asList(1, 2, 3, 3, 5, 6)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("중복된 숫자가 포함되어 있습니다.");
}
// ❌ 엣지 케이스: 범위를 벗어난 숫자 포함
@Test
@DisplayName("로또 번호가 1~45 범위를 벗어나면 예외가 발생해야 한다.")
void shouldThrowExceptionWhenNumberOutOfRange() {
assertThatThrownBy(() -> new LottoTicket(Arrays.asList(0, 2, 3, 4, 5, 6)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("로또 번호는 1부터 45까지의 숫자여야 합니다.");
}
}
개별 테스트를 분리하여 각각의 테스트가 정확히 어떠한 의도를 가지고 있는지 명확하게 드러내게 되었다.
그리고 테스트를 실패하더라도 정확히 어떠한 테스트가 실패했는지 손쉽게 파악이 가능하다.
테스트에서 컨텍스트를 유지하며 일련의 흐름을 테스트하는 경우
유혹을 이기지 못하고 하나의 테스트 코드에서 일련의 흐름을 나타내며 메서드의 호출과 단언을 반복하는 경우가 있다.
이러한 경우도 동일하다.
AS-IS
class WalletTest {
@Test
void testWalletWithMultipleValidations() {
// ✅ 1단계: 초기 지갑 생성 및 잔액 확인
Wallet wallet = new Wallet(10000);
assertThat(wallet.getBalance()).isEqualTo(10000);
// ✅ 2단계: 돈을 입금하고 확인
wallet.deposit(5000);
assertThat(wallet.getBalance()).isEqualTo(15000);
// ✅ 3단계: 돈을 출금하고 확인
wallet.withdraw(3000);
assertThat(wallet.getBalance()).isEqualTo(12000);
// ❌ 4단계: 출금 불가능한 금액 시도
assertThatThrownBy(() -> wallet.withdraw(20000))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("잔액이 부족합니다.");
}
}
Wallet의 상태를 공유하며 계속 상태를 바꿔가며 하나의 테스트에서 검증하려고 하는 모습이 보인다.
따라서 이전과 같이 동일하게 분리하는 방향으로 변경해주는게 좋다.
@Test
@DisplayName("입금하면 잔액이 증가해야 한다.")
void shouldIncreaseBalanceWhenDeposit() {
Wallet wallet = new Wallet(10000);
wallet.deposit(5000);
assertThat(wallet.getBalance()).isEqualTo(15000);
}
@Test
@DisplayName("출금하면 잔액이 감소해야 한다.")
void shouldDecreaseBalanceWhenWithdraw() {
Wallet wallet = new Wallet(10000);
wallet.withdraw(3000);
assertThat(wallet.getBalance()).isEqualTo(7000);
}
혹은 DynamicTest를 활용하여 일련의 시나리오로써 테스트를 표현하는 방식으로도 가능하다.
class WalletTest {
@TestFactory
@DisplayName("Wallet의 입출금 시나리오 테스트")
Stream<DynamicTest> sharedWalletTests() {
Wallet wallet = new Wallet(0); // 하나의 Wallet을 공유
return Stream.of(
DynamicTest.dynamicTest("5,000원 입금 후 잔액 확인", () -> {
wallet.deposit(5000);
assertThat(wallet.getBalance()).isEqualTo(5000);
}),
DynamicTest.dynamicTest("3,000원 출금 후 잔액 확인", () -> {
wallet.withdraw(3000);
assertThat(wallet.getBalance()).isEqualTo(2000);
}),
DynamicTest.dynamicTest("잔액보다 많은 금액을 출금 시도하는 경우 예외 발생", () -> {
assertThatThrownBy(() -> wallet.withdraw(30000))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("잔액이 부족합니다.");
})
);
}
}
그런데 어지간해서는 테스트 케이스를 분리하는 상황이 더 좋으니 그냥 이렇게 시나리오를 표현할 수 있구나 정도만 알아도 좋을것 같다.
'스터디 > 코드리뷰' 카테고리의 다른 글
테스트 코드에는 일련의 로직을 넣지 말자 (0) | 2025.02.23 |
---|---|
하드코딩한 매직넘버는 상수로써 표현하자 (0) | 2025.02.21 |
생성자 내부에서 너무 많은 일을 하지 말자 (0) | 2025.02.19 |
Exception을 테스트 할 때 거짓 음성을 주의하자 (0) | 2025.02.18 |
코드 리뷰 요청에 정성을 담아보자 (0) | 2025.02.18 |