서론:
IT연합동아리 DND에서 진행한 프로젝트를 하며 느낀 점입니다. TDD로 개발을 진행하게 되면서 메인 코드 뿐만 아니라 테스트 코드도 코드임을 느꼈습니다. 어떻게하면 테스트 코드의 양을 줄일 수 있고, 테스트를 편하게 작성할 수 있는지에 대한 고민이 담겨있습니다.
본론으로 들어가기 전에 저의 프로젝트는 Controller, Service, Repository 모든 계층의 테스트가 given-when-then 패턴을 사용하는 단위테스트로 이루어져있음을 알립니다!
Fixture
처음에는 given에 머릿속으로 흘러가는 로직을 모두 다 넣으면서 개발을 했었습니다. 하지만 연관 관계가 많은 엔티티를 마주하자 제가 사용하던 로직은 좋은 코드가 아님을 깨달았습니다. 제가 쓴 코드들을 쓱 보다가 문뜩 "모든 계층이 단위 테스트로 이루어져 있기 때문에 Fixture을 활용해 객체를 하나만 만들어도 되지 않을까?" 라는 생각이 들었습니다. TDD 특성상 실패 케이스를 먼저 만들기 때문에 Service 계층에선 똑같은 DTO, 똑같은 도메인 ex)User가 given으로 주어지는 게 반복되고 있었습니다. 제가 작성한 Fixture의 예시는 다음과 같습니다.
UserFixture
public class UserFixture {
private static final SocialType SOCIAL_TYPE_KAKAO = SocialType.KAKAO;
private static final String SOCIAL_ID = "1";
private static final String SOCIAL_ID_B = "2";
private static final String EMAIL = "test@test.com";
private static final String EMAIL_B = "testB@test.com";
private static final String NICKNAME = "테스트";
private static final String NICKNAME_B = "테스트B";
private static final String PROFILE_IMAGE_URL = "테스트 이미지";
private static final String PROFILE_IMAGE_URL_B = "테스트 이미지_B";
private static final Level LEVEL = ONE;
private static final String SOCIALNAME = "테스트";
private static final Gender GENDER = MALE;
private static final String BIRTHDAY = "2000.01.01";
private static final Career CAREER = BACKEND;
public static final SigningUser MOCK_SIGNING_ACCOUNT = new SigningUser("123", "mock",
"mock_profile", "mock@kakao.com", "kakao");
public static final SignUpRequestDto MOCK_SIGN_UP_REQUEST_DTO = new SignUpRequestDto("mock_user", "2000.01.01",
"남자", "백엔드", Arrays.asList("예술/대중문화", "게임"), "valid signToken");
public static final SignUpRequestDto MOCK_INVALID_SIGN_TOKEN_SIGN_UP_REQUEST_DTO = new SignUpRequestDto("mock_user", "2000.01.01",
"남자", "백엔드", Arrays.asList("예술/대중문화", "게임"), "Invalid signToken");
public static final User mock_user = User.of(MOCK_SIGNING_ACCOUNT, MOCK_SIGN_UP_REQUEST_DTO);
public static User createDummyUser() {
return User.of(MOCK_SIGNING_ACCOUNT, MOCK_SIGN_UP_REQUEST_DTO);
}
public static User createDummyUser_B() {
return User.of(SOCIAL_TYPE_KAKAO, SOCIAL_ID_B, EMAIL_B, NICKNAME_B, PROFILE_IMAGE_URL_B);
}
public static User createDummyUser_C() {
return User.of(SOCIAL_TYPE_KAKAO, SOCIAL_ID_B, EMAIL_B, LEVEL, SOCIALNAME, GENDER, BIRTHDAY,
PROFILE_IMAGE_URL, CAREER, NICKNAME_B);
}
public static User createDummyUser_D() {
return User.of(1L, SOCIAL_TYPE_KAKAO, SOCIAL_ID_B, EMAIL_B, LEVEL, SOCIALNAME, GENDER, BIRTHDAY,
PROFILE_IMAGE_URL, CAREER, NICKNAME_B);
}
public static User createDummyUser_E() {
return User.of(2L, SOCIAL_TYPE_KAKAO, SOCIAL_ID_B, EMAIL_B, LEVEL, SOCIALNAME, GENDER, BIRTHDAY,
PROFILE_IMAGE_URL, CAREER, NICKNAME_B);
}
}
엔티티와 DTO를 static으로 만들어 객체의 재사용성을 극대화했습니다. 테스트를 작성하다보니 하나의 유저만으로는 부족함을 느껴, 여러 유저를 만들어 놓게 되었습니다.
결과적으로 ServiceTest 단의 코드는 다음과 같습니다
ProjectServiceTest
public class ScrapServiceTest extends ServiceTest {
...
private User user;
private Project project;
private Scrap scrap;
@BeforeEach
public void setUp() {
this.user = spy(createDummyUser());
this.project = spy(createDummyProject(user));
this.scrap = spy(createDummyScrap(user, project));
}
@Nested
@DisplayName("스크랩 클릭을 했을 때")
class react {
@Test
@DisplayName("기존의 스크랩을 하지 않았으면 새롭게 저장한다.")
public void success() throws Exception {
//given
given(userService.getUserById(anyLong())).willReturn(user);
given(projectService.getProjectById(anyLong())).willReturn(project);
given(scrapRepository.findByUserAndProject(any(User.class), any(Project.class))).willReturn(Optional.empty());
given(scrapRepository.save(any(Scrap.class))).willReturn(scrap);
//when
ClickScrapResponseDto clickScrapResponseDto = scrapService.click(1L, 1L);
//then
assertThat(clickScrapResponseDto.isClicked()).isTrue();
}
...
}
}
다음과 같이 user, project, scrap등을 편하게 만드는 모습을 볼 수 있습니다. 위의 코드 뿐만 아니라 다른 Service 계층에서의 코드들도 다 동일하게 적용할 수 있다는 것이 큰 이점으로 다가왔습니다.
+ Service 계층에서의 getId()에 대한 고민
보통 서비스 계층에서는 id 식별자로 두고 해당 도메인을 조회하게 됩니다. ex)userID가 1인 유저를 findById(userId)를 통해 객체를 가져옴
하지만 Mock객체를 만들 때, 기존 메인 코드에 사용되는 코드 기반으로 Mock 객체를 만들게 됩니다. 하지만 ID값은 DB에 넣을 때 Id가 추가되지 자바 코드에선 null값으로 존재하기 때문에 getId를 하면 null값이 반환됩니다.
테스트에 의한 메인코드의 변경은 좋지 않은 코드를 유발한다고 생각해서 엔티티에 setId()나 테스트용 user.of(~~)패턴을 사용할 수 없었습니다.
한참을 고민하다 해결책을 찾았는데 Spy를 사용하는 것이었습니다! Spy는 선택적으로 stub할 수 있는 기능을 제공하고, 제가 설정하지 않은 것은 정상적으로 작동하기 때문에 지금 제 상황에 딱 맞는 상황이라고 생각했습니다. 무분펼한 stub 남발은 테스트 코드를 좋지 않게 한다고 생각하지만 이런 stub은 상황에 따라서 꼭 필요하다고 느껴서 사용했습니다.
Persister
DB를 직접 사용하지 않는 Controller와 Service 단은 Fixture을 활용했지만 무한 스크롤 같은 여러 객체를 만들어야 하고, 연관관계 매핑도 더욱 신경써야 한다고 생각해서 Persister라는 패키지를 도입했습니다.
코드부터 보면 다음과 같습니다.
@RequiredArgsConstructor
@Persister
public class ProjectTestPersister {
private final ProjectRepository projectRepository;
private final UserTestPersister userTestPersister;
public ProjectBuilder builder() {
return new ProjectBuilder();
}
public final class ProjectBuilder {
private User user;
private SaveProjectRequestDto saveProjectRequestDto;
public ProjectBuilder user(User user) {
this.user = user;
return this;
}
public ProjectBuilder saveProjectRequestDto(SaveProjectRequestDto saveProjectRequestDto) {
this.saveProjectRequestDto = saveProjectRequestDto;
return this;
}
private static final String TITLE = "title";
private static final String CONTENT = "content";
private static final String SUMMARY = "summary";
private static final String DEMO_SITE_URL = "demoUrl";
private static final LocalDate START_DATE = LocalDate.of(2024, 1, 1);
private static final LocalDate END_DATE = LocalDate.of(2024, 1, 3);
private static final Progress PLANNING_PROGRESS = Progress.PLANNING;
public Project save() {
Project project = Project.of(
(user == null ? userTestPersister.builder().save() : user),
(saveProjectRequestDto == null ? new SaveProjectRequestDto(TITLE, IT.getName(), CONTENT, SUMMARY, DEMO_SITE_URL, START_DATE, END_DATE,
PLANNING_PROGRESS.getValue(), 1L, 2L, 3L, 4L) : saveProjectRequestDto)
);
return projectRepository.save(project);
}
public Project save(FieldName fieldName) {
Project project = Project.of(
(user == null ? userTestPersister.builder().save() : user),
(saveProjectRequestDto == null ? new SaveProjectRequestDto(TITLE, fieldName.getName(), CONTENT, SUMMARY, DEMO_SITE_URL, START_DATE, END_DATE,
PLANNING_PROGRESS.getValue(), 1L, 2L, 3L, 4L) : saveProjectRequestDto)
);
return projectRepository.save(project);
}
public Project save(String title, FieldName fieldName) {
Project project = Project.of(
(user == null ? userTestPersister.builder().save() : user),
(saveProjectRequestDto == null ? new SaveProjectRequestDto(title, fieldName.getName(), CONTENT, SUMMARY, DEMO_SITE_URL, START_DATE, END_DATE,
PLANNING_PROGRESS.getValue(), 1L, 2L, 3L, 4L) : saveProjectRequestDto)
);
return projectRepository.save(project);
}
}
}
다음과 같이 Builder 패턴을 통해 제가 값을 설정하지 않았으면 미리 정해둔 기본값을 설정 후, DB에 저장하는 로직으로 진행됩니다. 가장 중요한 부분은 다음과 같습니다.
public Project save(String title, FieldName fieldName) {
Project project = Project.of(
(user == null ? userTestPersister.builder().save() : user),
(saveProjectRequestDto == null ? new SaveProjectRequestDto(title, fieldName.getName(), CONTENT, SUMMARY, DEMO_SITE_URL, START_DATE, END_DATE,
PLANNING_PROGRESS.getValue(), 1L, 2L, 3L, 4L) : saveProjectRequestDto)
);
return projectRepository.save(project);
}
user쪽을 보면 user가 저장되지 않으면 userTestPersister로부터 유저를 생성한 후, user를 주입하는 모습입니다.
그러면 userPersister.builder().save()는 어떻게 이루어져 있을까요?
UserPersister의 부분 코드
public User save() {
User user = User.of(
(socialType == null ? SocialType.KAKAO : socialType),
(socialId == null ? random(10, true, true) : socialId),
(email == null ? "test@email.com" : email),
(level == null ? Level.ONE : level),
(socialName == null ? random(10, true, true) : socialName),
(gender == null ? Gender.MALE : gender),
(birthDay == null ? "20000112" : birthDay),
(profileImageUrl == null ? "profileImageUrl" : profileImageUrl),
(career == null ? Career.BACKEND : career),
(nickname == null ? random(10, true, true) : nickname)
);
return userRepository.save(user);
}
마찬가지로 user의 값을 설정하지 않으면 랜덤 값을 주입하는 모습을 볼 수 있습니다.
정리하면 흐름은 다음과 같습니다.
(아무런 값을 설정하지 않은 상태)
projectPersister.builder().save() => 값을 저장 도중 연관 관계 매핑된 user가 우선시 되야함(builder의 유저 확인 결과 null값) => userPersister.builder().save() 호출 => DB에 user 저장 => 저장된 user를 사용해 project 저장
(사전에 테스트 작성자가 원한 user를 projectPersister.builder()에 주입한 상태)
projectPersister.builder() => 기존에 만들어둔 user 주입: builder.setUser(user) => builder.save()호출 => 값 저장 도중 user의 값이 null이 아니므로 해당 user를 사용 => project 저장
persister가 존재하지 않았던 코드는 다음과 같습니다.
ProjectRepositoryTest의 일부
@Test
@DisplayName("특정 userId를 외래키로 가지는 프로젝트의 개수를 반환한다.")
public void countByUserId() {
//given
User user = userRepository.save(User.of(값, 값))
Project project = projectRepository(값, 값, 값, user 값...);
//when
Long projectCount = projectRepository.countByUserAndIsDeletedIsFalse(project.getUser());
//then
assertThat(projectCount).isEqualTo(1);
}
변경 후,
@Test
@DisplayName("특정 userId를 외래키로 가지는 프로젝트의 개수를 반환한다.")
public void countByUserId() {
//given
Project project = projectTestPersister.builder().save();
//when
Long projectCount = projectRepository.countByUserAndIsDeletedIsFalse(project.getUser());
//then
assertThat(projectCount).isEqualTo(1);
}
특정 userId로 조회하는 간단한 매서드 임에도 테스트 작성자가 신경써야 하는 것이 확연히 줄어든 것을 볼 수 있습니다.
복잡한 경우 persister의 편리함은 더 크게 다가왔습니다.
Persister 설정
@RequiredArgsConstructor
@Persister
public class ProjectTestPersister {
~~~
}
Persister 어노테이션은 Repository 테스트를 @DataJpaTest를 사용했기 때문에 최소한의 Bean을 사용했습니다. 어떠한 설정을 해주지 않으면 Bean을 등록할 수 없었습니다. 해당 Bean을 등록하기 위한 식별자를 만들기 위해 Persister 이라는 어노테이션을 생성했습니다
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface Persister {
}
RepositoryTest 설정
@DataJpaTest(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Persister.class))
@Import(TestJpaConfig.class)
@ActiveProfiles({"test"})
@TestMethodOrder(MethodOrderer.DisplayName.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestInstance(PER_CLASS)
public abstract class RepositoryTest {
@Autowired
protected FieldTestPersister fieldTestPersister;
@Autowired
protected ProjectTestPersister projectTestPersister;
@Autowired
protected UserTestPersister userTestPersister;
@Autowired
protected ProjectImageTestPersister projectImageTestPersister;
@Autowired
protected LikeTestPersister likeTestPersister;
@Autowired
protected ScrapTestPersister scrapTestPersister;
@Autowired
protected FeedbackTestPersister feedbackTestPersister;
@Autowired
protected FeedbackSubmitTestPersister feedbackSubmitTestPersister;
}
@DataJpaTest(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Persister.class))
이부분에서 Persister이라는 어노테이션이 달린 것들을 Bean을 등록함으로써 테스트 코드 사용시 해당 클래스들을 주입할 수 있도록 했습니다.
결론 :
테스트 코드를 짜는 것의 방법은 없습니다. 제가 사용한 방법은 제가 사용하기에 편하다고 느꼈고, 그것을 공유하고 싶어 글을 적게 되었습니다. 저의 글이 이해가 가지 않고, 제가 사용한 방법의 단점이 보인다면 댓글을 달아주세요. 댓글을 통해 함께 성장하겠습니다 !
관련 프로젝트 : https://github.com/dnd-side-project/dnd-10th-7-backend
'Project' 카테고리의 다른 글
[Project] 실시간 채팅 구현 시 FCM Token 발송 여부 결정하기 (0) | 2024.03.25 |
---|---|
[Project] Spring + Stomp 테스트 하는 과정.. (실시간 채팅 구현) (4) | 2024.02.27 |
[Project] 멀티 모듈을 왜 쓸까? (멀티 모듈 도입 전) (1) | 2024.01.15 |
[Project] google Oauth2 로그인 시 refreshToken을 받는 방법 (최초 로그인에서 저장을 못했을 때) (0) | 2023.11.29 |
[Project] Redis(Elasticache Redis)로 RefreshToken을 관리하기 (1) | 2023.11.25 |