테스트 코드 유지보수 해보기 4

2023. 5. 17. 00:35테스트

배경

이전 포스팅에 이어 테스트 코드 유지보수를 이어나가 보겠습니다.

 

이전 포스팅에서 적용한 것들은 다음과 같습니다.

  • test datasource 설정 (1)
  • schema.sql, data.sql 추가 (1)
  • 기획서를 참고하여 기존 로직 수정 (2)
  • JPA 영속성 컨텍스트 관련 이슈 조치 (2)
  • Mock 기간 데이터 생성 메서드 수정 (3)
  • TestExecutionListener 추가 (3)

 

이렇게 했을 때, 마지막 테스트 수행 결과는 다음과 같았습니다.

테스트 실행 결과

 

모두 해결이 된 것 같아 커밋 시 파이프라인에서 전체 테스트를 수행하도록 했습니다.

파이프라인에서 Maven 테스트를 실행하는 방법은 이전에 작성한 적이 있으니 여기서 참고 부탁드립니다.

테스트 파이프라인 구축 (Bitbucket, Teams 노티)

 

테스트 파이프라인 구축 (Bitbucket, Teams 노티)

배경 저희 팀은 API, 화면 개발 모두 필요한 경우에는 BE(백엔드)와 FE(프론트엔드) 작업담당자가 REST API Request, Response 모델에 대해서 사전에 논의합니다. 논의한 내용을 바탕으로 각자 파트에서 작

ting-kim.tistory.com

 

 

문제 상황

파이프라인에서 테스트를 수행한 결과..

파이프라인 내 테스트 실행 결과

실패하는 모습을 보실 수 있습니다.

에러 로그는 다음과 같았는데요. 제가 이번 테스트 유지 보수를 진행하면서 가장 많이 겪은 에러입니다.

Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query

 

로그 내용을 읽으면 알 수 있듯이, 실행 중 DB에 query를 실행시킬 때 트랜잭션을 얻을 수 없다는 에러입니다.

저는 이러한 에러가 발생하는 원인을 알 수 없었습니다. 왜냐하면, 테스트 코드 중간에 따로 트랜잭션을 Close 하는 부분이 없기 때문입니다.

@SpringBootTest
@RunWith(SpringRunner.class)
@Transactional
class OrderCommentRemoveTest {

    // ..

    @Test
    void should_emptyOrderComment_when_removeCommentsOfOrder() {

        // ..

        orderCommentRepository.deleteAllInBatch(orderComments); // exception occured !
        em.flush();
        em.clear();

        // ..
    }
}

 

그리고 이전에도 등장했던, 데이터를 분명 insert 한 후 조회했는데 조회 결과가 없는 이슈도 발생하고 있었습니다.

java.util.NoSuchElementException: No value present

이러한 문제가 발생하는 정확한 이유에 대해서는 정말 오랜 시간 확인해보고, 고민을 했지만 파악을 못하고 있었습니다.

 

 

원인 분석

어느 날, 다시 이슈 원인 분석을 하고 있는 도중에 특이한 현상을 발견했습니다.

모듈 전체 테스트를 실행했을 때, 아래 테스트 순서대로 OrderUpdateTest가 먼저 수행되고, 중간에 여러 테스트들이 실행된 이후에 OrderCancelTest가 실행됩니다.

 

성공/실패하는 테스트

 

첫 번째 테스트인 OrderUpdateTest의 경우를 보겠습니다.

@SpringBootTest
@Transactional
@FixMethodOrder
class OrderUpdateTest {

    // ..

    @PersistenceContext
    private EntityManager em;

    @Inject
    private OrderRepository OrderRepository; // Debug: org.springframework.data.jpa.repository.support.SimpleJpaRepository@704dce8c

    // ..
}

 

디버깅 모드로 실행해봤을 때, 의존성 주입을 받고 있는 OrderRepository의 객체 주소는 @704dce8c 입니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Configurable
public class Order {

    // ..

    @Transient
    @Getter(AccessLevel.NONE)
    @Inject
    protected OrderRepository orderRepository; // Debug: org.springframework.data.jpa.repository.support.SimpleJpaRepository@704dce8c

