728x90

테스트 더블이란?


제라드 메스자로스(Gerard Meszaros)가 사람들이 테스트를 위해 시스템의 일부를 떼어낼 때 사용하는 Stub, Mock, Fake, Dummy 등 다양한 이름들로 불리고 있던 것을 해결하기 위해 만든 용어가 바로 테스트 더블이다.

즉, 테스트 더블이란 테스트 목적으로 프로덕션 객체를 대체하는 모든 경우를 통칭하는 용어이다.

테스트 더블이란 용어는 영화 촬영 시 위험한 역할을 대신하는 스턴트 더블에서 비롯되었다고 한다.

 

테스트 더블 종류


일반적으로 크게 5가지의 종류로 Dummy, Fake, Stub, Spy, Mock으로 분류된다.

Dummy

  • 가장 기본적인 테스트 더블이다.
  • 객체가 전달되지만 사용되지 않는 객체다.
    • 보통 매개변수 목록을 채우는 데만 사용된다.
    • 동작하지 않아도 테스트에는 영향을 미치지 않은 테스트 객체.
  • 정말 말 그대로 모조 객체. 객체인 척만 한다.
    • 테스트 핵심 로직과는 상관없는 객체이다.

 

Fake

  • 복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
  • 실제 동작의 구현을 가지고 있지만, 실제 프로덕션에는 적합하지 않는 객체를 의미한다
예시로는 DAO 혹은 repository의 inmemory 구현이 있다.

 

Stub

  • 스텁은 미리 정의된 데이터를 보관하고 테스트 중에 호출에 응답하는 객체이다.
    • 테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공한다.
  • 쉽게 말해 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태다.

 

Spy

  • 스파이는 일부 기록 기능이 있는 테스트 스텁이다.
    • Stub의 역할을 하면서 호출된 내용에 대한 정보를 기록한다.
    • ex. 이메일 서비스를 목킹할 때 전송된 메시지 수를 기록할 때 사용할 수 있다.
  • 실제 객체처럼 동작시킬 수도 있고, 필요한 부분에 대해서는 Stub으로 만들어서 동작을 지정할 수 있다.
    • 실제 객체로도 사용할 수 있고, Stub 객체로도 활용할 수 있으며 필요한 경우 특정 메서드가 제대로 호출되었는지 여부를 확인할 수 있다.
  • 상태를 가질 수 있어 대표적인 상태 검증 테스트 더블이다.
Mockito 프레임워크의 verify() 메서드가 같은 역할을 한다.

 

 

Mock

  • 호출에 대한 기대를 명세하고 내용에 따라 동작하도록 프로그래밍된 객체를 의미한다.
  • 프로덕션 코드를 호출하고 싶지 않거나 의도한 코드가 실행되었는지 쉽게 확인할 방법이 없을 때 사용한다,
  • 따라서 다른 테스트 더블과는 다르게 행위 검증 사용을 추구한다.
Mockito 프레임워크가 대표적인 Mock 프레임워크라 볼 수 있다.

 

 

단위테스트


공통적인 속성

마틴 파울러가 정리한 '유닛테스트'에 대한 다양한 의견 중 공통점은 다음과 같다.

  • there is a notion that unit tests are low-level, focusing on a small part of the software system.
    • 단위 테스트는 소프트웨어 시스템의 작은 부분들에 집중하는 로우 레벨이다.
  • unit tests are usually written these days by the programmers themselves using their regular tools
    • 일반적인 도구를 사용해서 스스로 단위 테스트를 작성한다
  • Thirdly unit tests are expected to be significantly faster than other kinds of tests.
    • 단위 테스트는 다른 테스트 종류들의 테스트에 비해 확실하게 빠르기를 기대한다.

 

단위의 기준 

 

그렇다면 가장 헷갈리는 부분은 바로 단위이다. 무엇이 단위가 되어야 할까? 단위의 범위는 어디까지일까?

 

마틴 파울러는 단위는 상황적이라고 언급한다. 무엇이 단위가 되는 것인지는 팀이나 개인이 (그때그때) 정하는 것이라고 한다.

