WY J
학습 공간
WY J
  • 분류 전체보기 (95)
    • Java (38)
      • 알고리즘 (5)
      • 자료구조 (4)
      • 기초 (9)
      • OOP (10)
      • Collection (3)
      • Effective (5)
      • reator (2)
    • HTML&CSS (5)
    • macOS (3)
    • Git (5)
    • Network (5)
    • MySQL (2)
    • Spring Boot (31)
      • Core (5)
      • MVC (15)
      • Security (10)
    • 알고리즘 (1)
    • Cloud (3)
      • AWS (3)
    • Docker (1)
    • Project (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

최근 글

hELLO · Designed By 정상우.
WY J

학습 공간

[Spring MVC] 테스팅(Testing)
Spring Boot/MVC

[Spring MVC] 테스팅(Testing)

2022. 9. 8. 20:04

작은 단위의 테스트로 미리미리 버그를 찾을 수 있기 때문에 애플리케이션의 덩치가 커진 상태에서 문제의 원인을 찾아내는 것보다 상대적으로 더 적은 시간 안에 문제를 찾아낼 가능성이 높다.

Spring에서는 계층별로 테스트 할 수 있는 테스트 기법을 지원 해주고 있다.

 

 

단위 테스트(Unit Test)란?

  • 기능 테스트는 주로 클라이언트 입장에서 애플리케이션이 제공하는 기능이 올바르게 동작하는지를 테스트하는 것이다. 
  • 통합 테스트는 개발자가 테스트의 주체가 되는 것이 일반적이다. 클라이언트 툴 없이 테스트 코드를 실행시켜 이루어지는 경우가 많다.
  • 슬라이스 테스트는 애플리케이션을 특정 계층으로 쪼개어 테스트하는 것을 의미한다.
  • 단위 테스트는 비즈니스 로직에서 사용하는 메서드 단위로 테스트하는 것을 의미한다.

단위 테스트를 위한 FIRST 원칙

FIRST 원칙은 테스트 케이스를 작성하기 위해 참고할 수 있는 가이드 원칙이다.

 

테스트 케이스란 테스트를 위한 입력 데이터, 실행 조건, 기대 결과를 표현하기 위한 명세를 의미하는데, 한마디로 메서드 등 하나의 단위를 테스트하기 위해 작성하는 테스트 코드이다.

 

  1. Fast : 작성한 테스트 케이스는 빨라야 한다.
  2. Independent : 각각의 테스트 케이스는 독립적이어야 한다. 실행 순서와 상관없이 정상적인 실행이 보장 되어야 한다.
  3. Repeatable : 테스트 케이스는 어떤 환경에서도 반복해서 실행이 가능해야 한다.
  4. Self-validation : 단위 테스트는 자체 검증 결과를 보여주어야 한다.
  5. Timely : 단위 테스트는 테스트 하려는 기능을 구현하기 직전에 작성해야 한다.

 

Given-When-Then 표현 스타일


Given

  • 테스트를 위한 준비 과정을 명시한다.

When

  • 테스트 할 동작(대상)을 지정한다.

Then

  • 테스트의 결과를 검증(Assertion)한다.

 

Assertion이란?

‘단언’, ‘단정’이라고 해석하며, ‘예상하는 결과 값이 참(true)이길 바라는 것’을 의미한다.


JUnit 없이 비즈니스 로직에 단위 테스트 적용해보기

JUnit 없이 기본적으로 테스트 케이스를 작성하는 흐름을 보면서 테스트 케이스 작성에 감을 잡아보자.

 

회원이 현재 보유한 스탬프 수와 회원이 주문한 커피 수량 만큼 획득한 스탬프 수를 더한 누적 스탬프 수를 계산해주는 헬퍼 클래스

public class StampCalculator {
    public static int calculateStampCount(int nowCount, int earned) {
        return nowCount + earned;
    }
}

 

StampCalculator 클래스를 테스트하는 테스트 케이스

public class StampCalculatorTestWithoutJUnit {
    public static void main(String[] args) {
        calculateStampCountTest();
    }

    private static void calculateStampCountTest() {
        int nowCount = 5;
        int earned = 3;

        int actual = StampCalculator.calculateStampCount(nowCount, earned);

        int expected = 7;

        System.out.println(expected == actual);
    }
}

/** 단위 테스트 결과 */
> Task :StampCalculatorTestWithoutJUnit.main()
false

 

다른 클래스에서 값을 가져와 테스트 하는 예제

테스트 대상

public class StampCalculator {
    public static int calculateEarnedStampCount(Order order) {
        return order.getOrderCoffees().stream()
                .map(orderCoffee -> orderCoffee.getQuantity())
                .mapToInt(quantity -> quantity)
                .sum();
    }
}

 

테스트 케이스

public class StampCalculatorTestWithoutJUnit {
    public static void main(String[] args) {
        calculateEarnedStampCountTest();
    }

    private static void calculateEarnedStampCountTest() {
        /** given. 객체를 생성하여 테스트에 필요한 데이터 생성 */
        Order order = new Order();
        OrderCoffee orderCoffee1 = new OrderCoffee();
        orderCoffee1.setQuantity(3);

        OrderCoffee orderCoffee2 = new OrderCoffee();
        orderCoffee2.setQuantity(5);

        order.setOrderCoffees(List.of(orderCoffee1, orderCoffee2));

        int expected = orderCoffee1.getQuantity() + orderCoffee2.getQuantity();

        /** when. 테스트 대상의 메서드에 위에서 생성한 데이터를 입력값으로 전달 */
        int actual = StampCalculator.calculateEarnedStampCount(order);

        /** then. Assertion 한다. */
        System.out.println(expected == actual);
    }
}

JUit으로 비즈니스 로직에 단위 테스트 적용하기

 

assertEquals(값1, 값2) : 같은 값인지 검증

public class HelloJUitTest {
    @DisplayName("Hello JUnit Test") // 실행 결과 창에 표시되는 이름을 지정하는 부분
    @Test
    public void assertionTest() {
        String expected = "Hello, JUnit";
        String actual = "Hello, JUnit";

        /** assertEquals() 메서드는 값이 같은지 검증할 수 있다. */
        assertEquals(expected, actual);
    }
}

 

assertNotNull(테스트대상객체, 테스트실패메세지) : Null 여부 테스트

public class AssertionNotNullTest {

    @DisplayName("AssertionNull() Test")
    @Test
    public void assertNotNullTest() {
        String currencyName = getCryptoCurrency("ETH");

        /** asserNotNull() 메서드는 테스트 대상 객체가 null인지 테스트할 수 있다. */
        assertNotNull(currencyName, "should be not null");
    }

    private String getCryptoCurrency(String unit) {
        return CryptoCurrency.map.get(unit);
    }
}
public class CryptoCurrency {
    public static Map<String, String> map = new HashMap<>();

    static {
        map.put("BTC", "Bitcoin");
        map.put("ETH", "Ethereum");
        map.put("ADA", "ADA");
        map.put("POT", "Polkadot");
    }
}

 

assertThrows(예외클래스, 대상 메서드 호출) : 예외(Exception) 테스트

public class AssertionExceptionTest {

    @DisplayName("throws NullPointerException when map.get()")
    @Test
    public void assertionThrowExceptionTest() {
        /** assertThrows(예외클래스, 대상 메서드 호출) */
        assertThrows(NullPointerException.class, () -> getCryptoCurrency("XRP"));
    }

    private String getCryptoCurrency(String unit) {
        /** XRP 라는 값이 없으므로 Null 이 반환되고, 
         * 이 상태에서 대문자로 변환하려고 했기 때문에 NullPointerException 이 발생한다. */
        return CryptoCurrency.map.get(unit).toUpperCase();
    }
}

// 테스트 결과
passed

 

 

테스트 케이스 실행 전, 전처리

 

@BeforeEach 애너테이션을 추가한 메서드는 테스트 케이스가 각각 실행될 때 마다 테스트 케이스 실행 직전에 먼저 실행되어 초기화 작업 등을 진행할 수 있다.

public class BeforeEachTest {
    private Map<String, String> map;

    @BeforeEach
    public void init() {
        map = new HashMap<>();
        map.put("BTC", "Bitcoin");
        map.put("ETH", "Ethereum");
        map.put("ADA", "ADA");
        map.put("POT", "Polkadot");
    }

    @DisplayName("Test case 1")
    @Test
    public void beforeEachTest() {
        map.put("XRP", "Ripple");
        System.out.println(map); // {BTC=Bitcoin, POT=Polkadot, XRP=Ripple, ETH=Ethereum, ADA=ADA}
        assertDoesNotThrow(() -> getCryptoCurrency("XRP")); // passed
    }

    @DisplayName("Test case 2")
    @Test
    public void beforeEachTest2() {
        System.out.println(map); // {BTC=Bitcoin, POT=Polkadot, ETH=Ethereum, ADA=ADA}
        assertDoesNotThrow(() -> getCryptoCurrency("XRP")); // failed
    }

    private String getCryptoCurrency(String unit) {
        return map.get(unit).toUpperCase();
    }
}

Test case 2 실행 전에 init() 메서드가 다시 호출되면서 map이 초기화 되었기 때문에 Test case 2는 fail 발생

 

@BeforeAll()은 클래스 레벨에서 테스트 케이스를 한꺼번에 실행 시키면 테스트 케이스가 실행되기 전에 딱 한번만 초기화 작업을 할 수 있도록 해준다.

public class BeforeAllTest {
    private static Map<String, String> map;

    @BeforeAll
    public static void initAll() {
        map = new HashMap<>();
        map.put("BTC", "Bitcoin");
        map.put("ETH", "Ethereum");
        map.put("ADA", "ADA");
        map.put("POT", "Polkadot");
        map.put("XRP", "Ripple");

        System.out.println("initialize Crypto Currency map");
    }

    @DisplayName("Test case 1")
    @Test
    public void beforeEachTest() {
        assertDoesNotThrow(() -> getCryptoCurrency("XRP")); // Passed
    }

    @DisplayName("Test case 2")
    @Test
    public void beforeEachTest2() {
        assertDoesNotThrow(() -> getCryptoCurrency("ADA")); // Passed
    }

    private String getCryptoCurrency(String unit) {
        return map.get(unit).toUpperCase();
    }
}

 

테스트 케이스 실행 후, 후처리

JUnit에서는 테스트 케이스 실행이 끝난 시점에 후처리 작업을 할 수 있는 @AfterEach, @AfterAll 같은 애너테이션도 지원한다.

이 애너테이션들은 @BeforeEach , @BeforeAll 과 동작 방식은 같고, 호출되는 시점만 반대이다.

 

 

Assumption을 이용한 조건부 테스트

public class AssumptionTest {
    @DisplayName("Assumption Test")
    @Test
    public void assumptionTest() {
        /** assumeTrue(파라미터) 가 true 이면 아래 로직을 실행된다. */
        assumeTrue(System.getProperty("os.name").startsWith("Windows"));
//        assumeTrue(System.getProperty("os.name").startsWith("Linux")); // (2)
        System.out.println("execute?");
        assertTrue(processOnlyWindowsTask());
    }

    private boolean processOnlyWindowsTask() {
        return true;
    }
}

Hamcrest

Hamcrest는 JUnit 기반의 단위 테스트에서 사용할 수 있는 Assertion Framework이다.

Assertion을 위한 매쳐(Matcher)가 자연스러운 문장으로 이어지므로 가독성이 향상 된다.

 

JUnit과 Hamcrest 비교

 

assertThat(결과값, is(equalTo(기대값)))

public class HelloJunitTest {
    @DisplayName("Hello Junit Test")
    @Test
    public void assertionTest() {
        String actual = "Hello, JUnit";
        String expected = "Hello, JUnit";

        assertEquals(expected, actual); // JUnit
        assertThat(actual, is(equalTo(expected))); // Hamcrest
    }
}

 

Hamcrest의 예외(Exception) 테스트

예외에 대한 테스트는 Hamcrest 만으로 Assertion을 구성하기 힘들기 때문에 Custom Matcher를 직접 구현해서 사용할 수 있다.

public class AssertionExceptionHamcrestTest {

    @DisplayName("throws NullPointerException when map.get()")
    @Test
    public void assertionThrowExceptionTest() {
    	assertThrows(NullPointerException.class, () -> getCryptoCurrency("XRP")); // JUnit
        Throwable actualException = assertThrows(NullPointerException.class,
                () -> getCryptoCurrency("XRP")); // Hamcrest

        assertThat(actualException.getCause(), is(equalTo(null))); // Hamcrest
    }

    private String getCryptoCurrency(String unit) {
        return CryptoCurrency.map.get(unit).toUpperCase();
    }
}

JUnit의 assertThrows() 메서드를 이용하여 리턴 값으로 전달 받은 Exception 내부의 정보를 가져온 후, assertThat(expectedException.getCause(), is(equalTo(null)));처럼 추가로 검증을 진행 했다.


슬라이스 테스트

슬라이스 테스트는 애플리케이션을 특정 계층으로 쪼개어 테스트하는 것을 의미한다.

 

API 계층 테스트

API 계층의 테스트 대상은 대부분 클라이언트의 요청을 받아들이는 핸들러인 Controller 클래스이다.

@SpringBootTest // Spring Boot 기반의 애플리케이션을 테스트 하기 위한 Application Context를 생성
@AutoConfigureMockMvc // Controller 테스트를 위한 애플리케이션의 자동 구성 작업
public class MemberControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Gson gson;

    @Test
    void postMemberTest() throws Exception {
        // given. 테스트용 request body 생성
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com", "홍길동", "010-1234-5678");

        String content = gson.toJson(post); // MemberDto.Post 객체를 JSON 포맷으로 변환


        // when. MockMvc 객체로 테스트 대상 Controller 호출
        ResultActions actions =
                // 핸들러 메서드 요청을 전송하기 위한 perform() 메서드
                mockMvc.perform(post("/v11/members") // request URL 설정
                        .accept(MediaType.APPLICATION_JSON) // 응답 타입
                        .contentType(MediaType.APPLICATION_JSON) // 서버쪽 처리 타입
                        .content(content)); // request body 데이터 설정

        // then. Controller 핸들러 메서드에서 응답으로 수신한 HTTP Status 및 response body 검증
        MvcResult result = actions
                .andExpect(status().isCreated()) // andExpect()로 매처 검증, status().isCreate()로 201이 맞는지 검증
                // jsonPath()를 통해 response body의 프로퍼티 중 응답으로 받는 값이 request body로 전송한 값과 일치하는지 검증
                .andExpect(jsonPath("$.data.email").value(post.getEmail()))
                .andExpect(jsonPath("$.data.name").value(post.getName()))
                .andExpect(jsonPath("$.data.phone").value(post.getPhone()))
                .andReturn(); // response 데이터 확인

        System.out.println(result.getResponse().getContentAsString());
    }
}

