반응형

 

 

이전 포스팅에서 @SpringBootTest를 사용해서 통합 테스트를 작성하는 방식에 대해서 알아보았다.

이번 포스팅에서는 테스트 코드에서의 모듈 의존성 격리의 필요성과, 어떤 경우에 대해 적용되어야 하는지 알아보도록 하겠다.

 

https://sjh9708.tistory.com/195

 

[Spring Boot] 테스트 코드 : 시작하기 (JUnit)

테스트 코드의 필요성 및 실천 방법론 테스트 코드의 필요성과 "좋은 테스트 코드"를 작성하기 위한 방법론에 대해서 작성한 내용이다. 테스트 코드를 "왜" 작성하고 "어떠한 마음가짐"으로 임

sjh9708.tistory.com

 

 


테스트를 작성할 예시 Service

 

필자는 아래의 서비스에 대한 테스트 코드를 작성해 보면서 앞으로의 내용을 설명하려고 한다.

@Service
@AllArgsConstructor
@Transactional(readOnly = true)
public class EnrollmentCommandService {
    private final EnrollmentRepository enrollmentRepository;
    private final EnrollmentQueryService enrollmentQueryService;
    private final LectureQueryService lectureQueryService;
    private final MemberQueryService memberQueryService;
    private final LectureMemberQueryService lectureMemberQueryService;
    private final EmailService emailService;

    /**
     * [학생 : 강의 등록 요청]
     * 해당 강의 등록을 교수자에게 요청한다.
     *
     * @param lectureId - 강의 ID
     * @return Long - 초대 PK
     */
    @Transactional
    public Enrollment requestEnrollment(Long lectureId, Long memberId) {

        Lecture lecture = lectureQueryService.queryOne(lectureId);
        Member member = memberQueryService.query(memberId);

        // Validate : 등록요청을 한 강의가 삭제된 경우
        if (lecture.getDeleted() == true) {
            throw new NoSuchElementFoundException404(ErrorDefineCode.DELETED_LECTURE);
        }

        // Validate : 이미 대기중인 초대 요청이 있는 지 조회
        isHaveAlreadyWaitRequest(member, lecture);

        // Validate : 이미 강의에 소속되어 있는 지 조회
        isEnrolled(member, lecture);

        // Insert : 데이터베이스 저장
        Enrollment enrollment = Enrollment.builder()
                .status(EnrollmentStatus.WAITING)
                .lecture(lecture)
                .member(member)
                .modified_by(member)
                .build();

        enrollment = enrollmentRepository.save(enrollment);
        
        
        // 교수자에게 초대가 왔음을 이메일로 보내 알린다.
        boolean result = emailService.send(enrollment);
        if (!result) {
            throw new IllegalArgumentException("메일 전송에 실패했습니다.");
        }

        // Out
        return enrollment;
    }


    /**
     * 해당 회원이 이미 Wait 상태인 Member-Lecture Enroll이 존재하는지 확인
     */
    private void isHaveAlreadyWaitRequest(Member member, Lecture lecture) {
        boolean exist = enrollmentQueryService.existWaitingEnrollmentByMemberId(member.getId(), lecture.getId());

        if (exist) {
            throw new AlreadyExistElementException409(ErrorDefineCode.ALREADY_ENROLL_REQUEST);
        }
    }


    /**
     * 이미 해당 Lecture에 Member가 등록되어 있는지 확인
     */
    private void isEnrolled(Member member, Lecture lecture) {
        boolean isEnrolled = lectureMemberQueryService.existLectureMember(
                member.getId(), lecture.getId());

        if (isEnrolled) {
            throw new AlreadyExistElementException409(ErrorDefineCode.ALREADY_JOIN);
        }
    }
}

 

우리는 requestEnrollment()에 대한 단위 테스트 코드를 작성해 보겠다.

코드의 상세한 내용은 남이 짠 코드이므로 쿨하게 무시하도록 하고 앞으로의 이해를 위해서 아래의 내용들만 살펴보고 넘어갔으면 좋겠다.

 

1. 역할

  • 회원(Member)가 강의(Lecture)의 구성원으로 참여요청(Enrollment)를 요청하는 API
  • Enrollment 테이블에 대기중(WAITING) 상태인 참여요청 데이터를 추가한다.
  • 외부 시스템을 통해서 교수자 이메일로 알림을 보낸다.

2. 제약사항 

  • 참여요청 대상 강의(Lecture)가 존재하지 않으면 요청을 보낼 수 없다.
  • 이미 초대 대기중(WAITING)인 상태의 참여요청(Enrollment)이 있다면 요청을 보낼 수 없다.
  • 이미 강의(Lecture)의 구성원이라면 요청을 보낼 수 없다.

 

 

 


@SpringBootTest의 장단점

 

