[TDD] TDD 해보자!
서론
AI 코딩이 판을 치는 이 개발자 시대에서 언뜻 회의를 조금 느꼈다.
인생의 여러 갈래에서 개발자가 되고자 한 이유는
내 손으로 직접 프로덕트를 만들 수 있다는 점
Coding이라는 행위 자체의 즐거움
이 두 가지였다.
그런데 개발자 취업 준비를 하면서, 이 두 가지는 더 쉬워졌는데
나에게는 더욱 공허함이 생겼다.
이러한 개발 트렌드에서 최근 많은 사람들이 이제는 개발자가 코드를 짜지 않고 마크다운 문서를 작성하는데 더욱 힘을 쏟고, 이제 바이브 코딩이 아닌 하네스 코딩시대로 흘러가고 있다.
여기서 하네스 코딩이라는 것은 규칙과 제약으로 AI 코딩을 수행하는 것이다.
그러면서, 나는 현 시대에서 개발자에게 가장 중요한 것은 다음이 아닐까라고 생각이 들었다.
아키텍트가 되기
테스트 코드 검증하기
내부 동작 원리 파악하기
도메인 문서 파악하기
도메인 문서 파악하기는 나의 개발 모토인데, 나는 도메인이 아닌 문서가 중심인 DDD(Document Driven Development)를 나의 개발 모토로 가지고 있고 개발을 잘하기 위해, 좋은 개발자가 되기 위해 많은 공식 문서를 읽고 있다.
그래서 오늘 다루고 싶은 주제는 테스트 코드 작성법에 대해 공부하고 적용했던 것을 공유해보고자 한다.
TDD, 켄트 백
너무 잘 알고 있겠지만, TDD(Test Driven Development)라는 개발 방법론은 켄트백이 제안한 것이다.
기존에 테스트를 먼저 작성하는 방식(test-first)라는 것은 TDD 이전에 이미 있었다고 하지만,
이를 하나의 개발 방법론으로 제안한 것은 켄트백의 Extreme Programming Explained(1999) 그리고 Test-Driven Development: By Example(2002)이다.
TDD의 목적은 "clean code that works"를 만드는 것으로 테스트를 많이 작성하는 것이 아니라, 작은 피드백 루프로 설계가 성장하도록 하고, 변경에 대한 공포를낮추고, 자신 있게 다음 변경을 할 수 있도록 하는 것이다.
그래서 Test Code는 목적을 달성하게 하는 도구이고,Red -> Green -> Refactor의 순환 패턴은 테스트 코드를 작성하는 패턴이 아니라,
개발 사이클 전체의 패턴이다.
따라서 테스트만 따로 사용하고 끝내는 것이 아니라
실패하는 테스트를 작성하고
프로덕션 코드를 최소한으로 구현하고
구조를 정리한 뒤
다시 다음 작은 테스트로 넘어간다.
작은 단위의 애자일 개발과 비슷한 방식이다.
예시로 다음과 같다.
닉네임이 없으면 예외 발생
닉네임이 있으면 저장 성공
중복 닉네임이면 충돌 발생
TDD, Demo
나는 TDD에 대해 공부하고 내 사이드 프로젝트에 적용하면서 다음 2가지의 목표를 정했다.
** 1. clean code that works**
** 2. Red -> Green -> Refactor**
그러나 위 2가지 원칙과 목표로 TDD 기반 애플리케이션 개발을 하는데,
실제 TDD를 해보니까 마냥 TDD의 개념처럼 단순하지 않았다.
TDD의 본질은 동일한데, 테스트의 단위가 달라진다.
먼저, 아키텍처 구조에 따라 테스트의 분리가 달라진다.
레이어드 아키텍처
보통 controller / service / repository 같은 레이어 단위를 가지므로 이와 동일한 단위로 테스트를 분리한다.
그래서 ServiceTest, ControllerTest, RepositoryTest가 자연스러운 형태가 된다.
클린 아키텍처(헥사고날 아키텍처)
보통 use case / port / adapter 기준으로 테스트를 나눈다.
핵심 비즈니스 규칙은 프레임워크 없이 use case 테스트로 먼저 잡고
DB, HTTP, 외부 OAuth 같은 건 adapter 테스트로 따로 검증한다.
즉 테스트가 기술 레이어보다 행동 경계를 더 강하게 반영한다.
그래서 위 2가지 구조에서는 다음과 같은 차이점이 생긴다.
레이어드 아키텍처
이 서비스 메서드가 repository.save를 호출했는가?
이 컨트롤러가 200/400을 반환하는가?
클린(헥사고날) 아키텍처
이 유스케이스가 어떤 입력에서 어떤 비즈니스 결과를 내는가?
포트 구현체(adapter)가 DB/API와 올바르게 연결되는가?
즉,
레이어드: 기술 계층 중심의 테스트 구조를 가짐
클린: 핵심 로직 테스트와 기술 통합 테스트로 분리됨.
그런데, 테스트를 자르는 단위(기준)은 다르지만 테스트 작성 원리는 동일하다.
그래서 오늘 다루고 싶은 내용은 먼저 내가 레이어드 아키텍처를 중심으로 사이드 프로젝트를 어떻게 수행했는지, 데모 코드로 보여주고자 한다.
레이어드 아키텍처
먼저 레이어드 아키텍처에 대해 잘 모르는 사람에게 간단하게 설명하자면,
애플리케이션을 역할별 층(layer)로 나눈 구조이다.
보통 왼쪽에서 오른쪽으로
Controller -> Service -> Repository -> Database의 흐름을 가지며, 각각은 다음 역할을 한다.
Controller: HTTP 요청/응답 처리
Service: 비즈니스 로직 처리
Repository: DB 접근 담당
Database: 실제 데이터 저장소
레이어드 아키텍처의 목적은 위 구조로 요청 처리, 비즈니스, DB 접근에 대한 관심사 분리이다.
레이어드 아키텍처에서의 TDD
위에서도 설명했듯이 레이어드 아키텍처에서의 TDD는 다음과 같은 기준을 가지면 된다.
Controller
HTTP 요청/응답, validation, status code, error body
비즈니스 규칙 테스트를 여기서 하지 않는다.
Service
TDD의 중심으로 비즈니스 규칙, 상태 변경, 예외 흐름을 여기서 고정한다.
Repository
DB 매핑, query method, persistence 동작만 검증한다.
규칙을 여기서 검증하지 않는다.
즉, 레이어드 아키텍처에서의 TDD는 service 중심으로 이루어진다고 보면 된다.
그래서 나는 다음과 같은 순서로 진행했다.
service test 고정
red -> green: 최소 service 코드로 작동하는 코드 작성
service 의존 repository는 mock으로 설정
controller test로 HTTP 계약 고정
필요하면 repository test로 DB 동작 검증
그리고 Spring Boot 환경에서 수행했으므로,
테스트는 JUnit 5, Mockito, MockMvc, @DataJpaTest, H2를 기준으로 구성했다.
BDD
이번 데모에서 또 한 가지 다루었던 방법으로 테스트 코드를
given / when / then 구조로 작성했다.
BDD는 Behavior-Driven Development를 의미하고 TDD와 완전히 다른 방법론이라기보다,
테스트를 행동 중심으로 정리해서 작성하는데 도움이 되서 나는 이 방식으로 테스트 코드를 작성하고 있고 테스트 패키지도 지원하고 있다.
BDD의 given / when / then은 각각 다음을 의미한다.
given: 어떤 상황이 주어졌는가?
when: 어떤 행동을 수행하는가?
then: 어떤 결과가 나와야하는가?
이런 구조로 테스트를 나누면,
테스트가 단순히 메서드를 호출하는 코드가 아니라 하나의 요구사항처럼 읽히게 된다.
따라서 이번 글에서는 TDD의 개발 사이클(Red -> Green -> Refactor) 위에서
BDD 스타일 표현(given / when / then)을 함께 사용했다고 보면 된다.
Domain
이번 데모에서 다루는 도메인은 Profile 생성이다.
즉, 사용자가 프로필 생성 요청을 보내면 시스템이 유효한 프로필을 만들고 저장하는 흐름을 중심으로 TDD를 진행했다.
이번 도메인에서 먼저 고정한 핵심 규칙은 다음과 같다.
유효한 입력이면 프로필이 생성되어야 한다.
email, nickname, passwordHash, region, grade, ageGroup, gender는 필수값이어야 한다.
이미 사용 중인 email이면 프로필 생성이 실패해야 한다.
tag는 사용자가 입력하는 값이 아니라 서버가 생성하는 값이다.
tag는 4자리 문자열이며, 중복되지 않아야 한다.
이를 기준으로 Profile 엔티티의 필드를 다음과 같이 정의했다.
필드
타입
생성 요청 포함
필수 여부
설명
id
UUID
아니오
시스템 관리
JPA에서 UUID로 생성되는 식별자
email
String
예
예
프로필의 고유 식별 이메일, 중복 불가
nickname
String
예
예
사용자 표시명
passwordHash
String
예
예
원문 비밀번호가 아니라 해시된 값
profileImageUrl
String
예
아니오
프로필 이미지 URL
tag
String
아니오
예
서버가 생성하여 할당하는 4자리 프로필 태그, 중복 불가
region
Region
예
예
사용자 지역 정보 (SEOUL ~ JEJU)
grade
Grade
예
예
사용자 등급 (E, D, C, B, A, S, SS)
ageGroup
AgeGroup
예
예
연령대 (TEENS, TWENTIES, THIRTIES, FORTIES, FIFTIES, SIXTIES)
gender
Gender
예
예
성별 (MALE, FEMALE)
createdAt
LocalDateTime
아니오
시스템 관리
엔티티 최초 저장 시 자동 기록
updatedAt
LocalDateTime
아니오
시스템 관리
엔티티 수정 시 자동 갱신
테스트 환경과 진행 방식
이번 데모는 Spring Boot 4.0.3, Java 21 환경에서 진행했다.
그리고 레이어드 아키텍처를 기준으로 service -> controller -> repository 순서로 테스트를 확장했다.
먼저 사용한 주요 의존성은 다음과 같다.
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
각 의존성은 다음 역할을 한다.
spring-boot-starter-webmvc
컨트롤러와 HTTP 요청/응답 처리
spring-boot-starter-validation
@Valid 기반 요청 검증
spring-boot-starter-data-jpa
JPA 엔티티와 Repository 구성
h2
테스트용 인메모리 데이터베이스
spring-boot-starter-webmvc-test
@WebMvcTest, MockMvc를 이용한 컨트롤러 테스트
spring-boot-starter-data-jpa-test
@DataJpaTest를 이용한 리포지토리 테스트
JUnit 5
테스트 실행 프레임워크
Mockito
서비스 테스트와 컨트롤러 테스트에서 의존성 분리
이번 데모는 켄트 백의 Red -> Green -> Refactor 흐름에 맞춰 진행했다.
처음부터 모든 계층을 한 번에 구현하는 것이 아니라, 먼저 서비스 테스트로 핵심 규칙을 고정하고, 최소 구현으로 통과시킨 뒤, 그 다음 컨트롤러 테스트로 HTTP 계약을 고정하고, 마지막으로 리포지토리 테스트에서 실제 DB 동작을 검증하는 방식으로 가져갔다.
즉, 이번 데모의 흐름은 다음과 같다.
ProfileServiceTest로 비즈니스 규칙 고정
ProfileServiceImpl 최소 구현
ProfileControllerTest로 HTTP 계약 고정
ProfileRepositoryTest로 query method와 persistence 동작 검증
이 순서를 선택한 이유는 레이어드 아키텍처에서 핵심 도메인 규칙이 service에 가장 많이 모이기 때문이다.
서비스 테스트
서비스 테스트는 ProfileServiceTest를 중심으로 작성했다.
여기서는 Spring Context를 띄우지 않고 JUnit 5 + Mockito 조합으로 순수 단위 테스트를 수행했다.
사용한 구성은 다음과 같다.
@ExtendWith(MockitoExtension.class)
JUnit 5 환경에서 Mockito를 사용하기 위한 확장 설정
@Mock
ProfileRepository, ProfileTagGenerator를 mock으로 대체
@InjectMocks
ProfileServiceImpl에 mock 의존성을 주입
서비스 테스트의 목적은 다음 규칙을 먼저 고정하는 것이었다.
유효한 입력이면 프로필 생성에 성공해야 한다.
필수값이 비어 있으면 생성이 실패해야 한다.
이미 사용 중인 이메일이면 생성이 실패해야 한다.
태그는 서버가 생성하며, 중복되면 안 된다.
예를 들어 성공 케이스는 다음과 같이 작성했다.
@ExtendWith(MockitoExtension.class)
class ProfileServiceTest {
@Mock
private ProfileRepository profileRepository;
@Mock
private ProfileTagGenerator profileTagGenerator;
@InjectMocks
private ProfileServiceImpl profileService;
@Test
@DisplayName("유효한 입력이면 프로필 생성에 성공한다")
void createProfile_validInput_success() {
// given
String validEmail = "myEmail@email.com";
CreateProfileRequest request = createRequest(validEmail);
given(profileTagGenerator.generate()).willReturn("AB12");
given(profileRepository.existsByTag("AB12")).willReturn(false);
given(profileRepository.save(any(Profile.class)))
.willAnswer(invocation -> invocation.getArgument(0));
// when
Profile profile = profileService.create(request);
// then
assertEquals(validEmail, profile.getEmail());
assertEquals("myNickname", profile.getNickname());
assertEquals("AB12", profile.getTag());
then(profileRepository).should().save(any(Profile.class));
}
}
이 테스트에서 중요한 것은 단순히 “생성 성공”만 보는 것이 아니라,
실제로 tag가 서버에서 생성되어 엔티티에 할당되었는지까지 함께 검증했다는 점이다.
반대로 실패 케이스는 다음과 같이 고정했다.
@Test
@DisplayName("중복 이메일이면 save()가 호출되지 않는다.")
void createProfile_duplicateEmail_doNotSave() {
// given
String duplicatedEmail = "myEmail@email.com";
CreateProfileRequest request = createRequest(duplicatedEmail);
given(profileRepository.existsByEmail(duplicatedEmail))
.willReturn(true);
// when & then
assertThrows(IllegalArgumentException.class, () -> profileService.create(request));
then(profileRepository).should(never()).save(any(Profile.class));
}
즉, 서비스 테스트에서는 “예외가 발생하는가”만 보는 것이 아니라,
실패했을 때 저장이 실제로 일어나지 않았는지까지 함께 고정했다.
이 테스트를 통과시키기 위한 실제 서비스 구현은 다음과 같다.
@Service
@Transactional
@RequiredArgsConstructor
public class ProfileServiceImpl implements ProfileService {
private final ProfileRepository profileRepository;
private final ProfileTagGenerator profileTagGenerator;
@Override
public Profile create(CreateProfileRequest request) {
validateRequiredFields(request);
validateDuplicateEmail(request.email());
String tag = generateUniqueTag();
Profile profile = Profile.create(
request.email(),
request.nickname(),
request.passwordHash(),
request.profileImageUrl(),
tag,
request.region(),
request.grade(),
request.ageGroup(),
request.gender()
);
return profileRepository.save(profile);
}
private String generateUniqueTag() {
do {
String tag = profileTagGenerator.generate();
if (!profileRepository.existsByTag(tag)) {
return tag;
}
} while (true);
}
}
즉, 서비스 테스트는 외부 기술보다
“비즈니스 규칙과 상태 변화”를 먼저 고정하는 데 집중했다고 보면 된다.
태그 생성기 테스트
이번 도메인에서는 tag가 사용자가 입력하는 값이 아니라 서버가 생성하는 값이다.
그래서 서비스 테스트 외에도 생성 규칙 자체를 별도의 단위 테스트로 분리했다.
이 테스트는 RandomProfileTagGeneratorTest에서 검증했다.
현재 프로젝트에서는 tag를 대문자 영어 + 숫자 조합의 4자리 문자열로 생성한다.
class RandomProfileTagGeneratorTest {
private final RandomProfileTagGenerator tagGenerator = new RandomProfileTagGenerator();
@Test
@DisplayName("생성된 태그는 4자리이다.")
void generate_returnsFourCharactersTag() {
String tag = tagGenerator.generate();
assertEquals(4, tag.length());
}
@Test
@DisplayName("생성된 태그는 대문자 영어와 숫자로만 구성된다.")
void generate_returnsUppercaseLettersAndDigitsOnly() {
String tag = tagGenerator.generate();
assertTrue(tag.matches("[A-Z0-9]{4}"));
}
}
즉, 서비스는 “태그를 어떻게 사용할 것인가”를 담당하고,
생성기는 “태그 형식 규칙”을 담당하도록 책임을 분리했다.
컨트롤러 테스트
컨트롤러 테스트는 ProfileControllerTest를 기준으로 작성했다.
여기서는 전체 애플리케이션을 띄우지 않고 MVC 계층만 검증하기 위해 @WebMvcTest와 MockMvc를 사용했다.
사용한 구성은 다음과 같다.
@WebMvcTest
웹 계층만 로드하는 테스트 슬라이스
MockMvc
실제 HTTP 요청처럼 컨트롤러 호출
@MockitoBean
컨트롤러가 의존하는 ProfileService를 mock으로 등록
ObjectMapper
요청 DTO를 JSON 문자열로 변환
컨트롤러 테스트의 핵심은 비즈니스 규칙을 다시 테스트하는 것이 아니라,
HTTP 계약을 고정하는 것이다.
즉,
정상 요청이면 어떤 상태 코드를 주는가
서비스에 요청을 위임하는가
잘못된 요청이면 어떤 상태 코드를 주는가
이 3가지를 보는 데 집중했다.
성공 케이스는 다음과 같이 작성했다.
@WebMvcTest
class ProfileControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private ProfileService profileService;
@Test
@DisplayName("유효한 요청이면 201을 반환한다.")
void createProfile_validRequest_returnCreated() throws Exception {
// given
CreateProfileRequest request = new CreateProfileRequest(
"myEmail@email.com",
"myNickname",
"HashedPassword",
"myProfileImageUrl",
Region.SEOUL,
Grade.D,
AgeGroup.TWENTIES,
Gender.MALE
);
String requestBody = objectMapper.writeValueAsString(request);
Profile profile = Profile.create(
"myEmail@email.com",
"myNickname",
"HashedPassword",
"myProfileImageUrl",
"AB12",
Region.SEOUL,
Grade.D,
AgeGroup.TWENTIES,
Gender.MALE
);
given(profileService.create(any())).willReturn(profile);
// when & then
mockMvc.perform(post("/profiles")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated());
then(profileService).should().create(any(CreateProfileRequest.class));
}
}
즉, 이 테스트는 두 가지를 함께 본다.
POST /profiles 요청에 대해 201 Created를 반환하는가
컨트롤러가 요청을 실제로 서비스에 위임하는가
잘못된 enum 값에 대한 400도 컨트롤러 테스트에서 검증했다.
@Test
@DisplayName("잘못된 region 값이면 400을 반환한다.")
void createProfile_invalidRegion_returnsBadRequest() throws Exception {
// given
String requestBody = """
{
"email": "myEmail@email.com",
"nickname": "myNickname",
"passwordHash": "HashedPassword",
"profileImageUrl": "myProfileImageUrl",
"region": "INVALID",
"grade": "D",
"ageGroup": "TWENTIES",
"gender": "MALE"
}
""";
// when & then
mockMvc.perform(post("/profiles")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
또한 서비스 계층에서 던진 예외를 HTTP 상태 코드로 바꾸기 위해 전역 예외 처리기도 추가했다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Void> handleIllegalArgumentException(
IllegalArgumentException e
) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
이를 기반으로 중복 이메일과 같은 예외가 409 Conflict로 변환되는지도 테스트할 수 있었다.
@Test
@DisplayName("중복 이메일이면 409를 반환한다.")
void createProfile_duplicateEmail_returnsConflict() throws Exception {
// given
String requestBody = """
{
"email": "myEmail@email.com",
"nickname": "myNickname",
"passwordHash": "HashedPassword",
"profileImageUrl": "myProfileImageUrl",
"region": "SEOUL",
"grade": "D",
"ageGroup": "TWENTIES",
"gender": "MALE"
}
""";
given(profileService.create(any(CreateProfileRequest.class)))
.willThrow(new IllegalArgumentException("이미 사용 중인 이메일입니다."));
// when & then
mockMvc.perform(post("/profiles")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isConflict());
}
즉, 컨트롤러 테스트는 도메인 규칙 테스트가 아니라
“HTTP 경계 테스트”로 가져가는 것이 중요했다.
리포지토리 테스트
리포지토리 테스트는 ProfileRepositoryTest를 기준으로 작성했다.
여기서는 mock을 사용하지 않고, 실제 JPA와 H2 DB를 붙여 query method와 영속성 동작을 검증했다.
사용한 구성은 다음과 같다.
@DataJpaTest
JPA 관련 빈만 로드하는 테스트 슬라이스
H2 in-memory DB
테스트용 데이터베이스
실제 ProfileRepository
existsByEmail, existsByTag, saveAndFlush 검증
리포지토리 테스트의 목적은 서비스 규칙을 다시 검증하는 것이 아니다.
대신 “이 매핑과 query method를 실제 DB에서 믿을 수 있는가”를 확인하는 것이다.
예를 들어 existsByEmail()는 다음과 같이 테스트했다.
@DataJpaTest
class ProfileRepositoryTest {
@Autowired
private ProfileRepository profileRepository;
@Test
@DisplayName("존재하는 이메일이면 existsByEmail은 true를 반환한다.")
void existsByEmail_existingEmail_returnsTrue() {
// given
Profile profile = createProfile("myEmail@email.com", "AB12");
profileRepository.saveAndFlush(profile);
// when
boolean result = profileRepository.existsByEmail("myEmail@email.com");
// then
assertTrue(result);
}
@Test
@DisplayName("존재하지 않는 이메일이면 existsByEmail은 false를 반환한다.")
void existsByEmail_nonExistingEmail_returnsFalse() {
// when
boolean result = profileRepository.existsByEmail("nonExistedEmail@email.com");
// then
assertFalse(result);
}
}
같은 방식으로 existsByTag()도 검증했다.
@Test
@DisplayName("존재하는 태그이면 existsByTag는 true를 반환한다.")
void existsByTag_existingTag_returnsTrue() {
// given
Profile profile = createProfile("myEmail@email.com", "AB12");
profileRepository.saveAndFlush(profile);
// when
boolean result = profileRepository.existsByTag("AB12");
// then
assertTrue(result);
}
또한 saveAndFlush() 이후 id, createdAt, updatedAt이 실제로 채워지는지도 확인했다.
@Test
@DisplayName("프로필 저장 시 id, createdAt, updatedAt이 저장된다.")
void save_profile_persistsAuditFields() {
// given
Profile profile = createProfile("email3@test.com", "EF56");
// when
Profile savedProfile = profileRepository.saveAndFlush(profile);
// then
assertNotNull(savedProfile.getId());
assertNotNull(savedProfile.getCreatedAt());
assertNotNull(savedProfile.getUpdatedAt());
}
즉, 리포지토리 테스트는 mock 기반의 서비스 테스트로는 확인할 수 없는
JPA 매핑, 쿼리 메서드, 라이프사이클 콜백을 실제 DB와 함께 검증하는 역할을 한다.
마치며,,,
테스트 코드에 대해 오늘 다루어봤다.
테스트를 작성하면서 느낀 것은
clean code that works에서 중요한 것은 단순히 동작 여부만이 아니라,
변경을 더 안전하게 만들 수 있는 상태라는 점이었다.
테스트를 기능 요구사항과 실제 코드를 거의 1대1로 매칭할 수 있게 한다고 느꼈다.
그러나, 여기서 거의 1대1이라고 하는 이유는 테스트가 모든 것을 해결해주지는 않기 때문이다.
예시로 tag에 관련된 테스트를 작성하기 전에 다음과 같이 작성했었다.
@Test
@DisplayName("유효한 입력이면 프로필 생성에 성공한다")
void createProfile_validInput_success() {
// given
String validEmail = "myEmail@email.com";
CreateProfileRequest request = createRequest(validEmail);
given(profileRepository.save(any(Profile.class)))
.willAnswer(invocation -> invocation.getArgument(0));
// when
Profile profile = profileService.create(request);
// then
assertEquals(validEmail, profile.getEmail());
assertEquals("myNickname", profile.getNickname());
assertEquals("HashedPassword", profile.getPasswordHash());
assertEquals("myProfileImageUrl", profile.getProfileImageUrl());
assertEquals(Region.SEOUL, profile.getRegion());
assertEquals(Grade.D, profile.getGrade());
assertEquals(AgeGroup.TWENTIES, profile.getAgeGroup());
assertEquals(Gender.MALE, profile.getGender());
then(profileRepository).should().save(any(Profile.class));
}
이 테스트는 tag 기능 도입 전에 작성된 테스트인데,
tag가 서버 생성 값으로 바뀌면서 테스트 계약도 함께 변경되어야 했다.
그런데 기존 테스트는 여전히 통과할 수 있었다.
즉, 테스트가 초록불이라고 해서
항상 충분한 테스트가 되었다는 뜻은 아니었다.
이러한 경우 기존 테스트를 수정하거나, 새로운 테스트를 추가해야한다.
이런 경우에 새롭게 어떻게 처리해야하는지 한 번 생각해보고 다음번에 공유해보고자 한다.
그리고 서론에서 AI 코딩에 대해서 언급했는데, 요즘 여러 AI코딩 덕분에 개발을 하는데 즐거움을 찾기 어려웠었다.
직접 코드를 작성하는 시간도 줄어들면서, 실제 문제를 혼자서 고민하는 시간도 줄어드는 것 같았다.
그래서 내가 공부한 내용을 데모 프로젝트로 직접 작성하는 것을 시작했는데,
이 덕분에 매우 코딩을 하는게 재미있었고 실제로 혼자 고민하는 시간도 늘어가면서 실력도 쌓이고 있는 것 같다.
그리고 조금 여러가지를 다루고 싶은 마음에 지금 TDD, 인프라, 아키텍처 등을 동시에 다루고 있는데, 이렇게 하는게 재미있어서 여러가지를 많이 다루고 싶다보니 그렇게 되었다.
그래서 이제는 조금 연속성을 가지고 블로그를 작성해보려고 한다.
참고 문서
Dan North, Introducing BDD
Cucumber Docs, Behaviour-Driven Development
Cucumber Docs, Gherkin Reference
JUnit User Guide
https://docs.junit.org/current/user-guide/
Mockito 공식 사이트
https://site.mockito.org/
Mockito BDDMockito Javadoc
https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/BDDMockito.html
Martin Fowler, Test Double
Spring Boot Testing 문서
https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html
Spring Framework MockMvc 문서
https://docs.spring.io/spring-framework/reference/testing/mockmvc.html
Spring Framework Test Bean Override 문서 (@MockitoBean)
https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/bean-overriding.html
카카오 TDD 블로그