또한, 이런 것을 정의하는 것은 전혀 중요하지 않는다고 한다. 클래스를 하나의 단위로 취급할수도 있고 클래스 메서드들의 부분 집합을 하나의 단위로 삼을 수도 있다.

 

Sociable & Solitary


따라서 단위 테스트를 말할 때 더 중요한 차이는 단위가 단독(Solitary)으로 진행할지 혹은 협동적(Sociable)으로 진행할지 정의하는 데 있다고 한다. 

 

Mock 객체를 이용하여 고립되어 단독으로 실행하는 것을 지향하는 'Mockist`파와 Mock객체를 사용하지 않고 실제 의존하고 있는 객체를 이용하여 협동적인 테스트를 지향하는 'Classist'파로 나뉜다고 한다.

즉 SUT의 협력 객체를 실제 객체로 사용하는지 Mock 객체로 사용하는지에 따라 구분되어지는 것이다.

 

 

Classist vs Mockist


 

Classist

  • 협력 객체를 실제 객체를 사용한다.
    • 실제 객체를 사용하기 때문에 협력 객체의 상세 구현에 대해서 알 필요가 없다.
  • 행위가 끝난 후 직접적으로 상태를 검증을 한다.
    • 테스트의 안정성은 높아질 수 있다.
  • 비결정적인 외부 요인은 테스트 더블 사용이 가능하다.
  • 테스트와 테스트간의 격리

 

Mockist

  • 협력 객체를 Mock 객체를 사용한다.
    • 하지만 테스트가 협력 객체의 상세 구현을 알아야 한다.
  • 객체 내부의 상태가 아닌 행위를 검증한다.
    • 비교적 테스트의 안정성은 낮아질 수 있다.
  • 테스트가 상세 구현에 의존하는 경향이 생긴다.
  • SUT와 협력 객체간의 격리

 

Inside-Out vs Outside-In


이렇게 격리의 단위에 따라 테스트의 구현 방법이 달라지므로 자연스럽게 TDD의 방향도 달라지게 된다. 

주로 Classist는 Inside out 방법을 사용하고 Mockist는 outside in 방법을 사용한다.

Inside-Out

일반적으로 도메인부터 시작하여 개발을 진행한다. 도메인에 필요한 것은 무엇인지 생각하여 도메인 개체가 필요한 작업을 수행한다.

이후 작업이 완료되면 그 위에 사용자와 맞닿는 영역을 구현한다. 따라서

  • 리팩토링 단계에서 디자인이 도출된다.
  • TDD에서 빠른 피드백이 가능하다.
  • 오버 엔지니어링을 피하기 쉽다.
  • 객체 간의 협력이 이상하거나 public api가 잘못 설계될 수 있다.

 

Outside-In

일반적으로 시스템 외부에 대한 첫 테스트를 작성한다. 요구사항을 만족하기 위해 협력 객체들에 대해 생각한다.

이후 작업이 완료되면 시스템 내부적으로 들어가며 구현한다. 따라서

  • Test Red 단계에서 디자인이 도출된다.
  • 오버 엔지니어링으로 이어질 수 있다.
  • 협력 객체의 public api가 자연스럽게 도출된다.
  • 객체들 간의 구현보다는 행위에 집중할 수 있기 때문에 객체지향적으로 바라보기 쉽다.

 

그래서?


둘 중의 한 가지 방식을 선택하는 개념의 문제가 아니다. 켄트백의 TDD에서는 방향성을 가질 필요가 있다면 아는 것에서 모르는 것으로(known-to-unknown)의 방향이 유용할 수 있다고 말한다.

 

개인적으로는 보통 요구사항을 단위로 전달받아 주로 개발이 진행되므로 인수테스트를 작성하여 전반적인 요구사항의 전반적인 이해를 먼저 진행한 뒤에 도메인부터 구현하는 방법도 유용할 수 있을 것이다.

 

참고

728x90

'test' 카테고리의 다른 글

[Cucumber] Scenario Outline vs Data Table  (1) 2024.11.27
728x90

