한줄 요약
처음부터 테스트를 완벽히 분리하려 하지 않고, E2E → 통합 → 단위 테스트 순으로
점진적 리팩토링을 진행하며 기능 흐름 중심의 TDD 루틴을 만들었다.
실무에서는 테스트 책임을 스스로 정해야 하며, 이를 설계하는 감각이 중요하다는 점을 깨달았다.
들어가며 – 개념과 실전 사이의 간극
TDD(Test-Driven Development)는 익숙한 개념이었다.
하지만 요구사항을 분석하고, 테스트를 먼저 작성하며 개발하는 실전 경험은 처음이었다.
처음엔 이런 고민이 가장 컸다
“테스트를 어떤 기준으로 나누고, 어떤 순서로 작성해야 할까?”
실습에서는 테스트 유형이 과제로 주어졌기 때문에, 예를 들어 "이 기능은 단위 테스트로, 저 기능은 통합 테스트로 작성하세요" 같은 명확한 지시가 있었다.
그래서 테스트 책임을 스스로 판단할 필요는 없었다.
하지만 돌아보면, 실제 업무에서 TDD를 적용할 때는 이런 기준을 직접 세워야 하며,
그게 훨씬 어렵고 중요한 일이라는 사실을 이번 경험을 통해 절감했다.
테스트는 왜 이렇게 어렵게 느껴질까?
실습에서는 각 테스트 유형이 이미 정해져 있었다.
형식 검증은 단위 테스트로, 저장 여부는 통합 테스트로, 응답 흐름은 E2E 테스트로 진행하라는 식이었다.
그렇기 때문에 “이 테스트는 단위로 충분할까?”, “이건 통합 테스트가 맞나?” 같은 판단을 할 필요는 없었다.
하지만 실제 실무에서는 이런 기준을 내가 세워야 한다.
예를 들어 이런 고민이 자연스럽게 생긴다:
- 도메인 객체에서 유효성 검사를 한다 → 단위 테스트?
- 회원 가입 요청이 오면 DB에 저장해야 한다 → 통합 테스트?
- 전체 가입 플로우가 실제로 잘 작동하는가 → E2E 테스트?
이 판단은 상황, 시스템 구조, 팀의 합의에 따라 달라질 수 있다.
그래서 오히려 더 어렵고, 더 많은 고민이 필요하다.
테스트 분리는 설계의 일부이며, 코드를 직접 써보는 행위 자체가 설계를 명확히 만든다.
전략 – 흐름부터 빠르게 확인하자
처음부터 모든 컴포넌트를 잘 나누려 하기보다,
"일단 이 기능이 제대로 작동하는가?"를 먼저 확인하는 게 우선이었다.
접근 방식: 회원 가입 기능 예시
- E2E 테스트 작성 – 가입 성공/실패 시나리오, 상태 코드 및 응답 구조 확인
- 기능 구현 – 최소한의 Controller → Service → Repository 구현
- 통합 테스트 분리 – 중복 가입 여부, 저장 확인
- 단위 테스트 작성 – 입력값 유효성, 예외 처리
이 방식의 장점은 전체 흐름을 먼저 검증하고,
필요한 부분을 점진적으로 분리하며 구조를 정리해나갈 수 있다는 점이다.
실무에서는 테스트를 '설계'해야 한다
실습에서는 테스트 책임이 정해져 있었기 때문에 판단이 필요 없었지만,
실무에서는 내가 직접 테스트의 범위와 책임을 결정해야 한다.
이 기준을 어떻게 세워야 할까? 직접 해보면서 몇 가지 중요한 기준을 떠올렸다.
- 이 테스트는 무엇을 보장하고, 무엇은 생략하고 있는가?
- 실패했을 때 원인을 빠르게 좁혀나갈 수 있는 구조인가?
- 변경에 강한 테스트인가, 아니면 자주 깨지는가?
이런 기준이 명확하지 않으면, 테스트는 불필요하게 많아지거나, 반대로 충분히 보장하지 못한 채 놓칠 수 있다.
결국, TDD에서 중요한 건 테스트를 ‘작성’하는 능력이 아니라, ‘설계’하는 감각이라는 점을 실감했다.
테스트 계층 정리
- 단위 테스트
- 목적: 도메인 로직 검증
- 적용 기준: 값 유효성, 형식 검사, 예외 처리 - 통합 테스트
- 목적: 레이어 간 협력 검증
- 적용 기준: DB 저장, 중복 가입 여부 등 - E2E 테스트
- 목적: 전체 시나리오 검증
- 적용 기준: API 응답, 상태 코드, 흐름 확인
시행착오와 얻은 교훈
잘한 점
- 기능 흐름을 빠르게 검증하며 방향을 설정할 수 있었다
- 테스트 기반으로 구조를 점진적으로 리팩토링할 수 있었다
- 테스트가 안전망 역할을 하며 리팩토링에 자신감을 줬다
아쉬운 점
- 테스트 분리에 집착한 나머지 구현 속도가 느려졌다
- 일부 테스트는 검증 대상이 불명확했다
- Controller / Service 책임 분리에 미흡한 점이 있었다
테스트 코드 예시
– 단위 테스트
@DisplayName("ID 가 영문 및 숫자 10자 이내 형식에 맞지 않으면, CoreException USERID_ERROR 에러타입 이 발생한다.")
@ParameterizedTest
@ValueSource(
strings = {
"toolonguserid1", // 10자 초과
"invalid!@#", // 특수문자 포함
"한글아이디", // 한글 포함
"space id", // 공백 포함
"", // 빈 문자열
}
)
void idValidate(String userId) {
// Arrange
String email = "email@email.com";
String birthday = "1996-11-27";
Gender gender = Gender.MALE;
// Act
// Assert
assertThatThrownBy(() -> User.create(userId, email, birthday, gender))
.isInstanceOf(CoreException.class)
.extracting(e -> ((CoreException)e).getErrorType())
.isEqualTo(ErrorType.USERID_ERROR);
}
작은 단위객체의 행위에 대한 명확한 검증
- 통합 테스트
@DisplayName("회원가입이 성공하면 Point 0원이 부여된다.")
@Test
void successSignUpThenGivePointZero() {
// Arrange
String userId = "test123";
String email = "email@email.com";
String birthday = "1996-11-27";
Gender gender = Gender.MALE;
UserRegisterCommand userRegisterCommand = new UserRegisterCommand(
userId,
email,
birthday,
gender
);
// Act
userPointFacade.signUp(userRegisterCommand);
// Assert
Optional<Point> point = pointRepository.findByUserId(userId);
assertThat(point).isPresent();
assertThat(point).get().extracting(Point::getPointBalance).isEqualTo(0L);
}
여러 스프링 빈들의 상호 작용을 검증하는 테스트
- E2E 테스트
@DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
@Test
void successSignUpThenReturnUser() {
// Arrange
String userId = "test123";
String email = "email@email.com";
String birthday = "1996-11-27";
String gender = "MALE";
SignUpRequest request = new SignUpRequest(
userId,
email,
birthday,
gender
);
// Act
ResponseEntity<ApiResponse<SignUpResponse>> response = client.exchange(
"/api/v1/users",
HttpMethod.POST,
new HttpEntity<>(request),
new ParameterizedTypeReference<ApiResponse<SignUpResponse>>() {}
);
// Assert
SignUpResponse data = Objects.requireNonNull(response.getBody()).data();
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(data.userId()).isEqualTo(userId);
assertThat(data.email()).isEqualTo(email);
assertThat(data.birthday()).isEqualTo(LocalDate.parse(birthday));
assertThat(data.gender()).isEqualTo(Gender.valueOf(gender));
}
외부 클라이언트의 엔드포인트 호출에 대한 응답 검증
마무리 – 나만의 TDD 루틴을 위해
이번 경험을 통해 얻은 가장 큰 인사이트는 다음과 같다.
- 완벽한 테스트 설계보다 실행 가능한 흐름이 우선이다
- Top-Down 방식은 빠르지만, 요구사항 정리가 반드시 선행돼야 한다
- 실무에서는 테스트 책임과 범위를 스스로 정의해야 한다
- 테스트는 검증 도구이자, 설계를 유도하는 강력한 가이드다
다음엔 이렇게 해보고 싶다
- 사용자 흐름 기반의 E2E 테스트를 먼저 작성
- 기능 구현 후, 통합/단위 테스트로 점진적 분리
- 문서처럼 읽히는 테스트 코드 구조와 네이밍
- 개발보다 요구사항 정리부터 하는 습관 만들기
마치며
이번 실습은 단순히 기능을 구현하는 것을 넘어서,
"왜 이런 선택을 했는가", "실제라면 어떻게 접근했을까"를 돌아보는 기회였다.
이런 회고를 꾸준히 남기며,
나만의 테스트 루틴과 문제 해결 방식을 조금씩 만들어가고 싶다.
'Test Code' 카테고리의 다른 글
| 설계를 위한 설계 – 개발 이전, 설계의 전체 흐름을 경험하다 (0) | 2025.07.22 |
|---|