Spring 입문 5-2 (TestCode)

SJ.CHO·2024년 10월 29일

테스트코트

  • 버그발견시기가 늦어질수록 비용이 기하급수적으로 증가.
  • 테스트코드를 작성하는 비용과 작성하지 않았을때의 비용차이를 알아야함.
  • 일반적으로 JUnit5 사용

단위테스트

  • 문제를 최대한 잘라내어 작은 단위의 동작들이 제대로 수행되어지는지 검사하는 테스트 기법
  • 작성시간이 빠르고 문제발생시 발생지점을 빠르고 정확하게 확인이 가능하다.

테스트 환경 제작

테스트 수행 전 후 설정.

  • @BeforeEach
@BeforeEach
void setUp() {
    System.out.println("각각의 테스트 코드가 실행되기 전에 수행");
}
  • @AfterEach
@AfterEach
void tearDown() {
    System.out.println("각각의 테스트 코드가 실행된 후에 수행\n");
}
  • @BeforeAll
@BeforeAll
static void beforeAll() {
    System.out.println("모든 테스트 코드가 실행되기 전에 초초로 수행\n");
}
  • @AfterAll
@AfterAll
static void afterAll() {
    System.out.println("모든 테스트 코드가 수행된 후 마지막으로 수행");
}

  • All 어노테이션들은 항상 static 상태여야함.

테스트 설명, 그룹화 및 순서설정.

  • @DisplayName
@Test
@DisplayName("테스트의 내용을 한눈에 알아볼 수 있게 네이밍 해줄 수 있습니다.")
void test1() {
    System.out.println("테스트의 수행 내용들을 빠르게 파악할 수 있습니다.");
}
  • 테스트 내용을 기존의 코드작성처럼 작성할 필요없이 주석처럼 설명이 가능함.

  • @Nested

@Nested
@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
class Test1 {
    @Test
    @DisplayName("Test1 - test1()")
    void test1() {
        System.out.println("Test1.test1");
    }

    @Test
    @DisplayName("Test1 - test2()")
    void test2() {
        System.out.println("Test1.test2");
    }
}

@Nested
@DisplayName("Test2 다른 주제")
class Test2 {
    @Test
    @DisplayName("Test2 - test1()")
    void test1() {
        System.out.println("Test2.test1");
    }

    @Test
    @DisplayName("Test2 - test2()")
    void test2() {
        System.out.println("Test2.test2");
    }
}

  • 각각의 주제별로 테스트를 그룹지어 파악하기 좋음. 클래스 단위로 그룹화 가능.

  • Inner Class에만 @Nested 를 사용할 수 있다. 계층구조의 테스트코드 작성이 쉬워짐.

  • @Order

@Nested
@DisplayName("주제 별로 테스트를 그룹지어서 파악하기 좋습니다.")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class Test1 {

    @Order(1)
    @Test
    @DisplayName("Test1 클래스")
    void test() {
        System.out.println("\nTest1 클래스");
    }

    @Order(3)
    @Test
    @DisplayName("Test1 - test1()")
    void test1() {
        System.out.println("Test1.test1");
    }

    @Order(2)
    @Test
    @DisplayName("Test1 - test2()")
    void test2() {
        System.out.println("Test1.test2");
    }
}

  • Filter 에서 많이 사용하는 기능으로 메소드의 순서 지정이 가능해짐.
  • @TestMethodOrder(MethodOrderer.OrderAnnotation.class) 어노테이션 지정 필수

테스트 반복설정.

  • @RepeatedTest
@RepeatedTest(value = 5, name = "반복 테스트 {currentRepetition} / {totalRepetitions}")
void repeatTest(RepetitionInfo info) {
    System.out.println("테스트 반복 : " + info.getCurrentRepetition() + " / " + info.getTotalRepetitions());
}

  • 기존의 반복문인 for(){} 문 처럼 value 값을 지정하여 횟수만큼 반복설정 가능.

  • RepetitionInfo 값을 파라미터로 받아서 현재횟수와 총 횟수값 확인이 가능

  • @ParameterizedTest

@DisplayName("파라미터 값 활용하여 테스트 하기")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
void parameterTest(int num) {
    System.out.println("5 * num = " + 5 * num);
}

  • 테스트의 값을 파라미터로 받아 테스트가 가능.
  • 전달되는 파라미터 수 만큼 테스트 메서드가 수행

테스트 결과값 비교

  • assertEquals
@Test
@DisplayName("assertEquals")
void test1() {
    Double result = calculator.operate(5, "/", 2);
    assertEquals(2.5, result);
}