서론


통합테스트나 인수테스트를 위해 @SpringBootTest를 이용하는 테스트 환경에서 테스트들을 격리하는 방법들 중 주로 널리 알려진 것들에 대해 정리하고 TestExecutionListener 사용하여 격리했던 방법을 공유해보고자 한다.

 

테스트 격리란


테스트 격리란 테스트 순서에 상관없이 독립적으로 실행되며, 결정적으로 동작되는 것을 의미한다.

비결정적인 테스트는 쉽게 말해서 테스트 실행 시 같은 입력값에 대해 항상 같은 출력하지 않는 테스트를 의미한다.

 

따라서 "결정적으로 동작"의 의미란 멱등성과 비슷하다. 같은 입력 값이면 항상 같은 결과를 반환하는 것을 의미한다.

 

@SpringBootTest 테스트 격리 방법


@Transactional

첫 번째로 트랜잭션 어노테이션이다. 많이 알려진 내용으로 해당 어노테이션을 테스트 코드에 사용하면 테스트가 종료되고 자동으로 롤백하는 기능을 이용하는 것이다.

test transactions will be automatically rolled back after completion of the test.

 

하지만 해당 방법은 API 접점에서 검증하는 E2E 테스트를 인수테스트의 의도로 인수테스트 환경을 구축했다면 @Transactional 어노테이션으로는 테스트 격리를 하기 힘들 수도 있다.

 

인수테스트는 보통 블랙박스 성격의 테스트이므로 WebTestClient 혹은 RestAssured을 이용하는 경우가 많은데 그러려면 @SpringBootTest 어노테이션을 RANDOM_PORT나 DEFINED_PORT를 이용하여 실제 웹 한경을 구성하기 때문이다

따라서 포트 번호를 따로 주어 WebEnvironment를 구성하는 경우 Http client와 테스트 서버는 별도의 스레드에서 수행되기 때문에 자동 롤백이 이루어지지 않는다.

 

@DirtiesContext

효과적인 테스트 수행을 위해 스프링에서는 context caching 기능을 지원한다. 해당 어노테이션은 테스트와 관련된 ApplicationContext가 Dirty하기 때문에 Context Cache에서 종료되고 삭제되어야 함을 나타내는 Test Annotation이다.

 

@DirtiesContext 어노테이션을 통해 테스트를 수행하기 전, 수행한 이후, 그리고 테스트의 각 테스트 케이스마다 수행하기 전, 수행한 이후에 context를 다시 생성하도록 지시하여 테스트를 격리할 수 있다.

 

하지만 매 테스트마다 Application Context를 매번 생성해야 하기 때문에 테스트 속도가 현저히 느려진다.

 

테스트 코드상으로 매번 삭제

테스트에 필요한 데이터를 JUnit 생명주기인 @BeforeEach, @AfterEach를 활용하여 테스트가 시작되기 전이나 후에 데이터들을 삭제하여 테스트 격리를 하는 방식이다. application context를 매번 띄우는 것보다 낮은 비용이므로 속도 측면에서 효율적이라고 볼 수 있다.

 

하지만 이 방식의 단점은 생성해야 할 데이터가 많거나, 연관관계를 모를 경우 제대로 데이터를 삭제하지 못하는 문제가 발생할 수 있다.

또한, 테스트 클래스도 길어지기에 가독성도 안 좋을 수 있다.

 

TRUNCATE를 통한 테이블 초기화


TRUNCATE (DDL)는 DELETE (DML)와 다르게 행마다 락을 걸지 않고, 트랜잭션 로그 공간을 적게 사용하므로 초기화하는 속도가 더 빠르다.

테스트 격리를 위해 TRUNCATE 하는 방법은 보통 크게 2가지이다.

  1. @Sql 사용
  2. 코드에서 truncate 쿼리 사용

 

@Sql

@Sql은 Spring Boot에서 제공하는 애노테이션이며, 클래스 테스트가 실행되기 전 @Sql이 가리키는 경로에 있는 SQL문이 먼저 실행되게 된다. 이 SQL 파일 안에 TRUNCATE관련 내용을 넣어두는 방식을 통해 DB 테스트 격리를 할 수 있다.

 

