미니 프로젝트의 테스트 코드를 작성하던 중 DTO, 엔티티 Class의 Object를 생성하는 메서드가 중복되는 것을 확인했다.
놀랍게도 전부 다른 클래스에 선언된 6개의 서로 다른 메서드다. 기능은 특별한 것 없이 DTO를 생성하고 entity class의 object를 생성하는 것이 전부다. 점점 중복이 증가하는게 너무 뻔히 보여서 바로 GPT에게 물어보았다.
내가 원하던 것이 저곳에 있었다. 바로 어딘가 익숙한 Object Mother 패턴이다. 이래서 디자인 패턴을 반드시 공부하라고 하는 것 같다.
Object Mother 패턴은 테스트 데이터 생성에 사용되는 패턴 중 하나이다.
이 패턴의 핵심 아이디어는 테스트에 사용될 객체를 중앙화된 위치에서 생성하고 관리하는 것이다. 그러므로, 목표인 반복적인 테스트 데이터 생성 로직의 중복을 줄이고, 테스트 데이터의 생성과 관리를 일관성 있게 할 수 있다.
Object Mother란 테스트를 위해 예제 객체를 생성하는 데 도움을 주는 특별한 클래스의 한 형태이다.
현재 객체 지향 세계에는 작가, 소설 객체가 존재한다. 이들은 이름, 생성 일자 등의 정보를 필요로 한다. 객체들이 필요로 하는 정보가 각각 다르기 때문에 생성해야할 객체는 많아지기 마련이다. 이런 data set를 test fixture라고 부른다.
여러 테스트 클래스에서 비슷한 데이터가 필요한 지금, 내 코드가 standard test fixture를 반환하는 factory object가 있다면 중복을 최대한 줄일 수 있지 않을까? 이것이 바로 object mother다.
이제 패턴을 코드에 적용할 시간이다.
실제 코드 1
@Test @DisplayName("소설 등록") void register() { // given NovelRegisterDto registerDto = createNovelRegisterDto(); Novel novel = registerDto.toEntity(); novelRepository.save(novel); WriterDefaultRegisterDto writerRegisterDto = createRegisterDto("testPenName", "testPassword"); Writer writer = writerRegisterDto.toEntity(); writerRepository.save(writer) // when WriterNovel writerNovel = WriterNovel.builder() .writer(writer) .novel(novel) .build(); writerNovelRepository.save(writerNovel); // then Optional<WriterNovel> byNovel = writerNovelRepository.findByNovel(novel); Optional<WriterNovel> byWriter = writerNovelRepository.findByWriter(writer); assertThat(byNovel.get()).isEqualTo(byWriter.get()); }
실제 코드 2
public static NovelRegisterDto createNovelRegisterDto() { NovelRegisterDto registerDto = new NovelRegisterDto(); registerDto.setTitle("testTitle"); registerDto.setPlot("testTitle"); registerDto.setTheme("testTheme"); registerDto.setCharacters("testCharacters"); registerDto.setBackground("testBackground"); registerDto.setEvent("testEvent"); registerDto.setPenName(penName); } private static WriterDefaultRegisterDto createRegisterDto(String penName, String password) { WriterDefaultRegisterDto registerDto = new WriterDefaultRegisterDto(); registerDto.setPenName(penName); registerDto.setPassword(password); return registerDto; } private static WriterDefaultLoginDto createLoginDto(String penName, String password) { WriterDefaultLoginDto loginDto = new WriterDefaultLoginDto(); loginDto.setPenName(penName); loginDto.setPassword(password); return loginDto; }
값을 받아 오고, 직접 입력하고, 정적 변수를 땡겨오는 등 아주 각양 각색이다. 가독성을 위해 다른 모든 Test 클래스와 메서드를 제거하고 중요한 테스트 메서드 하나만 가져왔다.
그럼에도 저기 createRegisterDto("testPenName", "testPassword")
가 굉장히 보기 싫지 않은가? 지금 내 test 디렉토리는 이런 코드가 점점 늘어나는 상황이었다. 이제 깔끔하게 만들어보자
사실 이미 많은 프로젝트들이 오픈소스로 제공되고 있다. naver에서 유지보수 하는 fixture-monkey를 처음에 사용하려고 했는데 Bean Validation에 따라 자동생성하게끔 바꾸려다가 점점 복잡해져서 차라리 더 직관적으로 보이는 Easy Random을 사용했다.
fixture-monkey는 Object Mother class를 직접 생성하지 않고 Builder 패턴 처럼 사용할 수 있어서 더 편리하다. 하지만 이번에는 Easy Random을 사용해 직접 Object Mother class를 만들어 보자
// build.gradle
testImplementation 'org.jeasy:easy-random-core:5.0.0'
build.gradle에 필요한 의존성을 추가해 주자
public class WriterMother { public static WriterDefaultRegisterDto registerDto() { EasyRandomParameters randomParameters = new EasyRandomParameters() .charset(StandardCharsets.UTF_8) .randomize(named("penName").and(ofType(String.class)), new StringRandomizer(30)) .randomize(named("password").and(ofType(String.class)), new StringRandomizer(100)); EasyRandom easyRandom = new EasyRandom(randomParameters); return easyRandom.nextObject(WriterDefaultRegisterDto.class); } public static WriterDefaultLoginDto loginDto() { EasyRandomParameters randomParameters = new EasyRandomParameters() .charset(StandardCharsets.UTF_8) .randomize(named("penName").and(ofType(String.class)), new StringRandomizer(30)) .randomize(named("password").and(ofType(String.class)), new StringRandomizer(100)); EasyRandom easyRandom = new EasyRandom(randomParameters); return easyRandom.nextObject(WriterDefaultLoginDto.class); } } public class NovelMother { public static NovelRegisterDto registerDto() { EasyRandomParameters randomParameters = new EasyRandomParameters() .charset(StandardCharsets.UTF_8) .randomize(named("title").and(ofType(String.class)), new StringRandomizer(100)) .randomize(named("plot").and(ofType(String.class)), new StringRandomizer(500)) .randomize(named("theme").and(ofType(String.class)), new StringRandomizer(400)) .randomize(named("characters").and(ofType(String.class)), new StringRandomizer(100)) .randomize(named("background").and(ofType(String.class)), new StringRandomizer(100)) .randomize(named("event").and(ofType(String.class)), new StringRandomizer(100)) .randomize(named("penName").and(ofType(String.class)), new StringRandomizer(30)); EasyRandom easyRandom = new EasyRandom(randomParameters); return easyRandom.nextObject(NovelRegisterDto.class); } }
위처럼 Class를 작성하면 된다. 참고로 EasyRandomParameters 클래스는 EasyRandom 인스턴스를 구성하기 위한 진입점이다. 이 클래스에서 모든 파라미터를 설정하여 랜덤 데이터 생성 방식을 제어할 수 있다. 나는 Dto들의 Bean Validation에 맞게 설정해준 것이 전부다. 하지만 공식 Github에 가면 더 많은 기능을 확인할 수 있다.
@Test
@DisplayName("소설 등록")
void register() {
// given
WriterDefaultRegisterDto writerRegisterDto = WriterMother.registerDto();
Writer writer = writerRegisterDto.toEntity();
writerRepository.save(writer);
NovelRegisterDto registerDto = NovelMother.registerDto();
Novel novel = registerDto.toEntity();
novelRepository.save(novel);
// when
WriterNovel writerNovel = WriterNovel.builder()
.writer(writer)
.novel(novel)
.build();
writerNovelRepository.save(writerNovel);
// then
Optional<WriterNovel> byNovel = writerNovelRepository.findByNovel(novel);
Optional<WriterNovel> byWriter = writerNovelRepository.findByWriter(writer);
assertThat(byNovel.get()).isEqualTo(byWriter.get());
}
static method로 선언해 별도의 생성자 없이도 generator method를 사용할 수 있다. 이제 테스트 코드에서 객체를 자동으로, Bean Validation에 맞춰 생성할 수 있게 되었다.
하.. 이 맛에 테스트 코드 작성한다.
안녕하세요! 픽스쳐 몽키 개발팀입니다.
글을 재밌게 읽다가 궁금한 점이 있어 질문 드립니다!
Fixture Monkey를 도입하시려다가 EasyRandom을 선택하신 것 같은데요.
"Bean Validation에 따라 자동생성하게끔 바꾸려다가 점점 복잡해져서 차라리 더 직관적으로 보이는 Easy Random을 사용했다."
Bean Validation에 따라 생성하려고 시도하셨을 때 혹시 어떤 점이 복잡하셨는지 공유해주실 수 있을까요??
겪으신 어려움을 저희가 개선할 수 있을지 궁금해서 질문드립니다!
감사합니다.