통합 테스트에서의 @SpringBootTest

1. 실제 애플리케이션과 유사한 환경에서 테스트를 수행하여 실제 동작을 더 정확하게 확인할 수 있다.

2. 실제 빈을 사용하므로 다른 시스템들과 연동하여 통합 테스트를 수행하기에 적절하다.

 

단위 테스트에서의 @SpringBootTest

1. 실행 속도가 느리다 : 전체 Spring Context를 로드해야 하기 때문이다.

2. 외부 의존성에 의해 테스트가 명확하지 않다 : 어려운 말일 수도 있는데 기존 코드를 보면서 다시 생각해보자.

 

@Service
@AllArgsConstructor
@Transactional(readOnly = true)
public class EnrollmentCommandService {
    private final EnrollmentRepository enrollmentRepository;
    private final EnrollmentQueryService enrollmentQueryService;
    private final LectureQueryService lectureQueryService;
    private final MemberQueryService memberQueryService;
    private final LectureMemberQueryService lectureMemberQueryService;

    @Transactional
    public Enrollment requestEnrollment(Long lectureId, Long memberId) {
		
        Member member = memberQueryService.query(memberId);
		// ======== 오류 발생 지점 !! ============
        // ...
        // Validate : 등록요청을 한 강의가 삭제된 경우
        // Validate : 이미 대기중인 초대 요청이 있는 지 조회
        // Validate : 이미 강의에 소속되어 있는 지 조회
        // Insert : 데이터베이스 저장
        // 교수자에게 초대가 왔음을 이메일로 보내 알린다.
        // Out
    }


}

 

 

만약 테스트 코드가 EnrollmentCommandService의 requestEnrollment()를 호출했을 때, 테스트 코드가 실패했다고 가정해보자. 그리고 실패의 이유가 MemberQueryService라는 외부 의존성에 의해서 발생했다고 생각해보자.

 

만약 해당 테스트 코드의 목적이 "단위 테스트"라고 가정해보자.

우리는 EnrollmentCommandService 내부에서 우리가 생각하지 못했던 오류를 발견하기 위해서 테스트 코드를 작성하는 것이 주목적이다. 그리고 해당 모듈 안에서 발생하는 오류를 잡아내는 것이 주목적이지, 외부에서 발생하는 오류를 잡아낸다면 테스트 코드의 책임이 불분명해진다.

  • EnrollmentServiceTest가 실패했다면 EnrollmentService가 범인이어야 한다. 그런데 사실 MemberQueryService가 범인이라면?
  • 단위 테스트가 실패한다면 -> 해당 Unit의 잘못이어야 한다. 외부 의존성에 대한 잘못은 해당 외부 의존성 모듈을 담당하는 또다른 단위 테스트가 판별해 주어야 할 일이다.

 

단위 테스트는 이름과 같이 해당 단위(Unit)에서의 문제점을 파악하기 위한 것이다.
우리는 A 단위 테스트가 실패하면 A Unit에서 문제가 발생하고 있다는 것을 명확하게 알 수 있어야 한다.
만약 문제의 용의선상에 A가 의존하고 있는 B, C, D까지 포함한다면 개발자는 이 모든 컴포넌트들을 디버깅 해봐야 한다. 이는 프로젝트가 거대해질수록 유지보수하기가 힘들어 질 것이다.

 

 

 


Test Double

 

테스트에서 실제 객체를 대신해 테스트를 지원하기 위해 사용되는 객체를 가리키는 용어이다.

실제 객체를 사용하는 대신 그 객체를 모방한 가짜 객체를 사용하여 테스트 주체에 집중할 수 있도록 하는 방법이다.

대표적인 Test Double의 유형에는 아래와 같은 내용들이 있으며 Spring Test에서는 Mock을 많이 이용하게 되는 편이다.

 

 

  • Stub : 특정한 메소드 호출에 대해 미리 정해진 결과를 반환하도록 구성된 객체. 특정 상황을 재현하는 것이 목적
  • Mock : 사전에 정의된 기대 동작을 기반으로 행동하는 객체. Stub과 달리 행동 검증이 목적.
  • Spy : 실제 객체처럼 동작하면서, 메소드 호출 내역과 전달 인자등을 기록하는 객체 -> 메소드 호출의 발생 여부를 파악하기 위해 사용

 

 

 


Mockito란?

 

Mockito는 Spring 진영에서 단위 테스트를 작성할 때 사용되는 인기 있는 Mock 라이브러리이다.
외부 의존성에 대해서 실제 Bean을 주입받는 대신 Mock 객체를 사용하여 해당 외부 의존성에 대해 모방하여 사용할 수 있도록 한다. 즉 외부 요인이 아니라, 테스트 주체에 집중할 수 있도록 한다.

 