하지만 테이블이 추가/변경/삭제될 때마다 수정이  필요하므로 관리가 필요하다.

 

코드에서 테이블 TRUNCATE 쿼리 사용

JPA 사용 시 EntityManager를 이용하여 혹은 jpa를 사용하지 않는 경우 DataSource를 이용하여 테이블 이름을 조회하여 각 테이블들을 truncate 시켜주는 쿼리를 수행하는 것이다. 해당 방법을 사용하면 테이블 상태에 의존하지 않는 초기화 환경 구축 가능하다.

해당 방법을 사용해서 테스트 격리를 적용해 보자.

 

 

코드에서 테이블 TRUNCATE 쿼리 사용 - TestExecutionListener 이용하기


TestExecutionListener

TestExecutionListener는 스프링에서 제공하는 테스트 실행 주기에서 콜백 메서드를 통해 사용자가 추가 작업을 수행하도록 지원하는 인터페이스이다.

 

사실 위에서 살펴본 transactional test의 기본 롤백, @DirtiesContext, @Sql의 기능들 모두 해당 TestExecutionListener 인터페이스를 상속받은 기본 리스너를 통해 스프링에서 제공하고 있는 것이다.

public interface TestExecutionListener {
	default void beforeTestClass(TestContext testContext) throws Exception {};
	default void prepareTestInstance(TestContext testContext) throws Exception {};
	default void beforeTestMethod(TestContext testContext) throws Exception {};
	default void afterTestMethod(TestContext testContext) throws Exception {};
	default void afterTestClass(TestContext testContext) throws Exception {};
}
  • beforeTestClass()
    • junit의 @BeforeAll와 같다.
    • 모든 테스트를 실행하기 전 단 한 번만 실행/호출
  • prepareTestInstance()
    • junit에서 X
    • 테스트 인스턴스가 준비되었을 때 호출
  • beforeTestMethod()
    • junit의 @BeforeEach
    • 각 테스트 메서드 실행 전에 실행/호출
  • afterTestExecution()
    • junit에서 X
    • 각 테스트 메서드 실행 직후 호출
  • afterTestMethod()
    • junit에서 @AfterEach
    • 각 테스트 메서드 실행 후에 실행/호출
  • afterTestClass()
    • junit의 @AfterAll
    • 모든 테스트를 실행한 후 단 한 번만 실행/호출

 

Custom TestExecutionListener를 구현


sql 파일이 아닌 코드에서 테이블 TRUNCATE를 위해 AbstractTestExecutionListener 이용해 보자.

afterTestMethod() 메서드 오버라이딩을 통해 테스트 메서드가 종료된 후 모든 테이블 truncate 쿼리를 실행한다.

 

 

@TestExecutionListeners 통해 리스너 등록

 

해당 커스텀 리스너를 등록하기 위해서는 @TestExecutionListeners 어노테이션을 통해 등록하면 되는데 기본 제공되는 테스트 리스너들이 필요하다면 MERGE_WITH_DEFAULTS 모드를 사용하도록 하자.

 

그리고 주의할 점은 TestExecutionListeners 들은 AnnotationAwareOrderComparator를 통해 정렬되어 순서대로 핸들링되기 때문에 순서에 주의하자.

 

따라서 AcceptanceTestExecutionListener 클래스에 마지막 순서로 동작하기 위해 Ordered 인터페이스를 구현하도록 추가하고 어노테이션을 붙여 테스트 격리를 진행하였다.

Ordered 구현하여 순서값 지정

 

사용 및 확인


 

이렇게 테스트 메서드 종료시마다 truncate 쿼리가 나가는 것을 확인할 수 있다. 물론 해당 방법이 가장 좋은 방법인 것은 아닐 것이다.

따라서 각각 자신의 상황에서 추구하는 테스트 격리의 정의와 격리를 위한 장치가 필요한지 살펴보며 적용하는 것이 가장 좋은 방법일 것이라고 생각한다.

 

