Spring boot 단위 테스트 vs 통합테스트

최인호·2025년 3월 11일

블로그 멤버 매핑 테스트 방식

@Entity
@Table(name = "blog_member_mappings",
    indexes = {
        @Index(name = "uk_blog_member_mapping",columnList = "mb_no, blog_id, role_id", unique = true)
    }
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Getter
public class BlogMemberMapping {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "blog_member_id")
    private Long blogMemberId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "mb_no", referencedColumnName = "mb_no", nullable = false)
    private Member member;

    @Setter
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="blog_id", referencedColumnName = "blog_id", nullable = false)
    private Blog blog;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="role_id", referencedColumnName = "role_id", nullable = false)
    private Role role;

    private BlogMemberMapping(Member member, Blog blog, Role role) {
        this.member = member;
        this.blog = blog;
        this.role = role;
    }

    public static BlogMemberMapping ofNewBlogMemberMapping(Member member, Blog blog, Role role) {
        return new BlogMemberMapping(member,blog,role);
    }

}

블로그 멤버 매핑은 Role, Blog, Member 세 가지 엔티티를 연결하는 중요한 관계 엔티티이다.
이러한 복잡한 관계를 테스트할 때 단위 테스트와 통합 테스트 각각의 적합성에 대해서 고민한 내용에 대해 얘기해보려한다.

이전에 Blog, Member 등에 대해서는 테스트 코드를 작성할 때 당연히 단위 테스트로 진행했다.

BlogRepositoryTest

@SpringBootTest
@ActiveProfiles("test")

class BlogRepositoryTest {

    @Autowired
    BlogRepository blogRepository;


    Blog blog = null;


    @BeforeEach
    void setUp(){
        blog = Blog.ofNewBlog(
                "testFid",
                true,
                "testBlogName",
                "testNickName",
                "testDescription"
        );

        blogRepository.save(blog);
    }

    @AfterEach
    void tearDown(){
        blogRepository.delete(blog);
    }

    @Test
    @DisplayName("블로그 아이디 - 블로그 찾기 ")
    void findBlogsByBlogId() {

        Optional<Blog> testBlog = blogRepository.findBlogsByBlogId(blog.getBlogId());

        assertNotNull(testBlog);

        assertAll(
                ()-> assertEquals(true, testBlog.get().isBlogMain()),
                ()-> assertEquals("testFid", testBlog.get().getBlogFid()),
                ()-> assertEquals("testBlogName",testBlog.get().getBlogName()),
                ()-> assertEquals("testNickName", testBlog.get().getBlogMbNickname()),
                ()-> assertEquals("testDescription", testBlog.get().getBlogDescription())
        );
    }
   ...
  }

BlogServiceTest

@ActiveProfiles("test")
@SpringBootTest
@Transactional
class BlogServiceImplTest {

    @Mock // 블로그레포지토리는 테스트가 끝났으므로 (검증 완료) 실제 객체가 아닌 Mock 객체
    BlogRepository blogRepository;

    @InjectMocks // Mock 객체 자동 주입 후 AutoWired
    BlogServiceImpl blogService;



    @Test
    @DisplayName("블로그 저장")
    void saveBlog() {
        BlogRequest blogRequest = new BlogRequest(
                "testFid",
                true,
                "testName",
                "testNickName",
                "testDescription"
        );

        Mockito.doAnswer(invocationOnMock -> {
                  Blog blog = invocationOnMock.getArgument(0);
                  Field blogId = Blog.class.getDeclaredField("blogId");
                  Field createdAt = Blog.class.getDeclaredField("createdAt");
                  blogId.setAccessible(true);
                  blogId.set(blog, 1L);

                  createdAt.setAccessible(true);
                  createdAt.set(blog, LocalDateTime.now());
                  return null;
                })
                .when(blogRepository)
                .save(Mockito.any(Blog.class));

        BlogResponse blogResponse =  blogService.saveBlog(blogRequest);

        assertNotNull(blogResponse);

        assertAll(
                ()-> assertNotNull(blogResponse.getBlogId()),
                ()-> assertTrue(blogResponse.getCategories().isEmpty()),
                ()-> assertTrue(blogResponse.getBlogMemberMappings().isEmpty()),
                ()-> assertEquals("testFid",blogResponse.getBlogFid()),
                ()-> assertTrue(blogResponse.isBlogMain()),
                ()-> assertEquals("testName", blogResponse.getBlogName()),
                ()-> assertEquals("testNickName", blogResponse.getBlogMbNickName()),
                ()-> assertEquals("testDescription", blogResponse.getBlogDescription()),
                ()-> assertNotNull(blogResponse.getCreatedAt()),
                ()-> assertNull(blogResponse.getUpdatedAt()),
                ()-> assertTrue(blogResponse.getBlogIsPublic())
        );

    }
    ...
}

