스프링/프로젝트

[스프링부트] 통합 테스트 작성 과정

쑥멍 2023. 7. 1. 14:15

힘들었다.

처음엔 어떻게 써야할 지 알 수 없었다. 지금도 마찬가지긴 하지만. 그땐 더더욱 암흑이었다. 그냥 각 엔티티 save 하는 것만 테스트 했고 그것만 해도 힘들었다. 이런저런 준비 과정이 필요하고 어떤 라이브러리의 함수를 써야할 지도 몰랐다. 

그런데 테스트 프레임워크 사용법을 모르는 것보다 방법론을 모르는 게 더 영향이 컸다. 그래서 다른 사람들의 깃허브를 보고 참고하고, <단위 테스트>라는 책을 읽었다. 이 책이 도움이 많이 되었다. 그런데 아직 단위 테스트는 어떻게 써야할 지 감을 못 잡았고 통합 테스트만 썼다. 

 

1) 동작 단위로 테스트 & 외부 의존성만 목킹

책에서는 테스트 방식에 있어 고전파, 런던파가 있다고 소개한다. 런던파가 빡세게 목킹하고, 클래스 단위로 쪼개서 테스트해야한다고 주장한다. 고전파는 반대로 외부 의존성만 목킹하고, 동작 단위로 쪼개 테스트해야한다고 주장한다. 저자는 고전파 손을 들어줬는데 나도 그게 맞다고 생각한다. 너무 잘게 쪼개면 의미 파악이 어려우니까. 중요한 점은 비즈니스 관계자가 이해할 수 있는 만큼의 동작을 단위로 해야한다고 한다. 그리고 DB를 제외한 외부 의존성만 목킹하라고 하더라. 오히려 DB가 안 정해졌다면, 통합 테스트는 나중에 쓰고 단위 테스트만 쓰라고 해서 놀랐다.

 

@SpringBootTest, @AutoConfigureMockMvc 어노테이션을 써서 컨트롤러 테스트부터 썼다. 외부 의존성만 목킹하라고 했으니, 서비스와 리포지토리는 @Autowired로 주입하고, 서비스에서 쓰는 외부 API 클래스는 @MockBean으로 목킹했다.

@SpringBootTest
@AutoConfigureMockMvc
public class PasswordFindControllerTest {
    @Autowired
    ObjectMapper mapper;

    @Autowired
    private MockMvc mvc;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserFactory userFactory;

    @Autowired
    private ConfirmPasswordCodeRepository confirmPasswordCodeRepository;

    @MockBean
    private EmailSender emailSender;
    
    ...
}

PasswordFindControllerConfirmPasswordCodeService에 의존한다. 그리고 ConfirmPasswordCodeServiceEmailSender에 의존한다. EmailSender 클래스는 외부 API인 JavaMailSender를 호출한다. 그럼 EmailSender@MockBean 어노테이션을 붙이면 ConfirmPasswordCodeService에 목으로 주입된다. 외부 API만 목킹하고 나머지는 실제와 똑같이 구성한 것이다. 


2) 테스트 준비 구절은 어떻게? 

@SpringBootTest
public class DepositTaskTest {
    ...

    @BeforeEach
    public void setup() throws Exception {
        userFactory.createUser("duck12@gmail.com");
        Users user = userRepository.findByEmail("duck12@gmail.com").orElseThrow();

        Address address = addressAccountFactory.createAddress("김오리", user, true);
        Account account = addressAccountFactory.createAccount("김오리", user, true);

        Event event = eventFactory.createEvent(user, "오뤼", "2023-06-10");

        Gift gift = giftFactory.createGift("이어폰", 300000, event, account, address);
        paymentFactory.createPayment(50000, gift, user);
        giftService.expired(gift);
        giftRepository.save(gift);
        userRepository.save(user);
    }

    @AfterEach
    public void clean() {
        depositRepository.deleteAll();
        messageRepository.deleteAll();
        paymentRepository.deleteAll();
        giftRepository.deleteAll();
        eventRepository.deleteAll();
        addressAccountFactory.deleteAll();
        notificationRepository.deleteAll();
        userRepository.deleteAll();
    }
    
    ...
}

일부러 준비 구절이 긴 코드를 가져왔다. 