참고


728x90

'spring' 카테고리의 다른 글

[spring data mongo] Query를 Type-safe 하게 작성하기 (작성 중)  (0) 2024.11.19
WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02
728x90

Spring Data JPA 사용하는 미션에서 멘토님께 리뷰를 받으며 다음과 같은 피드백을 받으며 슬라이스 테스트 존재에 대해 알게 되었다.

  • Repository Test시 @SpringBootTest를 @DataJpaTest로 변경해서 테스트 작성하기

슬라이스 테스트란 무엇이고 왜 사용하는 것일까??


Spring Boot 슬라이스 테스트


슬라이스 테스트란?

  • 즉 스프링은 레이어 별로 잘라서 특정 레이어에 대해서 Bean을 최소한으로 등록시켜 테스트 하고자 하는 부분에 최대한 단위 테스트를 지원해주고 있다.
  • 그렇다면 @SpringBootTest 대신 슬라이스 테스트를 하는 이유는 무엇일까??

F.I.R.S.T 테스트 원칙

단위 테스트는 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이며 로버트 마틴의 클린코드에서 깨끗한 테스트를 위한 다섯 가지 F.I.R.S.T 규칙을 말한다.

  • Fast — 테스트는 빨라야 한다.
  • Isolated — 각 테스트는 서로 의존하면 안된다.
  • Repeatable — 테스트는 어떤 환경에서도 반복 가능해야 한다.
  • Self-validating — 테스트는 bool 값으로 결과를 내야 한다.
  • Timely — 테스트는 적시에 작성해야 한다.

@SpringBootTest 어노테이션을 사용하는 경우 단점은 아래와 같다.

  • 실제 구동되는 애플리케이션의 설정, 모든 Bean을 로드하기 때문에 시간이 오래걸리고 무겁다.
  • 테스트 단위가 크기 때문에 디버깅이 어려운 편이다.
  • 외부 API 콜같은 Rollback 처리가 안되는 테스트 진행을 하기 어려움

따라서 repository 레이어의 단위테스트의 경우 @SpringBootTest 대신 @DataJpaTest 사용하여 테스트를 작성하는 경우 통해 속도적인 측면과 의존성 측면에서 이점을 가질 수 있다.


슬라이스 테스트 어노테이션 종류

아래는 대표적인 슬라이스 테스트 어노테이션이 존재하는데 해당 글에서는 중 @WebMvcTest, @DataJpaTest 살펴보도록 할 것이다.

  • @WebMvcTest
  • @WebFluxTest
  • @DataJpaTest
  • @JsonTest
  • @RestClientTest

@WebMvcTest

  • MVC를 위한 테스트.
  • 웹에서 테스트하기 힘든 컨트롤러를 테스트하는 데 적합.
  • 웹상에서 요청과 응답에 대해 테스트할 수 있음.
  • 시큐리티, 필터까지 자동으로 테스트하며, 수동으로 추가/삭제 가능.
  • @SpringBootTest 어노테이션보다 가볍게 테스트할 수 있음.
  • 다음과 같은 내용만 스캔하도록 제한함.@Controller, @ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor,
    • 따라서 의존성이 끊기기 때문에, 예를 들면 서비스와 같은 객체들은 @MockBean을 사용해서 만들어 사용해야 한다.
@WebMvcTest(ShelterPostController.class)
public class ShelterPostControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected ShelterPostService shelterPostService;

    @Test
    @DisplayName("게시글 리스트 조회 테스트")
    void getShelterPostsTest() throws Exception {
        // given, when, then
                ...
    }
}

@MockBean

