솔리드 커넥션에 신규 개발자로 팀에 합류한 뒤, 테스트 코드의 일관성과 품질을 높이기 위해 다양한 리팩토링을 진행해왔다.
초기 논의 끝에 단위 테스트 → BDD Mockito 기반 통합 테스트로 테스트 전략을 정했고,
이에 맞춰 테스트 데이터 설정 방식도 도메인 객체를 생성하는 함수를 제공하는 형태로 fixture를 제공하도록 리팩토링했다.
초기 관련 논의들 : BDD Mockito vs Mockito, mock 을 사용한 단위 테스트 vs 통합 테스트, 테스트 코드 패키지 구조 변경
이 글에서는 그 과정을 돌아보며, 왜 그렇게 했는지, 어떤 고민이 있었는지를 정리해보려 한다.
처음에는 별도의 컨벤션이 존재하지 않았고 주로 각 테스트 클래스 내부에 private factory method를 사용해왔다.
🔽 예시
// MyPageServiceTest
@Test
void 마이페이지_정보를_조회한다() {
// given
SiteUser testUser = createSiteUser();
// when
MyPageResponse response = myPageService.getMyPageInfo(testUser);
// then
(생략)
}
private void createSiteUser() {
SiteUser siteUser = new SiteUser(
"siteUser@example.com",
"nickname",
"profileImageUrl",
PreparationStatus.CONSIDERING,
Role.MENTEE
);
siteUserRepository.save(siteUser);
}
이 방식은 독립적인 테스트를 구성하기엔 좋았지만, 필드 하나만 바뀌어도 수십 개 테스트에서 관련 메서드들을 수정해주어야 했다.
유저 관련 컬럼 두 개를 삭제하는 pr이었는데 테스트 코드 관련 클래스 파일 23개를 수정하는 파급효과가 있었다 😰 관련 PR 링크
이러한 문제를 해결하고자 BaseIntegrationTest라는 상속 기반 통합 테스트 클래스를 정의하고, 내부에 static 필드로 fixture 데이터를 선언해 모든 테스트에서 사용할 수 있도록 했다.
🔽 예시
@TestContainerSpringBootTest
@ExtendWith(DatabaseClearExtension.class)
public abstract class BaseIntegrationTest {
public static SiteUser 테스트유저_1;
(생략)
@BeforeEach
public void setUpBaseData() {
setUpSiteUsers();
(생략)
}
private void setUpSiteUsers() {
테스트유저_1 = siteUserRepository.save(new SiteUser(
"test1@example.com",
"nickname1",
"profileImageUrl",
PreparationStatus.CONSIDERING,
Role.MENTEE));
(생략)
}
(생략)
}
// MyPageServiceTest
@Test
void 마이페이지_정보를_조회한다() {
// when
MyPageResponse response = myPageService.getMyPageInfo(테스트유저_1);
// then
(생략)
}
이 방식은 static 필드를 통해 모든 테스트에서 재사용 가능한 데이터를 제공했기 때문에, 각 테스트에서 별도로 데이터를 생성하지 않아도 되어 given 절을 간결하게 만들 수 있었다.
덕분에 테스트 코드에서는 핵심 로직 검증에만 집중할 수 있었고, 초반 개발 생산성도 높았다.
하지만 시간이 지날수록 모든 테스트가 동일한 기본 데이터를 공유한다는 점이 문제로 드러나기 시작했다.
각 테스트가 의도하지 않은 데이터를 암묵적으로 참조하게 되면서, 테스트의 독립성이 깨지고 예상치 못한 테스트 실패가 발생하는 상황이 생겼다. 🥲 관련 PR 링크
BaseIntegrationTest 방식의 문제를 해결하기 위해, 도메인별 fixture 클래스를 생성하고, 필요할 때마다 객체를 생성해 사용하는 방식으로 리팩토링했다.
각 도메인(SiteUser, University, Application 등)에 대해 fixture 클래스를 만들고, 그 내부에 테스트 목적에 맞는 데이터를 메서드 체이닝 방식으로 생성할 수 있게 했다.
🔽 예시
@TestComponent
@RequiredArgsConstructor
public class SiteUserFixture {
private final SiteUserFixtureBuilder siteUserFixtureBuilder;
public SiteUser 사용자() {
return siteUserFixtureBuilder.siteUser()
.email("test@example.com")
.authType(AuthType.EMAIL)
.nickname("사용자")
.profileImageUrl("profileImageUrl")
.role(Role.MENTEE)
.password("password123")
.create();
}
}
// MyPageServiceTest
@Test
void 마이페이지_정보를_조회한다() {
// given
SiteUser user = siteUserFixture.사용자();
// when
MyPageResponse response = myPageService.getMyPageInfo(user);
// then
(생략)
}
관련 구현 PR들 : University, SiteUser, Score, Application, Community
BaseIntegrationTest에서 static 필드로 데이터를 공유할 때 가장 큰 문제는, 테스트 실행 순서나 상태 변화에 따라 테스트가 의도치 않게 깨지는 일이 발생한다는 점이었다.
그래서 각 테스트마다 fixture 함수를 호출해 명시적으로 객체를 생성하는 방식으로 전환했다.
오버로딩 방식보다 메서드 체이닝 방식이 훨씬 직관적이고 확장에 유리했다.
SiteUserFixture, UniversityFixture, ApplicationFixture처럼 도메인 단위로 분리해서 관리하면 변경이 발생했을 때 영향을 받는 범위를 해당 fixture 내부로 한정시킬 수 있다.
예시로, 테스트에서 University를 만들려면 Country, Region이 먼저 있어야 하고, Country를 만들려면 Region이 있어야 한다.
이걸 테스트 코드에서 매번 직접 생성하고 순서 맞추는 건 번거롭고, 실수하기도 쉽다.
그래서 각 fixture 내부에서 연관관계를 자동으로 생성하거나 찾아서 넣어주는 방식으로 구성했다.
// UniversityFixture
public University 괌_대학() {
return universityFixtureBuilder.university()
.koreanName("괌 대학")
.englishName("University of Guam")
.country(countryFixture.미국()) // 내부적으로 region까지 생성
.region(regionFixture.영미권()) // 직접 참조해도 가능
.create();
}
// CountryFixture
public Country 미국() {
return countryFixtureBuilder.country()
.code("US")
.koreanName("미국")
.region(regionFixture.영미권()) // 선행 관계를 내부에서 해결
.findOrCreate();
}
// RegionFixture
public Region 영미권() {
return regionFixtureBuilder.region()
.code("AMERICAS")
.koreanName("영미권")
.findOrCreate();
}
이렇게 구성하면 테스트에서는 단지 "괌 대학을 만든다"는 행위만 명시하면 되고, 그에 필요한 관계 설정은 fixture 내부에서 알아서 해결해준다.
관련 논의 : BaseIntegrationTest에서 통합 테스트 데이터 정의 구조 개선
이전까지는 테스트 코드를 “있으면 좋고, 없어도 되는 것”처럼 여기거나, 바빠지면 제일 먼저 미루는 대상이 되기 쉬웠다.
하지만, 테스트 코드를 일관된 방식으로 작성하면서 “테스트 작성 속도보다, 테스트의 신뢰도와 유지보수성이 훨씬 더 중요하다.”는 것을 알게 된 거 같다.
테스트 작성이 더는 귀찮은 작업이 아니라, 명확한 흐름을 검증하는 과정처럼 느껴졌고
fixture 덕분에 데이터 세팅도 일관되고 예측 가능해져서 테스트를 작성하면서도 오히려 서비스 로직의 흐름을 다시 정리할 수 있었다.
그만큼 테스트의 중요성과 역할을 더 크게 느끼게 되었다.
물론, 아쉬운 점도 있다
도메인 수가 많아지다 보니, 하나의 테스트 클래스에서 SiteUserFixture, UniversityFixture, CountryFixture, ApplicationFixture 등
여러 개의 fixture를 주입받아서 조합해야 하는 상황도 생겼다.
처음엔 “이렇게 많은 걸 주입받아야 하는 게 맞나?” 싶어 멈칫하기도 했다. 🥲
“이걸 조합해주는 상위 fixture를 또 만들어야 하나?” 고민했지만, 아직까지는 그 자체도 복잡해질 것 같아 보류 중이다.
결국 중요한 건 “어떤 코드를 더럽힐 것인가”가 아니라 “어떤 코드를 더 우아하게 만들고 싶은가”일지도 모르겠다. 🙂
Fixture 구조가 지금보다 더 복잡해지면 Fixture Monkey 같은 도구를 도입하는 것도 고민해볼만 하다.
다만 지금은 명시적으로 데이터를 구성하는 방식이 테스트의 의도를 가장 잘 드러내기 때문에 아직은 도입하지 않기로 했다.