단위 테스트는 개별 컴포넌트나 메서드를 격리된 환경에서 테스트하는 방식으로 위의 예시로 서비스 테스트의 경우 Repository는 이미 검증이 끝났기 때문에 Mockito를 사용해 가짜 객체를 만들어 Service 비즈니스로직에만 집중해서 테스트를 할 수 있다.

따라서 블로그 멤버 매핑도 단위 테스트로 진행하려 했다.

통합테스트
여러 컴포넌트간의 상호작용을 테스트

하지만 블로그 멤버 매핑에서는 Blog, Member, Role 등 여러 모듈이 합쳐지기 때문에 이를 통합테스트로 진행해야 하는 것이 아닌가?

@SpringBootTest
@ActiveProfiles("test")
@Transactional
class BlogMemberMappingServiceTest {
    @Autowired
    private BlogMemberMappingService blogMemberMappingService;
    
    @Autowired
    private BlogMemberMappingRepository blogMemberMappingRepository;
    ...
 }

즉 실제 ID가 생성되고 관계가 맺어지는 것을 테스트해야 하므로 통합 테스트로 변경해서 진행했다.

테스트 목표인 블로그 멤버 매핑 서비스 코드를 다시 한번 보자

public class BlogMemberMappingServiceImpl implements BlogMemberMappingService {

	private final BlogMemberMappingRepository blogMemberMappingRepository;

    @Override
    @Transactional
    public void createBlogMemberMapping(Member member, Blog blog, Role role) {
        Optional<BlogMemberMapping> existingMapping =
            blogMemberMappingRepository.findBlogMemberMappingByMember_MbNoAndBlog_BlogId(
            member.getMbNo(), blog.getBlogId()
        );

        if(existingMapping.isPresent()){
            return;
        }

        BlogMemberMapping memberMapping = 
            BlogMemberMapping.ofNewBlogMemberMapping(
            member, blog, role
        );
        blogMemberMappingRepository.save(memberMapping);
    }
}

이거 근데 보이드면 검증이 불가능하기 때문에 dto만들어서 BlogMemberMappingResponse를 반환형을 바꿔준다면?

확인해보니 void로 지정할 경우
1. 단순성 : 구현이 간단하고 명확하고 메서드는 작업을 수행하고 끝난다
2. 또한 오직 작업 수행에만 집중하고 결과 정보 생성에 대한 책임이 없다
3. CQS(명령 쿼리 분리 원칙) 디자인 원칙
이와 같은 장점이 있지만

  • 메소드 실행 결과를 직접 검증할 수 없어 DB 조회 등 간접적인 방법으로 검증해야한다(Repository)
  • 확장성이 제한되고 추가 조회 작업이 필요

따라서 단순한 작업일 경우엔 void, 복잡한 비즈니스 로직이나 API에서는 DTO 반환 방식이 좀 더 유연하다고 할 수 있다.

하지만 이 테스트만을 위해서 dto를 만들어주고, 메소드 반환형을 수정하는게 과연 올바른가?

테스트가 설계를 주도하는 것과 같은 상황에 다음과 같이 생각해보자

  • API 사용성: 호출자에게 작업 결과에 대한 정보 제공이 필요한가?
  • 비즈니스 요구사항: 실제 비즈니스 로직에서 반환값이 필요한 상황이 있는가?
  • 확장성: 향후 추가 정보가 필요할 가능성이 있는가?
  • 일관성: 다른 유사한 API들은 어떤 패턴을 따르고 있는가?

등을 생각하다...가
블로그 멤버 매핑 서비스의 필요성에 대해서 생각하게 됐다.

당연히 모든 엔티티는 레포지토리, 서비스가 있을 것이다 라고 깊은 고민을 해보지 않은채 단정을 지었기 때문에 미쳐 놓쳐버린 것이다.

단순 CRUD vs 비즈니스 로직: 서비스 = 비즈니스 로직, 레포지토리 = 데이터 액세스
만약 블로그 멤버 매핑이 단순한 데이터 저장/조회를 넘어 권한 검증, 비즈니스 규칙 적용 등이 필요하다면 서비스 계층이 적합하지만 지금 블로그 멤버 매핑은 간단한 데이터 저장.조회 뿐이기 때문에 레포지토리 계층에 끝내는게 맞는 것 같다.

결론

  • 모든 엔티티에 동일한 계층 구조가 필요한 것은 아니며, 각 엔티티의 역할과 복잡성에 따라 적절한 구조를 선택해야함
  • 블로그 멤버 매핑과 같은 단순 관계 매핑 엔티티의 경우, 복잡한 비즈니스 로직이 필요하기 전까지는 레포지토리 계층만으로도 충분
  • 테스트 코드를 작성하며 기능 검증뿐만이 아닌 설계 자체에 대해 고민하는 시간이 되었다.

0개의 댓글