    // ..
}

 

그리고 해당 테스트에서 사용되는 엔티티인 OrderLoadTimeWeaver를 통해 주입되는 OrderRepository 객체 주소 또한 @704dce8c 입니다. 지금까지 문제 없이 잘 동작합니다.

이후에 여러 테스트가 실행된 후, OrderCancelTest에 대해서 디버깅 해보겠습니다.

@SpringBootTest
@Transactional
@FixMethodOrder
class OrderCancelTest {

    // ..

    @PersistenceContext
    private EntityManager em;

    @Inject
    private OrderRepository OrderRepository; // Debug: org.springframework.data.jpa.repository.support.SimpleJpaRepository@704dce8c

    // ..
}

 

여기에 주입되는 OrderRepository 객체 주소는 위에서 봤던 주소와 동일합니다.

하지만, 이번에도 엔티티에 대해서 디버깅해보면 결과가 조금 다릅니다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Configurable
public class Order {

    // ..

    @Transient
    @Getter(AccessLevel.NONE)
    @Inject
    protected OrderRepository orderRepository; // Debug: org.springframework.data.jpa.repository.support.SimpleJpaRepository@223f9cef

    // ..
}

주입되는 OrderRepository의 객체가 다른 것을 볼 수 있습니다.

왜 이런 현상이 나타난 걸까요?

 

 

테스트 컨텍스트

Junit이 테스트를 실행하는 순서는 별도의 설정이 없다면 보장되지 않습니다. 이는 메서드, 클래스 단위 모두에서 적용되는 이야기입니다.

Junit에서 지원하는 기능들을 통해 커스텀하게 순서를 설정할 수도 있긴 합니다. https://junit.org/junit5/docs/current/user-guide/#writing-tests-test-execution-order

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

junit.org

 

하지만, 항상 전체적인 테스트의 순서를 파악하고 일일이 지정해주기 어렵고, 테스트에 너무 과도한 설정이 들어가게 되면 팀에서 테스트를 작성하는 문화를 지속하기 힘들다고 생각합니다.

 

 

이번에는 Spring의 테스트 컨텍스트에 대해서 이야기 해보겠습니다.

@SpringBootTest를 이용할 때 Spring이 지원하는 테스트 컨텍스트를 사용하게 됩니다. 각 테스트에는 어플리케이션 컨텍스트가 필요한데, 모든 테스트 메서드 마다 어플리케이션 컨텍스트를 새로 생성하여 주입한다면 시간이 굉장히 오래 걸릴 것입니다.

 

스프링에서는 이를 최적화하기 위해 컨텍스트 캐싱을 지원합니다. 테스트는 실행 시 테스트 클래스에 대한 인스턴스를 생성하고, 해당 인스턴스를 통해 각 테스트 메서드들을 호출합니다.

 

이 과정에서 테스트에서 사용하는 Bean의 종류, 설정 등의 조건들이 동일하다고 판단되는 경우, 해당 인스턴스들에 동일한 어플리케이션 컨텍스트를 주입하여 실행합니다.

 

테스트 컨텍스트 프레임워크의 동작 구조

 

이제 위에서 만났던 현상을 여기에 적용해보겠습니다.

위에서 다뤘던 예시 테스트 클래스는 OrderUpdateTestOrderCancelTest 였습니다. 이 두 테스트 클래스가 바로 같은 어플리케이션 컨텍스트를 주입받았습니다. (같은 Bean을 주입 받았었죠?)

그러므로, OrderUpdateTest, OrderCancelTest는 각각 위 이미지에서 애플리케이션 컨텍스트 A를 주입 받은 Test 1, Test N이라고 하겠습니다.

 

아까 테스트 실행 순서는 보장되지 않는다고 말씀드렸습니다. 즉, 같은 어플리케이션 컨텍스트를 이용하더라도 그 사이에 다른 어플리케이션 컨텍스트를 주입 받은 다양한 테스트들이 실행될 수 있습니다. 애플리케이션 컨텍스트 A의 Test 1, Test N 사이에 애플리케이션 컨텍스트 B의 Test 1 ~ Test N이 모두 실행된다고 생각해보겠습니다.

 