spring-boot-test 패키지는 Mockito를 포함하고 있기 때문에 기존에 사용하던 방식대로 Mock 객체를 생성해서 테스트하는 방법도 있지만, spring-boot-test에서는 새로운 방법도 제공한다.

  • 바로 @MockBean 어노테이션을 사용해서 이름 그대로 Mock 객체를 빈으로써 등록할 수 있다.
  • 기존에 사용되던 스프링 Bean이 아닌 Mock Bean을 주입한다.
  • 그렇기 때문에 만일 @MockBean으로 선언된 빈을 주입받는다면 Spring의 ApplicationContext는 Mock 객체를 주입한다.
  • 새롭게 @MockBean을 선언하면 Mock 객체를 빈으로써 등록하지만, 만일 @MockBean으로 선언한 객체와 같은 이름과 타입으로 이미 빈으로 등록되어있다면 해당 빈은 선언한 Mock 빈으로 대체된다.

해당 어노테이션은 테스트 내용 중 외부 서비스를 호출하는 부분을 Mock해서 쉽게 처리할 수 있다.

@SpringBootTest
public class XXXControllerTest {

    @MockBean  // 외부 서비스 호출에 사용되는 RestTemplate Bean을 Mock
    private RestTemplate mockRT;

    @MockBean  // 외부 서비스 호출에 사용되는 Service Bean을 Mock
    private XXXService xXXService;

}

@DataJpaTest

Spring Data JPA를 테스트하고자 한다면 @DataJpaTest 기능을 사용해볼 수 있다.

  • 해당 테스트는 기본적으로 in-memory embedded database를 생성하고 @Entity 클래스를 스캔한다.
  • 일반적인 다른 컴포넌트들은 스캔하지 않는다. 따라서 특정 bean의 의존성이 필요한 경우 아래의 방법 사용
    • @import
    • @DataJpaTest(includeFilters = @Filter(..))

@DataJpaTest@Transactional 어노테이션을 포함하고 있다.

  • 따라서 테스트가 완료되면 자동으로 롤백된다.

만약 @Transactional 기능이 필요하지 않다면 아래와 같이 줄 수 있다.

@DataJpaTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SomejpaTest {
    ...
}

@DataJpaTest 기능을 사용하면 @Entity를 스캔하고 repository를 설정하는 것 이외에도 테스트를 위한 TestEntityManager라는 빈이 생성된다.

  • 이 빈을 사용해서 테스트에 이용한 데이터를 정의할 수 있다.
@DataJpaTest
class SomejpaTest {

    @Autowired
    private TestEntityManager entityManager;

    @Test
    @DisplayName("게시글 아이디로 댓글 목록 삭제 테스트")
    void deleteAllByMissingPostIdTest() {
        // given
        LongStream.rangeClosed(1, 3).forEach(idx ->
            entityManager.persist(Comment.builder()
                .missingPost(missingPost)
                .content("내용")
                .account(account)
                .build()
            )
        );

        // when
        commentRepository.deleteAllByMissingPostId(missingPost.getId());
        List<Comment> comments = commentRepository.findAll();

        // then
        SoftAssertions.assertSoftly(softAssertions -> {
                softAssertions.assertThat(comments).hasSize(3);
                comments.forEach(foundComment -> softAssertions.assertThat(foundComment.isDeleted()).isTrue());
            }
        );
    }

}

만약 테스트에 내장된 임베디드 데이터베이스를 사용하지 않고 real database를 사용하고자 하는 경우, @AutoConfigureTestDatabase 어노테이션을 사용하면 손쉽게 설정할 수 있다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class SomejpaTest {
    ...
}

사용시 주의할 점

슬라이스 테스트 시, 하위 레이어는 Mock 기반으로 만들기 때문에 주의할 점들이 있다.

  • 의존성 객체를 Mocking하기 때문에 완벽한 테스트는 아님
  • Mocking 처리하기 위한 시간이 소요
  • Mocking 라이브러리에 대한 학습 비용 발생
  • Mock 기반 으로 테스트하기 때문에 실제 환경에서는 결과가 다를 수 있음

참고 출처

728x90

'spring' 카테고리의 다른 글

WebClient의 DataBufferLimitException 해결방법  (0) 2024.11.15
TestExecutionListener를 이용한 테스트 격리 방법  (1) 2024.11.05
Spring Async  (0) 2024.08.02
Spring Webclient  (0) 2024.08.02
Spring AOP  (0) 2024.08.02

+ Recent posts