데이터 액세스 계층 테스트

데이터 액세스 계층 테스트 시에는 DB의 상태를 테스트 케이스 실행 이전으로 되돌려서 깨끗하게 만들어 주어야 한다.

 

회원 정보 저장 테스트

// MemberRepository의 기능을 정상적으로 사용하기 위한 Configuration을 Spring이 자동으로 해주게된다.
// @Transactional을 포함하고 있어 하나의 테스트 케이스 실행이 종료되는 시점에 데이터베이스에 저장된 데이터는 rollback 처리된다.
@DataJpaTest
public class DataAccessLayerTest {
    @Autowired
    private MemberRepository memberRepository; // 테스트 대상 DI

    @Test
    public void saveMemberTest() {
        // given. 테스트 데이터 준비
        Member member = new Member();
        member.setEmail("hgd@gmail.com");
        member.setName("홍길동");
        member.setPhone("010-1234-5678");

        // when. 회원 정보 저장
        Member savedMember = memberRepository.save(member);

        // then. 회원 정보 검증
        assertNotNull(savedMember); // 저장된 회원 정보가 null이 아닌지 검증
        // Member 필드 들이 테스트 데이터와 일치하는지 검증
        assertTrue(member.getEmail().equals(savedMember.getEmail()));
        assertTrue(member.getName().equals(savedMember.getName()));
        assertTrue(member.getPhone().equals(savedMember.getPhone()));
    }
}

 

