사실 테스트 코드를 학습하면서 회사에서 내가 해온 테스트 방식이 효율이 떨어진다고 생각했다.
지금까지 해온 테스트 방식은 화면을 직접 눌러보고, API는 Postman으로 호출해 보며 정상 동작되는지만 확인했고 꼼꼼한 개발자는 이 방식 대로 회원가입 테스트를 한다고 하면 테스트 코드 없이는 몇십 개에 계정이 테스트 계정으로 생길 것이다.
또한 요구사항 조건을 까먹은 기획자가 와서 나에게 물어볼 것이다.
“중복 요청은 어떻게 처리했죠?”
“이 조건은 왜 들어간 건가요?”
“어떤 경우를 실패로 봤었죠?”
기획자가 변경되거나 요구사항 문서나 피그마 링크가 사라지면 결국 기능을 개발했던 사람의 기억에 의존해야 하는데 기억이 나지 않으면 코드를 다시 읽으며 의도를 추측해야 하는 상황이 빈번하게 발생했다.
위에 상황을 테스트 코드를 작성하여 해결해 보자
Arrange-Act-Assert
테스트 코드는 크게 3가지 단계로 나누어 작성한다.
Arrange - 테스트를 위한 준비
Act - 실제 행위를 실행
Assert - 테스트 결과를 검증
항상 3단계가 모두 필요한 것은 아니다.
테스트 준비 과정이 필요하지 않다면 Act - Assert 형태로도 충분히 작성할 수 있다.
생각해 보면 내가 과거에 하던 원시적인 테스트 방식도 크게 다르지 않았다.
Arrange - POSTMAN에 URL과 파라미터를 세팅한다.
Act - 호출한다.
Assert - 결과를 확인한다.
방식은 동일하지만 사람이 직접 반복하던 과정을 테스트 코드로 자동화하고, 검증 가능하게 만든 것이 테스트 코드이다.
테스트 코드만으로 요구사항 확인하기
도메인 규칙을 테스트 코드로 한눈에 확인할 수 있다면 다른 기능을 개발할 때 훨씬 수월할 것이다.
예시로 회원이름은 2글자 이상으로 사용해야 하고 비밀번호는 8~16자 사이의 영문 대소문자, 숫자, 특수문자 이외에 사용하지 못하는 규칙이 있다면 테스트 케이스는 이렇게 되어야 한다.
"회원 모델을 생성할 때, 이름 형식에 맞지 않다면 " (X)
"회원 모델을 생성할 때, 이름이 공백이거나 2자 미만이면 " (O)
"회원 모델을 생성할 때, 비밀번호 형식에 맞지 않다면 " (X)
"비밀번호가 8~16자의 영문 대소문자, 숫자, 특수문자를 포함하지 않으면" (O)
나쁜 예의 테스트 코드는 구현부 코드를 확인하게 될 것이다.
구현부를 확인해도 정규식으로 되어 있는 규칙을 한번 더 AI한테 물어보는 상황이 발생한다.
아래 JUnit에서 활용하는 어노테이션을 사용해서 코드를 작성해 보자
| @DisplayName | 테스트 이름을 사람이 읽기 좋게 표시해주는 용도, 테스트 결과를 원하는 문장으로 보여줄 수 있음 |
| @Nested | 테스트를 그룹화할 때 사용 |
@DisplayName("회원 모델을 생성 할 때,")
@Nested
class Create {
@DisplayName("이름이 공백이거나 2자 미만이면,")
@Nested
class NameFormatValidation {
@ParameterizedTest(name = "[{index}] {0}")
@ValueSource(strings = {BLANK, SPACE, "양"})
@DisplayName("BAD_REQUEST 예외가 발생한다.")
void throwsBadRequest_whenNameIsInvalid(String invalidName) {
// act
CoreException result = assertThrows(CoreException.class, () ->
new UserModel(DEFAULT_USERID, passwordEncoder.encode(DEFAULT_PASSWORD), invalidName, DEFAULT_BIRTHDAY, DEFAULT_EMAIL)
);
// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
}
}
@DisplayName("비밀번호가 8~16자의 영문 대소문자, 숫자, 특수문자를 포함하지 않으면,")
@Nested
class PasswordFormatValidation {
@ParameterizedTest(name = "[{index}] {0}")
@ValueSource(strings = {
"abc1", // 8자 미만
"toolongpassword12345!", // 16자 초과
"한글포함패스워드", // 허용 문자 외
})
@DisplayName("BAD_REQUEST 예외가 발생한다.")
void throwsBadRequest_whenPasswordFormatIsInvalid(String invalidPassword) {
// act
CoreException result = assertThrows(CoreException.class, () ->
UserModel.validatePassword(invalidPassword, DEFAULT_BIRTHDAY)
);
// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
@Test
@DisplayName("비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.")
void throwsBadRequest_whenPasswordContainsBirthday() {
// act
CoreException result = assertThrows(CoreException.class, () ->
UserModel.validatePassword("19900101abc!", DEFAULT_BIRTHDAY)
);
// assert
assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
}
위에 테스트 코드에서
1. 이름은 2글자 이상이어야 한다.
2. 비밀번호는 8~16자 사이의 영문, 숫자, 특수문자를 포함할 수 있고 생년월일은 포함할 수 없다.
이렇게 도메인 규칙을 확인할 수 있다.
다음으로 서비스 흐름에 대해 테스트 코드를 작성해 보자
@DisplayName("비밀번호 변경 시,")
@Nested
class ChangePassword {
@DisplayName("유효한 새 비밀번호면, 비밀번호가 변경된다.")
@Test
void changesPassword_whenNewPasswordIsValid() {
// arrange
saveDefaultUser();
// act
UserModel result = sut.changePassword(DEFAULT_USERID, NEW_PASSWORD);
// assert
assertThat(passwordEncoder.matches(NEW_PASSWORD, result.getPassword())).isTrue();
}
@DisplayName("회원이 존재하지 않으면, NOT_FOUND 예외가 발생한다.")
@Test
void throwsNotFound_whenUseridDoesNotExist() {
// act
CoreException exception = assertThrows(CoreException.class, () ->
sut.changePassword(DEFAULT_USERID, DEFAULT_PASSWORD)
);
// assert
assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
}
@DisplayName("현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.")
@Test
void throwsBadRequest_whenSamePasswordIsUsed() {
// arrange
saveDefaultUser();
// act
CoreException exception = assertThrows(CoreException.class, () ->
sut.changePassword(DEFAULT_USERID, DEFAULT_PASSWORD)
);
// assert
assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
}
}
위에 서비스 코드를 보면서 확인할 수 있는 비밀번호 변경 규칙은
1. 비밀번호는 현재 비밀번호를 사용할 수 없다.
2. 회원이 존재하지 않으면 비밀번호를 변경할 수 없다.
이처럼 테스트 코드는 어떤 의도를 가졌는지 한눈에 확인하는 것이 중요하다.
* 인텔리제이를 사용한다면 Setting > Build, Execution, Deployment > Build Tools > Gradle 아래 옵션을 Intelij로 변경해야 내가 작성한 테스트 케이스가 나온다.

테스트 더블로 무엇을 검증할까?
테스트 더블은 테스트 과정에서 실제 객체 대신 사용하는 대체 객체를 의미한다.
대표적으로 Mock과 Fake 가 있는데 각각 살펴보면
Mock : "행위(상호작용) 검증"에 초점이 맞춰진 테스트 더블이다.
즉, 특정 메서드가 호출되었는지? 몇 번 호출되었는지? 어떤 값으로 호출되었는지?
를 검증하기 위해 사용한다.
Fake : 실제 객체처럼 동작하도록 만든 가짜 구현체
단순히 호출 여부만 검증하는 것이 아니라, 실제 데이터 저장이나 상태 변화까지 직접 수행한다.
Mock과 Fake 객체를 사용해서 현업에서 만들었던 정기결제 테스트 코드를 작성해 보자
@ExtendWith(MockitoExtension.class)
class BillingServiceTest {
@Mock
private PgClient pgClient;
private FakeBillingRepository billingRepository;
private BillingService billingService;
@BeforeEach
void setUp() {
billingRepository = new FakeBillingRepository();
billingService = new BillingService(
billingRepository,
pgClient
);
}
@Test
@DisplayName("정기 결제 성공 시 결제 상태를 저장한다")
void pay_success() {
// Arrange
BillingPayCommand command =
new BillingPayCommand(
1L,
10000
);
given(pgClient.pay(any()))
.willReturn(
new PgResponse(
true,
"SUCCESS"
)
);
// Act
billingService.pay(command);
// Assert
Billing billing =
billingRepository.findByUserId(1L)
.orElseThrow();
assertThat(billing.getStatus())
.isEqualTo(PaymentStatus.PAID);
verify(pgClient).pay(any());
}
}
실제 외부 서버의 구조를 알지 못하고 인터페이스로 연결되어 있어 기댓값 검증에선 Mock의 강점이 두드러진다.
public class FakeBillingRepository
implements BillingRepository {
private final List<Billing> storage =
new ArrayList<>();
@Override
public Billing save(Billing billing) {
storage.add(billing);
return billing;
}
@Override
public Optional<Billing> findByUserId(Long userId) {
return storage.stream()
.filter(it ->
it.getUserId().equals(userId)
)
.findFirst();
}
}
Mock과 Fake는 서로 대체 관계라기보다 검증 목적에 따라 선택하는 도구에 가깝다
내부 DB 영역처럼 서비스 내부 데이터 흐름과 상태 변화가 중요한 경우에는 Fake 객체를 In-Memory 기반으로 구현하여 사용하는 방식이 더 적합하고 실제 저장, 조회, 상태 변경 흐름을 함께 검증할 수 있기 때문에, 단순 호출 여부보다 결과 중심으로 테스트를 작성해야만 하고 PG사 연동과 같은 외부 API 영역은 실제 서버와 통신하기보다 예상 응답값과 호출 흐름을 검증하는 것이 더 적절했다.
결국 테스트 코드는 도메인 규칙과 비즈니스 흐름이 자연스럽게 읽히도록 작성하는 것이 중요하고 테스트 검증은 하나의 방식만으로 해결하기보다, 무엇을 검증하려는지에 따라 Mock과 Fake를 적절히 선택하고 적용하는 것이 중요하다.
'루퍼스 4기' 카테고리의 다른 글
| 쇼핑몰에서 안전하게 재고 차감하기 (0) | 2026.06.12 |
|---|---|
| 주문 생성 흐름에서 책임을 어디까지 나눌 것인가 (0) | 2026.05.29 |
| 루퍼스 2주차 WIL (소프트웨어 설계) (0) | 2026.05.24 |
| 이커머스 도메인을 설계하면서 나는 어떤 고민을 했을까 (0) | 2026.05.22 |
| 루퍼스 1주차 WIL (TDD & 테스트 가능한 구조) (0) | 2026.05.15 |