테스트 컨텍스트 프레임워크는 우선 애플리케이션 컨텍스트를 포함한 여러 설정들을 주입하며 각 테스트 인스턴스들을 생성할 것입니다.

// 설명하기 위해 작성한 예시
public class TestContextFramework {

    // ..

    public void getReady() {
        ApplicationContextA a = new ApplicationContextA();
        ApplicationContextB b = new ApplicationContextB();
        ArrayList<Test> tests = Lists.newArrayList(
            new ATestOne(a),
            new ATestTwo(a),
            new ATestThree(a),
            // ..
            new ATestN(a),
            new BTestOne(b),
            new BTestTwo(b),
            // ..
            new BTestN(b)
            // ..
        );

        tests.randomSort();

        execute(tests);
    }

    // ..
}

 

초기 어플리케이션이 주입되어 실행될 때, 클래스 로딩이 일어납니다. 그렇게 되면 프로젝트에 적용했던 LTW(LoadTime-weaving)이 동작하며 우리의 엔티티에도 필요한 Bean들을 주입해줄 것입니다.

 

따라서, 다음과 같이 진행됩니다.

  • ATest 1 ~ ATest N 생성 (애플리케이션 컨텍스트 A)
  • BTest 1 ~ BTest N 생성 (애플리케이션 컨텍스트 B)
  • ATest 1 실행 : 클래스 로딩, 각 엔티티에 애플리케이션 컨텍스트 A 관련 Bean 주입
  • BTest 1 ~ BTest N 실행 : 클래스 로딩, 각 엔티티에 애플리케이션 컨텍스트 B 관련 Bean 주입
  • ATest N 실행 : 별도 클래스 로딩 X, 각 엔티티에는 애플리케이션 컨텍스트 B 관련 Bean이 주입된 상태

 

이제 위에서 발생한 이상한 현상에 대해서 이해가 되시나요?

맞습니다. 이를 예시에 적용하여 정리해보면 다음과 같습니다.

  • OrderUpdateTest, OrderCancelTest 는 같은 애플리케이션 컨텍스트를 주입 받고
  • OrderUpdateTest 실행 후 다른 테스트들이 실행되면서 LTW를 통해 Order 엔티티에는 다른 애플리케이션 컨텍스트의 OrderRepository Bean이 주입
  • OrderCancelTest 실행 시 애플리케이션 컨텍스트와 다른 Bean이 Order 엔티티에 주입되어 있는 상태
  • 테스트 중 OrderRepository 메서드가 포함된 Order 내부 로직 호출 시 다른 애플리케이션 컨텍스트에 대한 트랜잭션이 없기 때문에 Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query 에러 발생

 

이러한 과정을 통해 테스트 실행 시 이상 현상(?)이 발생하는 원인을 파악할 수 있었습니다.

 

그래서, 이 문제는 어떻게 해결할 수 있을까요?

기존 코드를 엔티티에 의존성 주입이 이루어지지 않게 대대적인 개편을 할수도 있고, 테스트 순서를 명시적으로 정하는 등의 방식이 있을 수 있겠지만, 저는 이렇게 애플리케이션 컨텍스트를 함께 사용하면서 트랜잭션이 필요한 테스트에 @DirtiesContext 어노테이션을 추가하는 방식을 택했습니다.

테스트를 위해서 기존 기능에 과도한 수정이 발생하는 것은 좋은 방향이 아니라고 생각하며, 비슷한 상황이 다시 발생했을 때 구성원 모두가 쉽게 해결할 수 있어야 하기 때문입니다.

 

파이프라인에서 테스트가 모두 통과하는 모습

 

테스트 리팩토링 및 개선을 진행하면서 여러 기술들을 잘 알고 사용해야 한다는 점을 많이 느꼈습니다.

다양한 테스트 관련 문제들을 해결하며 이번 기회에 많은 것을 배운 것 같습니다. 이를 바탕으로 테스트를 점차 잘 작성하고, 문제를 원활하게 해결할 수 있기를 기대하며 포스팅을 마무리하겠습니다.

 

 

참고

JUnit 5 User Guide