회원 정보 조회 테스트

@DataJpaTest
public class MemberRepositoryTest {
    @Autowired
    private MemberRepository memberRepository; // 테스트 대상 DI

    @Test
    public void findByEmailTest() {
        // given. 회원 정보 준비
        Member member = new Member();
        member.setEmail("hgd@gmail.com");
        member.setName("홍길동");
        member.setPhone("010-1234-5678");

        // when. 회원 정보 저장
        memberRepository.save(member);
        // 이메일에 해당되는 회원 정보 조회
        Optional<Member> findMember = memberRepository.findByEmail(member.getEmail());

        // then. 회원 정보 조회 검증
        assertTrue(findMember.isPresent()); // null 인지 검사
        // 조회한 회원 이메일 주소와 테스트 데이터 이메일과 일치하는지 검증
        assertTrue(findMember.get().getEmail().equals(member.getEmail()));
    }
}

 

 

'Spring Boot > MVC' 카테고리의 다른 글

[Spring MVC] API 문서화 - Spring Rest Docs  (0) 2022.09.15
[Spring MVC] 테스팅(Testing) - Mockito  (0) 2022.09.13
[Spring MVC] 트랜잭션(Transaction)  (0) 2022.09.06
[Spring MVC] Spring Data JPA 데이터 액세스 계층 구현  (0) 2022.09.03
[Spring MVC] JPA Entity 매핑과 연관 관계 매핑  (0) 2022.09.01
    'Spring Boot/MVC' 카테고리의 다른 글
    • [Spring MVC] API 문서화 - Spring Rest Docs
    • [Spring MVC] 테스팅(Testing) - Mockito
    • [Spring MVC] 트랜잭션(Transaction)
    • [Spring MVC] Spring Data JPA 데이터 액세스 계층 구현
    WY J
    WY J

    티스토리툴바