@Test
@DisplayName("assertEquals - Supplier")
void test1_1() {
    Double result = calculator.operate(5, "/", 0);
    // 테스트 실패 시 메시지 출력 (new Supplier<String>())
    assertEquals(2.5, result, () -> "연산자 혹은 분모가 0이 아닌지 확인해보세요!");
}

@Test
@DisplayName("assertNotEquals")
void test1_2() {
    Double result = calculator.operate(5, "/", 0);
    assertNotEquals(2.5, result);
}

  • assertEquals() : 첫 파라미터에 예상 결과값을 넣고 두 번째 파라미터에 실제 동작값을 넣어줌. 값이 다르다면 테스트 실패.

  • assertNotEquals() : 값이 다르다면 테스트성공 같다면 실패.

  • assertTrue

@Test
@DisplayName("assertTrue 와 assertFalse")
void test2() {
    assertTrue(calculator.validateNum(9));
    assertFalse(calculator.validateNum(0));
}
  • assertTrue() : 메서드의 파라미터값이 true/false 인지 확인.

  • assertNotNull

@Test
@DisplayName("assertNotNull 과 assertNull")
void test3() {
    Double result1 = calculator.operate(5, "/", 2);
    assertNotNull(result1);
    Double result2 = calculator.operate(5, "/", 0);
    assertNull(result2);
}
  • assertNotNull() : 해당 파라미터 값이 Null 인지 아닌지 확인.

  • assertThrows

@Test
@DisplayName("assertThrows")
void test4() {
    IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> calculator.operate(5, "?", 2));
    assertEquals("잘못된 연산자입니다.", exception.getMessage());
}
  • assertThrows() : 첫 파라미터에 예상하는 Exception 클래스 타입, 두 번째에 실행코드를 넣음.
  • 실행 코드의 예외가 예상한 클래스 타입이라면 성공.
  • assertEquals 을 활용해 예외메세지까지 제대로 출력이 되는지 확인가능.

테스트코드 패턴

  • Given - When - Then
    • Given
      • 테스트 하고자 하는 대상을 실제로 실행 전 테스트에 필요한 값을 미리 선언
    • When
      • 테스트 하고자 하는 대상을 실제로 실행
    • Then
      • 어떤 특정한 행동에 의해 발생할 것이라 예상하는 결과가 맞는지 확인.
    @Test
    @DisplayName("계산기 연산 성공 테스트")
    void test1() {
        // given
        int num1 = 5;
        String op = "/";
        int num2 = 2;

        // when
        Double result = calculator.operate(num1, op, num2);

        // then
        assertNotNull(result);
        assertEquals(2.5, result);
    }

Mockito

  • 현재 Service단을 테스트하려고 하지만 Service단을 실행시키기 위해선 다양한 Repository 들을 주입받고있음.
  • 하지만 단위테스트내에서 다른객체의 의존과 실제 DB상으로 테스트를하는것은 큰 위험이 따른다.
  • 이를 위해 가짜객체인 Mock object 를 사용한다.


  • 해당 그림처럼 서로 의존하고있는 관계를 끊어 내어 각 단위별 테스트의 신뢰성이 확실해짐. Ex) Service가 값을 변결하는 로직을 테스트한다면 굳이 DB 까지 연결됄 필요가없음.

실제 사용


@Service
@RequiredArgsConstructor
public class ProductService {

  private final ProductRepository productRepository;
  private final FolderRepository folderRepository;
  private final ProductFolderRepository productFolderRepository;
  
  ...
  
  @Transactional
  public ProductResponseDto updateProduct(Long id, ProductMypriceRequestDto requestDto) {
    int myprice = requestDto.getMyprice();
    if (myprice < MIN_MY_PRICE) {
      throw new IllegalArgumentException(
          "유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
    }

    Product product =
        productRepository
            .findById(id)
            .orElseThrow(() -> new NullPointerException("해당 상품을 찾을 수 없습니다."));

    product.update(requestDto);

    return new ProductResponseDto(product);
  }
  • 해당 서비스 코드를 테스트코드작성을 해보자. 해당서비스는 현재 3개의 레파지토리를 주입받아서 사용중이기 때문에 레파지토리들의 대한 Mock 객체 주입이 필요하다
@ExtendWith(MockitoExtension.class) // @Mock 사용을 위해 설정합니다.
class ProductServiceTest {

    @Mock
    ProductRepository productRepository;

    @Mock
    FolderRepository folderRepository;

    @Mock
    ProductFolderRepository productFolderRepository;

    @Test
    @DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
    void test1() {
        // given
        Long productId = 100L;
        int myprice = ProductService.MIN_MY_PRICE + 3_000_000;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
        requestMyPriceDto.setMyprice(myprice);

        ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);

        // when
        ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);

        // then
        assertEquals(myprice, result.getMyprice());
    }