Mock 객체 : 이름과 같이 테스트에서 사용되는 "가짜" 객체이다. 목 객체는 실제 객체를 모방하여 동작하도록 프로그래밍되어 있지만, 실제 동작하는 객체가 아니다. 대신에 테스트 중에 호출되는 메서드의 호출 및 반환 값을 추적하고, 특정 메서드가 호출되었는지 여부를 검증하는 데 사용한다.

 

Mock 객체는 Stub의 일종이다. 테스트 수행 시 테스트 되는 메서드가 다른 객체에 의존하는 경우들이 많을 것이다. 이런 경우 메서드를 격리시켜 온전히 메서드의 기능에만 집중하여 테스트하는 것이 불가능하다. 따라서 Mock 객체가 필요하다.

  • Stub : 제어 모듈이 호출하는 타 모듈의 기능을 단순히 수행하는 도구, 일시적으로 필요한 조건만을 가지고 있는 시험용 모듈이다.

 

 

 

이제부터 단위 테스트의 관점에서의 Mock 사용과, 통합 테스트 관점에서의 MockBean 사용법에 대해 둘 다 살펴보려고 한다.
기준은 Service Layer (Business Layer)의 테스트 코드를 기준으로 할 것이며, 예시를 위해서 단위 테스트 관점에서도 코드를 작성하지만 일반적으로 단위 테스트보다는 통합 테스트로 많이 작성된다는 것을 알아두자.

 

 

 


단위 테스트 : Mock과 InjectMocks

 

먼저 Mock의 아이덴티티를 먼저 서술하고 가도록 하겠다. 

  1. 외부 의존성을 가짜 객체로 대체한다. 이는 외부 영향으로부터 테스트 코드를 격리시키고 해당 단위 테스트에 집중할 수 있게 한다.
  2. 가짜 객체로 대체시키는 대신, 테스트의 주체가 외부 컴포넌트의 메서드를 호출(외부 의존성을 사용)하는 부분에 대해서 동작을 지정해 주어야 한다.
    • 테스트 중 사용되는 외부 의존성의 특정 메서드가 호출될 때 반환해야 하는 값, 예외 등을 지정한다.
    • 이를 지정하지 않으면, 테스트의 주체가 외부 의존성을 사용하는 부분에서 오류가 날 것이다. 왜냐하면 외부 의존성 컴포넌트는 텅 빈 상태(Mock)이고, 특정 메서드를 호출할 때의 동작이 명시되어 있지 않기 때문이다.

 

 


Mock 객체 생성하기

@Transactional
@DisplayName("Enrollment Command Service")
@ExtendWith(MockitoExtension.class)
public class EnrollmentCommandServiceTest {

    // 테스트 주체의 외부 의존성들 -> Mock 객체로 생성하기
    @Mock
    private EnrollmentRepository enrollmentRepository;
    @Mock
    private LectureQueryService lectureQueryService;
    @Mock
    private MemberQueryService memberQueryService;
    @Mock
    private LectureMemberQueryService lectureMemberQueryService;
    @Mock
    private EnrollmentQueryService enrollmentQueryService;
    @Mock
    private EmailService emailService;

    // 단위 테스트의 주체 -> @InjectMocks로 가짜 의존성 주입받기
    @InjectMocks
    private EnrollmentCommandService enrollmentCommandService;


}

 

  1. @ExtendWith(MockitoExtension.class): JUnit 5와 함께 사용되는 Mockito 확장을 지정하여 Mockito의 기능을 활용할 수 있다.
  2. @Mock : Mock 객체들을 지정한다. 즉 테스트 대상 객체(EnrollmentCommandService)가 의존하는 외부 객체들(EnrollmentRepository, LectureQueryService 등)은 Mock(가짜) 객체로 생성된다. 이는 외부 의존성을 격리하고, 테스트 중에 이들 객체의 동작을 제어하거나 검증할 수 있습니다.
    • EnrollmentCommandService가 Injection받는 Bean들을 Mock 객체로 선언해준다.
  3. @InjectMocks: 테스트 대상 객체(EnrollmentCommandService)에 실제 외부 의존성 대신 Mock 객체들을 주입하여 사용하도록 한다.

 


Mock 객체 : 외부 의존성의 특정 메서드가 호출될 때 반환해야 하는 값 지정

 

@Test
@DisplayName("Enrollment : (학생) 강의 등록 요청")
public void enrollmentRequest() throws Exception {

    //given
    Member student = Member.builder()
            // ...
            .build();
    Member tutor = Member.builder()
            // ...
            .build();
    Lecture lecture = Lecture.builder()
            // ...
            .build();
            
    //when
    Enrollment enrollment = enrollmentCommandService.requestEnrollment(lecture.getId(), student.getId());

    //then
    assertThat(enrollment).isNotNull();
    assertThat(enrollment.getMember().getEmail()).isEqualTo(student.getEmail());
    assertThat(enrollment.getLecture().getTitle()).isEqualTo(lecture.getTitle());
    assertThat(enrollment.getStatus()).isEqualTo(EnrollmentStatus.WAITING);


}

 