준비할 때는 테스트 데이터를 삽입하는 @BeforeEach 메소드를 쓰고 그걸 모두 삭제하는 @AfterEach도 썼다. 테스트 데이터 삽입은 각 엔티티에 대한 팩토리 클래스를 만들어서 얘도 주입해서 썼다. 책에서는 웬만하면 private 이너 메소드로 쓰라고 했지만 너무 길고 복잡하고 중복되길래 자주 쓰이는 건 팩토리로 분리했다.

 

테스트 데이터를 삭제하는 @AfterEach에서 삑사리가 많이 났다. 외래키가 복잡하게 꼬여있기 때문에 순서대로 잘 삭제하지 않으면 DB 제약조건 에러가 발생한다. 

 

그런데 이 방식 말고 @Transactional을 테스트 메소드에 붙여서 롤백하는 방식으로 바꿀까 말까 고민 중이다. 왜냐하면.

 

3) 제일 애먹게 한 것은 트랜잭션 

테스트 코드를 작성하면서 제일 애먹었던 점은 트랜잭션이다. 포스트맨으로 테스트할 땐 DB에 잘 저장되는데 테스트할 땐 잘 저장되지 않는 경우가 정말 잦았다. 그래서 그냥 @Transactional 붙여서 해결하고 싶었지만 대충 구글링해보니 테스트 메소드에 @Transactional을 쓰지 않는 게 좋다고 해서 악으로 깡으로 안 쓰고 버텼다.

 

그런데 토비님이 쓰는 걸 적극 권장하는 글을 봤다. 테스트 메소드에 @Transactional을 쓰면, 테스트로 인해 이리저리 바뀐 데이터들이 모두 롤백된다. 그래서 @BeforeEach, @AfterEach로 매번 생성, 삭제를 반복하는 것보다 편리하고 효율적인 것이다. 

그렇다면 왜 쓰지 않는게 좋다고 했을까? 프로덕션 코드에 @Transactional을 실제로 안 붙였는데, 테스트 메소드에만 붙이면 정상적으로 처리된다. 이 실수를 알아챌 수 없기 때문이다. (사실 이 설명만 보고 그렇군, 하고 안 썼던 건데 조금 더 검색해볼걸 그랬다)

토비님 글에서는 그런 단점이 있음에도 불구하고 쓰는 게 좋다고 한다. 이유가 뭐였지? 트랜잭션 경계가 두 개 이상인 메소드를 테스트한다면 강제 커밋을 해야 한다고 한다. 

 

자세한 내용은 이곳. https://www.inflearn.com/questions/792383/%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%97%90%EC%84%9C%EC%9D%98-transactional-%EC%82%AC%EC%9A%A9%EC%97%90-%EB%8C%80%ED%95%B4-%EC%A7%88%EB%AC%B8%EC%9D%B4-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4

 

테스트에서의 @Transactional 사용에 대해 질문이 있습니다. - 인프런 | 질문 & 답변

안녕하세요 토비 선생님!강의 너무 재밌게 잘 듣고 있습니다. 이제 몇개 남지 않아서 많이 아쉽네요.다름이 아니라 테스트 코드 작성시 `@Transactional` 어노테이션의 사용에 대해 질문이 있습니다.

www.inflearn.com

 

4) 검증도 하는 방법이 있다

극초반에 테스트 코드 쓸 땐 andExpect()로 json 응답만 검증했는데. 책을 읽고 나서는 외부 API 호출하는 메소드가 호출되었는지 검증하기도 하고 직접 리포지토리로 데이터가 저장되었는지 조회하기도 했다. 책에서 하라는 대로 했다. 검증할 때 처음에 만든 테스트 데이터로만 검증하지 말고, 직접 DB에서 조회해온 데이터와 꼭 비교하라고 했기 때문이다. 그리고 외부 API 메소드가 몇 번 호출되었는지 테스트할 수도 있더라.

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;

...

@MockBean
private EmailSender emailSender;

...