    @Test
    @DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
    void test2() {
        // given
        Long productId = 200L;
        int myprice = ProductService.MIN_MY_PRICE - 50;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
        requestMyPriceDto.setMyprice(myprice);

        ProductService productService = new ProductService(productRepository, productFolderRepository, folderRepository);

        // when
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            productService.updateProduct(productId, requestMyPriceDto);
        });

        // then
        assertEquals(
                "유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
                exception.getMessage()
        );
    }
}
  • @Mock 기능을 활용하여 해당 레파지토리들을 가짜객체로 채워놓아서 서비스에게 의존성을 주입한다. 하지만 해당코드를 실행을 해본다면 다음과 같은 오류가 발생한다.

  • 오류 발생이유는 Mock을 적용 했지만 결국엔 해당객체가 무슨일을 하는지 모른다.
Product product =
        productRepository
            .findById(id)
            .orElseThrow(() -> new NullPointerException("해당 상품을 찾을 수 없습니다."));
  • 해당 상품이 존재하는지를 찾는 DB역할을 수행을하지 못하기 때문이다. 이를 해결해주기위해 우리는 테스트 코드 내부에 해당역할까지수행을 하게끔 코드의 변경이 필요하다.
@ExtendWith(MockitoExtension.class) // @Mock 사용을 위해 설정합니다.
class ProductServiceTest {

    @Mock
    ProductRepository productRepository;

    @Mock
    FolderRepository folderRepository;

    @Mock
    ProductFolderRepository productFolderRepository;

    @Test
    @DisplayName("관심 상품 희망가 - 최저가 이상으로 변경")
    void test1() {
        // given
        Long productId = 100L;
        int myprice = ProductService.MIN_MY_PRICE + 3_000_000;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
        requestMyPriceDto.setMyprice(myprice);

        User user = new User();
        ProductRequestDto requestProductDto = new ProductRequestDto(
                "Apple <b>맥북</b> <b>프로</b> 16형 2021년 <b>M1</b> Max 10코어 실버 (MK1H3KH/A) ",
                "https://shopping-phinf.pstatic.net/main_2941337/29413376619.20220705152340.jpg",
                "https://search.shopping.naver.com/gate.nhn?id=29413376619",
                3515000
        );

        Product product = new Product(requestProductDto, user);

        ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);

        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        // when
        ProductResponseDto result = productService.updateProduct(productId, requestMyPriceDto);

        // then
        assertEquals(myprice, result.getMyprice());
    }

    @Test
    @DisplayName("관심 상품 희망가 - 최저가 미만으로 변경")
    void test2() {
        // given
        Long productId = 200L;
        int myprice = ProductService.MIN_MY_PRICE - 50;

        ProductMypriceRequestDto requestMyPriceDto = new ProductMypriceRequestDto();
        requestMyPriceDto.setMyprice(myprice);

        ProductService productService = new ProductService(productRepository, folderRepository, productFolderRepository);

        // when
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            productService.updateProduct(productId, requestMyPriceDto);
        });

        // then
        assertEquals(
                "유효하지 않은 관심 가격입니다. 최소 " +ProductService.MIN_MY_PRICE + " 원 이상으로 설정해 주세요.",
                exception.getMessage()
        );
    }
}
  • 직접 Product 객체를 만들어주고 가짜 레파지토리에게 요청이 온다면 해당 답안을 반환하라고 given 을 지정해주어서 동작까지 지정해준다면 수행이 가능하다(stubbing) 이라고 함.

  • 이렇게 동작을 지정해주는 이유는 우리는 DB에 값이 잘 들어가는것이 중요한게아니다. 업데이트가 제대로 수행되어서 myprice 의 값이 변경되는것을 확인하는게 주 목적이다. 레파지토리의 동작보장은 해당 단계전에서 보장되어야한다.

  • 예외체크의 경우 시나리오에 따라서 굳이 객체가 필요하지않을수 있다 이럴경우 굳이 객체를 만들어주지않고 해당예외가 제대로 동작하는지만 판단해도 충분하다.

profile
70살까지 개발하고싶은 개발자

0개의 댓글