기존 테스트 코드와 같이 given, when, then으로 동작에 대한 검증을 수행한다.
그런데 우리는 검증할 서비스에서 호출하고 있는 외부 의존 클래스의 메서드를 주목해야 한다. 왜냐하면 우리는 Mock 객체를 사용하므로 실제 외부 코드를 사용할 수 없기 때문이다.

 

가짜 객체로 대체시키는 대신 테스트의 주체가 외부 컴포넌트의 메서드를 호출(외부 의존성을 사용)하는 부분에 대해서 동작을 지정해 주어야 한다.

 

우선 해당 서비스의 코드에서 의존하는 외부 객체의 메서드들을 파악해야 한다. 왜냐하면 우리는 실제 외부 객체를 사용하지 않을 것이고, 외부 객체를 사용할 때에는 예상 시나리오 대로의 동작을 지정해 줄 것이기 때문이다.
즉 외부 의존성을 사용하는 곳에서는 예상대로 흘러갈 것이라고 가정하고 동작하도록 할 것이다 -> 테스트 코드에서 문제가 발생한다면 책임소재는 분명히 해당 Test의 주체가 된다.

 

 


 

아래의 테스트 하려는 코드를 다시 살펴보자.

 

@Service
@AllArgsConstructor
@Transactional(readOnly = true)
public class EnrollmentCommandService {
    private final EnrollmentRepository enrollmentRepository;
    private final EnrollmentQueryService enrollmentQueryService;
    private final LectureQueryService lectureQueryService;
    private final MemberQueryService memberQueryService;
    private final LectureMemberQueryService lectureMemberQueryService;
    private final EmailService emailService;

    /**
     * [학생 : 강의 등록 요청]
     * 해당 강의 등록을 교수자에게 요청한다.
     *
     * @param lectureId - 강의 ID
     * @return Long - 초대 PK
     */
    @Transactional
    public Enrollment requestEnrollment(Long lectureId, Long memberId) {

        Lecture lecture = lectureQueryService.queryOne(lectureId);
        Member member = memberQueryService.query(memberId);

        // Validate : 등록요청을 한 강의가 삭제된 경우
        if (lecture.getDeleted() == true) {
            throw new NoSuchElementFoundException404(ErrorDefineCode.DELETED_LECTURE);
        }

        // Validate : 이미 대기중인 초대 요청이 있는 지 조회
        isHaveAlreadyWaitRequest(member, lecture);

        // Validate : 이미 강의에 소속되어 있는 지 조회
        isEnrolled(member, lecture);

        // Insert : 데이터베이스 저장
        Enrollment enrollment = Enrollment.builder()
                .status(EnrollmentStatus.WAITING)
                .lecture(lecture)
                .member(member)
                .modified_by(member)
                .build();

        enrollment = enrollmentRepository.save(enrollment);
        
        // 교수자에게 초대가 왔음을 이메일로 보내 알린다.
        boolean result = emailService.send(enrollment);
        if (!result) {
            throw new IllegalArgumentException("메일 전송에 실패했습니다.");
        }

        // Out
        return enrollment;
    }


    /**
     * 해당 회원이 이미 Wait 상태인 Member-Lecture Enroll이 존재하는지 확인
     */
    private void isHaveAlreadyWaitRequest(Member member, Lecture lecture) {
        boolean exist = enrollmentQueryService.existWaitingEnrollmentByMemberId(member.getId(), lecture.getId());

        if (exist) {
            throw new AlreadyExistElementException409(ErrorDefineCode.ALREADY_ENROLL_REQUEST);
        }
    }


    /**
     * 이미 해당 Lecture에 Member가 등록되어 있는지 확인
     */
    private void isEnrolled(Member member, Lecture lecture) {
        boolean isEnrolled = lectureMemberQueryService.existLectureMember(
                member.getId(), lecture.getId());

        if (isEnrolled) {
            throw new AlreadyExistElementException409(ErrorDefineCode.ALREADY_JOIN);
        }
    }
}

 

외부의 의존하는 객체의 메서드를 사용하는 부분들을 파악해야 한다.

 