@Test
@Transactional
void confirm_password_success() throws Exception {

    mvc.perform(post("/api/user/find-password")
                    .param("email", "duck12@gmail.com")
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk());

    String userId = userRepository.findByEmail("duck12@gmail.com").orElseThrow().getId();
    ConfirmPasswordCode confirmPasswordCode = confirmPasswordCodeRepository.findByUserId(userId).orElse(null);
    String authCode = confirmPasswordCode.getId();

    mvc.perform(post("/api/user/confirm-password")
                    .param("authCode", authCode)
                    .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andDo(print());

    confirmPasswordCode = confirmPasswordCodeRepository.findByUserId(userId).orElse(null);
    assertTrue(confirmPasswordCode.isExpired());
    then(emailSender).should().sendEmail(any(SimpleMailMessage.class)); // ⭐

}
  1. EmailSender가 외부 API와 통신하는 클래스이다. 호출되어야 하는 메소드는 sendEmail()이다. 외부 API와 통신했는지, 상호작용을 검증할 때는 Mockito 라이브러리의 메소드를 쓰면 된다. 
    • then(emailSender).should().sendEmail(any(SimpleMailMessage.class)); 이 코드만 봐도 어떻게 써야할 지 감이 올 것이다.
    • EmailSender 클래스는 SimpleMailMessage 타입을 인자로 받는 sendEmail() 메소드를 호출해야 한다, 라는 뜻이다. 
  2. MockMvc로 요청을 보낸 후에 ConfirmPasswordCodeRepository로 조회해온다. DB에 잘 반영되었는지 확인하기 위함이다. 

5) 테스트코드 작성은 리팩토링을 유발

테스트코드를 쓰면 쓸수록 점점 참여하는 도메인들이 많아져서 골치 아팠다. UserController는 테스트 데이터도 User 엔티티만 만들면 되는데. 반면 입금 이체 스케줄러를 테스트하려면 Deposit 엔티티의 테스트 데이터를 만들어야 한다. Deposit을 만들려면 Gift가 Expired여야 하고, Gift가 Expired이려면 다른 유저가 Gift에 적어도 한 번은 결제를 해야 해서 또 Payment 엔티티도 필요하고... 등등. 이렇게 모든 엔티티들이 연쇄적으로 이어져있다. 그래서 입금 이체 테스트를 하는 것인데 이전에 테스트를 썼던 부분(User 생성, Gift 생성, Payment 생성)을 @BeforeEach에서 해야했다.

 

이 부분이 좀 헷갈렸는데, 준비 과정이 복잡하더라도 준비 과정에 속하지, 테스트로 쓰면 안 된다고 한다. 그냥 입금 이체 테스트에 결제도 포함해버리면 하나의 메소드로 테스트가 가능하지 않나 싶었지만 책에서는 중복되더라도 나눠서 테스트 하라길래 다 나눠서 했다. 

// DepositTask 테스트 코드
@BeforeEach
public void setup() throws Exception {
    userFactory.createUser("duck12@gmail.com");
    Users user = userRepository.findByEmail("duck12@gmail.com").orElseThrow();

    Address address = addressAccountFactory.createAddress("김오리", user, true);
    Account account = addressAccountFactory.createAccount("김오리", user, true);

    Event event = eventFactory.createEvent(user, "오뤼", "2023-06-10");

    Gift gift = giftFactory.createGift("이어폰", 300000, event, account, address);
    paymentFactory.createPayment(50000, gift, user);
    giftService.expired(gift);
    giftRepository.save(gift);
    userRepository.save(user);
}

준비 구절을 쓰다 보면 쉽게 가고 싶어진다. 위 코드의 아래에서 보면 알 수 있듯, 나는 편한 길을 갔다. 트랜잭션 적용이 안 돼서, createPayment()를 호출하면 Gift가 expired로 바뀌어야 하는데 바뀌지 않아서 결국 내가 직접 바꿨다.

 

이렇게 직접 바꿀 때 의문이 든다. 계층간 책임이 분명하지 않은 것 같다는 느낌이 들었다. 처음에는 GiftService.expired(gift)가 아니라 Gift.expired(gift)로, 도메인의 메소드를 호출하는 방식으로 했었는데. 왜 바꿨더라? 그냥 별로인 것 같았음. 이걸 private으로 하고 GiftService를 통해서 바꿀 수 있게 하는 게 나은 것 같았다. 왜냐하면. 모르겠다 나도 나중에 쓰자. 

테스트 코드 작성이 이런 자잘한 구조도 많이 바꾸게 해줬다.

 

테스트 데이터를 만들고, 그걸로 이것저것 테스트하고, 변경하다보면 높은 확률로 연관관계가 꼬인다. 그래서 연관관계 매핑도 고쳤다. 내 경우에는 @OneToMany를 쓰면서 애먹었다. 이 부분은 다음에 트랜잭션에 대한 글을 쓸 때 다루려고 한다. 

 

그런데 아직 단위 테스트는 못 쓰겠다. 통합 테스트 코드를 다 쓰고 나니 이 정도면 충분하지 않나, 싶기도 하고. 여기서 뭘 더 작게 쪼개서 단위 테스트를 해야할 지 모르겠기 때문이다.