1. 해당 강의(Lecture)의 존재여부 파악 : lectureQueryService.queryOne(lectureId)
2. 요청자(Member)의 존재여부 파악 : memberQueryService.query(memberId)
3. 대기(Waiting)상태인 초대요청(Enrollment)가 이미 있는지 파악 : enrollmentQueryService.existWaitingEnrollmentByMemberId(member.getId(), lecture.getId())
4. 이미 강의(Lecture)에 요청자(Member)가 소속되었는지 파악 : lectureMemberQueryService.existLectureMember(member.getId(), lecture.getId())
5. Enrollment 데이터베이스에 저장 : enrollmentRepository.save()

 

 

 


Mock 객체의 메서드 호출에 대한 행위를 지정

 

@Test
@DisplayName("Enrollment : (학생) 강의 등록 요청")
public void enrollmentRequest() throws Exception {

    //given
    Member student = Member.builder()
        // ...
        .build();
    Member tutor = Member.builder()
        // ...
        .build();
    Lecture lecture = Lecture.builder()
        // ...
        .build();
            
    // given : Stubbing (메서드에 대한 행위 지정)
    given(lectureQueryService.queryOne(lecture.getId())).willReturn(lecture);
    given(memberQueryService.query(student.getId())).willReturn(student);
    given(enrollmentQueryService.existWaitingEnrollmentByMemberId(student.getId(), lecture.getId())).willReturn(false);
    given(lectureMemberQueryService.existLectureMember(student.getId(), lecture.getId())).willReturn(false);
    given(emailService.send(any(Enrollment.class)).willReturn(true);
    given(enrollmentRepository.save(any(Enrollment.class))).will(AdditionalAnswers.returnsFirstArg());

    //when
    Enrollment enrollment = enrollmentCommandService.requestEnrollment(lecture.getId(), student.getId());

    //then
    assertThat(enrollment).isNotNull();
    assertThat(enrollment.getMember().getEmail()).isEqualTo(student.getEmail());
    assertThat(enrollment.getLecture().getTitle()).isEqualTo(lecture.getTitle());
    assertThat(enrollment.getStatus()).isEqualTo(EnrollmentStatus.WAITING);


}

 

 

위에서 파악한 외부의 의존하는 객체의 메서드를 사용하는 부분들을 given에 외부 컴포넌트의 메서드를 호출하는 부분에 대해서 예상되는 시나리오를 작성해주자. 아래의 예시를 통해 한번 살펴보자.

 

대기(Waiting)상태인 초대요청(Enrollment)가 이미 있는지 파악 

 

given(enrollmentQueryService.existWaitingEnrollmentByMemberId(student.getId(),lecture.getId())).willReturn(false)

  • EnrollmentQueryService Mock 객체의 existWaitingEnrollmentByMemberId() 메서드가 호출될 때, 특정 Member ID와 특정 Lecture ID를 인자로 받을 때의 기대되는 결과값으로 대기중인 초대요청이 없음을 나타내는 Boolean 타입의 false를 반환하도록 설정한다.
  • 실제 EnrollmentQueryService에 작성된 existWaitingEnrollmentByMemberId()의 코드는 이용할 수 없다고 했다.
  • 대신 테스트가 실행될 때 외부 컴포넌트의 해당 메서드가 호출되면 false 객체를 반환하는 것처럼 동작하도록 한다.
    • 이미 요청한 (대기 상태인) 초대가 없으므로 제약사항에 걸리지 않고 Enrollment를 요청할 수 있도록 동작하게 된다.

 

Enrollment를 데이터베이스에 저장

 

when(enrollmentRepository.save(any(Enrollment.class))).then(AdditionalAnswers.returnsFirstArg())

  • 외부 의존 객체인 EnrollmentRepository의 save()메서드를 사용할 때, 인자(Arg, any(Enrollment.class))로 들어온 것을 그대로 반환하여 데이터베이스에 삽입이 성공한 것 처럼 보이도록 하는 것이다.

 

전체 코드

@Transactional
@DisplayName("Enrollment Command Service")
@ExtendWith(MockitoExtension.class)
public class EnrollmentCommandServiceTest {

    // 테스트 주체의 외부 의존성들 -> Mock 객체로 생성하기
    @Mock
    private EnrollmentRepository enrollmentRepository;
    @Mock
    private LectureQueryService lectureQueryService;
    @Mock
    private MemberQueryService memberQueryService;
    @Mock
    private LectureMemberQueryService lectureMemberQueryService;
    @Mock
    private EnrollmentQueryService enrollmentQueryService;
    @Mock
    private EmailService emailService;

    // 단위 테스트의 주체 -> @InjectMocks로 가짜 의존성 주입받기
    @InjectMocks
    private EnrollmentCommandService enrollmentCommandService;

    @Test
    @DisplayName("Enrollment : (학생) 강의 등록 요청")
    public void enrollmentRequest() throws Exception {

        //given
        Member student = Member.builder()
            // ...
            .build();
        Member tutor = Member.builder()
            // ...
            .build();
        Lecture lecture = Lecture.builder()
            // ...
            .build();

        // given : Stubbing (메서드에 대한 행위 지정)
        given(lectureQueryService.queryOne(lecture.getId())).willReturn(lecture);
        given(memberQueryService.query(student.getId())).willReturn(student);
        given(enrollmentQueryService.existWaitingEnrollmentByMemberId(student.getId(), lecture.getId())).willReturn(false);
        given(lectureMemberQueryService.existLectureMember(student.getId(), lecture.getId())).willReturn(false);
        given(emailService.send(any(Enrollment.class)).willReturn(true);
        given(enrollmentRepository.save(any(Enrollment.class))).will(AdditionalAnswers.returnsFirstArg());

        //when
        Enrollment enrollment = enrollmentCommandService.requestEnrollment(lecture.getId(), student.getId());

        //then
        assertThat(enrollment).isNotNull();
        assertThat(enrollment.getMember().getEmail()).isEqualTo(student.getEmail());
        assertThat(enrollment.getLecture().getTitle()).isEqualTo(lecture.getTitle());
        assertThat(enrollment.getStatus()).isEqualTo(EnrollmentStatus.WAITING);


    }

    
}

 

 

 


단일 책임 원칙 : 내 것만 잘하자

 

만약 외부 컴포넌트가 지정한 행위처럼 동작하지 않으면 해당 단위 테스트에서는 성공하지만, 실제로는 정상적으로 동작하지 않을 것입니다. 그러면 문제가 되는 것이 아닌가요?

 

단위 테스트에서 Mock을 사용하는 이유는 의존성을 격리시켜서 특정 부분에 집중해서 테스트를 하고 책임을 명확히 하겠다는 것이었다.
단위 테스트에서 의존하는 외부 컴포넌트에서 문제가 발생하여 해당 서비스에까지 문제가 발생한다면, 이는 문제가 발생하고 있는 컴포넌트의 단위 테스트에 책임을 위임하고 현재 테스트 단위에만 집중할 수 있게 해야 한다.

 

 

 


의존하는 다른 서비스들에 대한 단위 테스트 작성

 

대기(Waiting)상태인 초대요청(Enrollment)가 이미 있는지 파악 

 

given(enrollmentQueryService.existWaitingEnrollmentByMemberId(student.getId(), lecture.getId())).willReturn(false);

 

enrollmentQueryService.existWaitingEnrollmentByMemberId()가 올바르게 동작하고 있는지 아닌지는 EnrollmentCommandServiceTest에서 판별할 일이 아니다.

EnrollmentQueryService에서 발생하는 문제점은 EnrollmentQueryServiceTest에서 판별할 일이다. EnrollmentCommandServiceTest는 오직 EnrollmentCommandService의 문제점에 대해서만 판별하면 충분하다.

 

 

 EnrollmentQueryServiceTest

더보기
@Transactional
@DisplayName("Enrollment Query Service")
@ExtendWith(MockitoExtension.class)
public class EnrollmentQueryServiceTest {
    @Mock
    private EnrollmentRepository enrollmentRepository;
    @InjectMocks
    private EnrollmentQueryService enrollmentQueryService;


    @Test
    @DisplayName("해당 Student가 Lecutre에 이미 초대 요청을 보내 대기중인 Enrollment가 있는지 확인 ")
    public void existWaitingEnrollmentTest() throws Exception {

        Enrollment enrollment = givenEnrollment();


        given(enrollmentRepository.findEnrollment(
                EnrollmentQueryFilter.builder()
                        .memberId(1L)
                        .lectureId(1L)
                        .statuses(List.of(EnrollmentStatus.WAITING))
                        .build()
        )).willReturn(Optional.ofNullable(enrollment));


        //when
        boolean exist = enrollmentQueryService.existWaitingEnrollmentByMemberId(1L, 1L);

        //then
        assertThat(exist).isEqualTo(true);

    }

    private Enrollment givenEnrollment(){
        //given
        // ...
        return enrollment;
    }
}

 


 

Enrollment를 데이터베이스에 저장
when(enrollmentRepository.save(any(Enrollment.class))).then(AdditionalAnswers.returnsFirstArg());

 

마찬가지로 EnrollmentCommandServiceTest에서 호출되는 외부 의존 Repository의 메서드에 대한 검증은 해당 Repository의 Test가 하도록 하면 된다.

 

 EnrollmentRepositoryTest

더보기
@DataJpaTest
@DisplayName("Enrollment Repository Test")
@Import({QueryDslConfig.class, OffsetDateTimeProvider.class})
public class EnrollmentRepositoryTest {

    @Autowired
    private EnrollmentRepository enrollmentRepository;

    Member student;
    Member tutor;
    Lecture lecture;
    Enrollment waitEnrollment;

    @BeforeEach
    public void before(){
        this.initializeData();
    }

    @Test
    @DisplayName("해당 Member가 Lecutre에 이미 요청한 Enrollment(대기중) 가 존재하는지 확인")
    public void findExistWaitingEnrollmentTest(){
        //given
        givenEnrollment();

        //when
        Optional<Enrollment> enrollment = enrollmentRepository.findEnrollment(
                EnrollmentQueryFilter.builder()
                        .memberId(student.getId())
                        .lectureId(lecture.getId())
                        .statuses(List.of(EnrollmentStatus.WAITING))
                        .build()
        );

        //then
        assertThat(enrollment.get().getMember().getId()).isEqualTo(student.getId());
        assertThat(enrollment.get().getLecture().getId()).isEqualTo(lecture.getId());
        assertThat(enrollment.get().getLecture().getOwner().getId()).isEqualTo(tutor.getId());
    }


    private void givenEnrollment(){
        waitEnrollment = Enrollment.builder()
                .status(EnrollmentStatus.WAITING)
                .lecture(lecture)
                .member(student)
                .modified_by(student)
                .build();

        waitEnrollment = enrollmentRepository.save(waitEnrollment);

    }



    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private LectureRepository lectureRepository;
    
    private void initializeData(){

        student = Member.builder()
                .email("enrollstd@test.com")
                .password("test123!")
                .name("학생1")
                .role(Authority.ROLE_USER)
                .birth(OffsetDateTime.now())
                .build();

        // 이하 테스트 데이터 만드는 코드 생략
        //tutor = Member.builder()...
        //student = memberRepository.save(student);
        //tutor = memberRepository.save(tutor);
        //lecture = Lecture.builder()...
        //lecture = lectureRepository.save(lecture);
    }
}

 


Repository단도 단위 테스트 코드를 작성해서, 해당 컴포넌트에서 발생하는 일들은 해당 테스트 코드가 담당하도록 작성해 주자.

여담으로 Repository단에서의 테스트 코드를 작성할 때에는 아래의 사항을 참고하면 될 것 같다.

  • @DataJPATest를 사용 : 데이터베이스와 상호 작용하는 코드를 테스트할 때 데이터베이스를 완전히 적재하고 구성할 필요 없이 인메모리 데이터베이스를 사용할 수 있다.
  • 데이터베이스와의 상호작용을 테스트 하기 위해서는 실제 JPA 관련 의존성이 필요하고, 해당 JPA 의존성은 이제 우리가 작성한 것이 아니기 때문에 Test도 불가능하다. 따라서 Mock 객체를 사용하지 않고 의존성을 주입받아 사용하였다.

 

 

 


통합 테스트 : MockBean

 

이번에는 통합 테스트 관점에서의 Mock의 사용에 대해서 알아보도록 하겠다.

일반적으로 통합 테스트는 @SpringBootTest 등을 통해서 의존하는 다른 모듈들의 Bean을 주입받아 실제 객체간의 상호작용을 테스트한다. 

 

아래의 코드는 위에서 살펴보았던 단위 테스트 코드를 통합 테스트 관점에서의 코드로 바꾼 것이다.

@SpringBootTest
@Transactional
@DisplayName("Enrollment Service")
@AutoConfigureTestDatabase
public class EnrollmentServiceTest {

    @Autowired
    private EnrollmentCommandService enrollmentCommandService;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private LectureRepository lectureRepository;


    @Test
    @DisplayName("Enrollment : (학생) 강의 등록 요청")
    public void testEnrollment_Request_Enrollment() throws Exception {
        //given
        Member student = Member.builder()
                //...
                .build();

        Member tutor = Member.builder()
                //...
                .build();

        memberRepository.saveAll(List.of(student, tutor));

        Lecture lecture = Lecture.builder()
                //...
                .build();

        lectureRepository.save(lecture);



        //when
        Long enrollment = enrollmentCommandService.requestEnrollment(lecture.getId(), student.getId()).getId();

        //then
        assertThat(enrollment).isNotNull();

    }
    

}

 

 


MockBean

 

그렇다면 Mock이 언제 필요한 것인가? 바로 통제할 수 없는 외부 시스템과 상호작용하는 모듈에 대해서다.

예를 들어 OpenAPI 등을 통해서 외부 시스템의 데이터를 가져온다고 가정해보자. 이는 우리가 통제할 수 없는 영역이므로 "기대되는 결과"에 대한 행위를 Mock으로 지정해두고 사용하는 전략을 사용할 수 있을 것이다.

 

 

통합 테스트의 일부 모듈만을 Mock 객체로 대체하여 사용할 수 있는 방법으로 MockBean을 사용하면 된다.

우리의 예시 코드에서는 교수자의 이메일로 알림을 송신하는 것을 Test Double로 처리하면 좋겠다는 생각이 든다. 

 

@SpringBootTest
@Transactional
@DisplayName("Enrollment Service")
@AutoConfigureTestDatabase
public class EnrollmentServiceTest {

    @Autowired
    private EnrollmentCommandService enrollmentCommandService;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private LectureRepository lectureRepository;
    
    @MockBean
    private EmailService emailService; //일부 모듈을 MockBean으로 처리


    @Test
    @DisplayName("Enrollment : (학생) 강의 등록 요청")
    public void testEnrollment_Request_Enrollment() throws Exception {
        //given
        Member student = Member.builder()
                //...
                .build();
        // ...         
        lectureRepository.save(lecture);
        
        // stubbing (Mock 객체에 대한 Stubbing)
        given(emailService.send(any(Enrollment.class)).willReturn(true);



        //when
        Long enrollment = enrollmentCommandService.requestEnrollment(lecture.getId(), student.getId()).getId();

        //then
        assertThat(enrollment).isNotNull();

    }
    

}

 

@MockBean을 통해 Mock으로 대체할 일부 모듈을 지정할 수 있으며, 앞에서 살펴보았듯 Mock 객체에 대한 기대 행위를 Stubbing해주어 사용하면 된다.

 

 

 

 


 

단위 테스트 vs 통합 테스트

 

지금까지 단위 테스트와 통합 테스트에서 Test Double을 적용해보았다. 그렇다면 이런 의문이 생길 수 있다. 

 

그래서 테스트 코드를 작성할 때 특정 모듈에 집중하기 위해 Mock으로 처리하여 단위 테스트로 작성하라는 것인가? 통합 테스트로 작성하라는 것인가?

결론부터 말하면 "정해진 것은 없다"이다. 모든 것에는 Trade-off가 있기 때문이다.

  • Mock을 적극적으로 활용하여 단위 테스트를 작성하면 테스트의 주체에 대한 행위에 집중하여 테스트가 이루어진다. 
  • 그렇지만 모든 의존성에 대한 Stubbing을 하기 위해 기대 행위를 작성하는 과정이 생산성을 저하시키고 과연 올바르게 행위를 기술했는가에 대한 신뢰성의 의문이 생길 수 있다.

 

 


Classicist VS Mockist

 

이렇게 상이한 테스트 코드의 접근 방식이 Classicist와 Mockist가 있다. 각자 프로젝트의 팀 철학에 따라 갈리는 부분이라고 할 수 있겠다.

 

Classicist

  • 객체들 간의 실제 상호작용의 결과에 대해서 테스트의 중심을 두는 것.
  • 통합 테스트 성격
  • Test Double 사용의 최소화

Mockist

  • 특정 객체의 테스트에 집중. 다른 객체와의 상호작용은 실제 행위보다, 호출 여부 등 상호작용에 중점을 두는 것.
  • 단위 테스트 성격.
  • Test Double 적극 활용

 

 


Layer 별 테스트 전략

 

이렇듯 테스트 코드를 작성하는 데에도 정답은 없지만 필자가 좋다고 생각하는 방식이다.

Controller와 Repository는 단위 테스트의 성격으로, Service Layer는 통합 테스트의 성격으로 작성하는 편이다.

https://sjh9708.tistory.com/240

 

[Spring Boot] 테스트 코드 : 계층별 테스트 코드 작성 전략 (Controller, Service, Repository)

우리는 흔히 Controller, Service, Repository 등의 계층별 모듈로 구분하여 프로그램을 작성한다.이번 포스팅에서는 Layer 별 테스트 코드를 어떻게 작성하면 좋을지에 대한 전략에 대해서 작성해보려고

sjh9708.tistory.com

 

 

 


결론


단위 테스트에 대해서는 크게 아래의 두 가지 이유로 Mocking을 사용하는 것이 더 많은 장점을 가질 수 있다.

 

1. 테스트 격리: 단위 테스트는 시스템의 특정 부분을 격리하여 테스트하는 것이 관건이다. Mocking을 사용해서 외부의 문제를 신경쓰지 않고 오로지 목표 컴포넌트에 대한 테스트만을 진행할 수 있어 테스트 코드의 책임 범위를 명확하게 설정할 수 있다.

 

2. 빠른 속도: Spring Context를 로드하지 않고 외부 의존성 없이 가짜 객체를 사용하기 때문이다.

 

 

그렇지만 모든 의존성을 격리하기 위해 Stubbing을 하는 과정의 생산성과 신뢰성을 고려해야 한다. 모든 것은 Trade-off, 결국 당신의 팀의 전략에 맞게 Test Double을 사용하는 전략을 세워야 할 것이다.

 

 

 

반응형

BELATED ARTICLES

more