코드스테이츠 백엔드 부트캠프 54, 55일차 - [Spring MVC] 테스팅(Testing)

wish17·2023년 3월 3일
0
post-thumbnail

DailyCoding 32번

총 인원 N명이 설 수 있는 모든 경우의 수 중 K의 경우가 몇번쨰인지 구하시오.

  • 오름차순으로 나열
  • 중복되는 요소 x

ex) N = 3일경우, [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
K = [2, 3, 1]라면 정답은 3

처음에는 예시와 같이 이중배열을 만들어서 K의 인덱스를 찾을까 싶었지만 더 간단한 방법이 떠올랐다.

아래와 같이 K의 요소와 길이를 통해 몇가지 경우의 수가 앞에서 지나갔는지 카운팅하는 것이다.

public class FindDoubleArrangement { // 규칙이 있는 이중배열에서 k의 인덱스 찾기
    public int orderOfPresentation(int N, int[] K) {
        int result = 0;
        for(int i=0; i<K.length; i++){
            result = result + numberOfCase(K[i],N-i);
        }

        return result;
    }

    public int numberOfCase(int num, int N){
        int result = 0;
        result = result + (num-1)*factorial(N-1);
        return result;
    }

    public int factorial(int num){
        if(num<=0) return 0;
        if(num==1) return 1;

        return num*factorial(num-1);
    }
}
//입력
N = 3, K = [2, 3, 1]

//출력
4

앞에 요소가 이미 사용한 숫자인 case는 경우의 수에서 제외해야하는데 깜빡했다.

public class FindDoubleArrangement { // 규칙이 있는 이중배열에서 k의 인덱스 찾기
    public int orderOfPresentation(int N, int[] K) {
        int result = 0;
        ArrayList<Integer> arrayList = new ArrayList<Integer>();

        for(int i=0; i<K.length; i++){
            int num = K[i];
            for(Integer o : arrayList){
                if(o<K[i]) num--;
            }
            result = result + numberOfCase(num,N-i);
            arrayList.add(K[i]);
        }

        return result;
    }

    public int numberOfCase(int num, int N){
        int result = 0;
        result = result + (num-1)*factorial(N-1);
        return result;
    }

    public int factorial(int num){
        if(num<=0) return 0;
        if(num==1) return 1;

        return num*factorial(num-1);
    }
}

모든 테스트케이스 통과

막상 작성하고 보니 이것도 결국 이중반복문을 사용했기 때문에 시간복잡도면에서 좋다고 보기 힘들 것 같다.

    public int orderOfPresentation2(int N, int[] K) {
        // 조의 개수 N, 발표 순서 K
        // 발표 순서를 만드는 것은 순열(permutation)이므로, 발표 순서의 모든 경우의 수는 !(팩토리얼)이다.

        int order = 0;

        boolean[] isUsed = new boolean[N + 1];

        for (int i = 0; i < K.length; i++) {
            int num = K[i];
            isUsed[num] = true;
            boolean[] candidates = Arrays.copyOfRange(isUsed, 1, num); // // num보다 앞에 올 수 있는 수들의 배열을 복제
            int validCnt = 0;
            for (boolean candidate : candidates) if (!candidate) validCnt++; // 아직 사용되지 않은 수의 개수 카운팅
            int formerCnt = validCnt * factorial(N - i - 1);
            order = order + formerCnt;
        }
        return order;
    }

boolean을 이용해서 BFS알고리즘 처럼 방문체크 하듯 해봤지만 이것도 결국 순회해야하고 오히려 순회해야하는 양이 많아졌다.

일단은 시간복잡도를 더 줄일 방법이 생각나지 않아 여기까지만 고민해봤다.

[Spring MVC] 테스팅(Testing)

단위 테스트(Unit Test)

단위 테스트 코드는 대부분 메서드 단위로 작성된다.

테스트 케이스(Test Case)

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

F.I.R.S.T 원칙

  • Fast (빠르게): 테스트 케이스는 빠르게 실행되어야 한다. 느린 테스트는 개발자들이 자주 실행하지 않게 되어 버그를 찾기 어렵다.
  • Independent (독립적으로): 각각의 테스트는 서로 독립적으로 실행되어야 한다. 다른 테스트에 의존하는 경우, 실패한 테스트를 찾기 어렵게 만든다.
    Repeatable (반복 가능하게): 테스트 케이스는 반복 가능해야 한다. 언제든지 실행 가능하고, 결과가 일관되게 나와야 한다.
  • Self-Validating (자가 검증 가능하게): 단위 테스트는 스스로 검증이 가능해야 한다. 테스트가 성공한 것과 실패한 것을 명확하게 알려줘야 한다.
  • Timely (적시에): 단위테스트는 기능 구현을 하기 전에 작성되어야 한다.
    (코딩테스트처럼 결과를 정해두고 거기에 맞춘 기능을 짠다고 생각하면 됨)
    이를 통해 개발자는 코드 수정 후 변경 사항에 대한 영향을 빠르게 확인할 수 있다.

Assertion(어써션)

  • 예상하는 결과 값이 참(true)이길 바라는 것
  • 테스트 결과를 검증할 때 주로 사용하는 단어

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

풀코드 GitHub주소

기존에 코딩테스트 하던 것 처럼 테스트하는 방식이다.


JUnit을 사용한 단위 테스트

JUnit

  • Java의 테스트 프레임워크 중 하나
  • Spring Boot의 디폴트 테스트 프레임워크

Assertion 메서드 연습 GitHub 주소

@DisplayName("테스트 이름")

  • 테스트 이름을 정하는 애너테이션

assertEquals(aaa, bbb)

  • JUnit에서 사용하는 두 값이 같은지 비교하는 메서드
  • 첫번째 파라미터와 두번째 파라미터가 같은지 비교

assertNotNull()

  • 테스트 대상 객체가 null 이 아닌지를 테스트하는 메서드
  • 첫 번째 파라미터는 테스트 대상 객체이고, 두 번째 파라미터는 테스트에 실패했을 때, 표시할 메시지

assertThrows()

  • 동작 과정 중에 예외가 발생하는지 테스트하는 메서드
  • 첫 번째 파라미터에는 발생이 기대되는 예외 클래스를 입력, 두 번째 파라미터인 람다 표현식에는 테스트 대상 메서드를 호출
//예시
assertThrows(NullPointerException.class, () -> getCryptoCurrency("XRP"));

예외 클래스의 상속 관계를 이해한 상태에서 테스트 실행 결과를 예상해야 된다.
(NullPointerExceptionRuntimeException 을 상속하는 하위 타입이고, RuntimeExceptionException 을 상속하는 하위 타입이다.)

Executable 함수형 인터페이스

  • assertThrows() 의 두 번째 파라미터인 람다 표현식은 JUnit에서 지원하는 Executable 함수형 인터페이스다.
  • Java에서 지원하는 함수형 인터페이스 중에서 리턴값이 없는 Consumer에 해당 (리턴값이 없음)

자바 내장 함수형 인터페이스 종류

Consumer 함수형 인터페이스

  • 인수를 받아들이고, 리턴 값을 반환하지 않는다.

Supplier 함수형 인터페이스

  • 인수를 받지 않고, 리턴 값을 반환한다.

Function 함수형 인터페이스

  • 하나의 인수를 받아들이고, 결과를 반환한다.

Predicate 함수형 인터페이스

  • 인수를 받아들이고, true 또는 false 값을 반환한다.

Executable 함수형 인터페이스

  • 이러한 함수형 인터페이스와 함께 사용하여 자바의 함수형 프로그래밍을 구현할 수 있다.

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

연습코드 GitHub주소

@BeforeEach

  • 테스트 케이스를 실행하기 전 전처리 과정에 사용하는 애너테이션
  • 각각의 테스트 케이스가 실행될 때 마다 실행되도록 함

@BeforeAll

  • 테스트 케이스를 실행하기 전 전처리 과정에 사용하는 애너테이션
  • 클레스 레벨에서 테스트 케이스를 한꺼번에 실행 시키면 테스트 케이스가 실행되기 전에 딱 한번만 실행 됨
  • 이 애너테이션을 추가한 메서드는 정적 메서드(static method)여야 한다.

assertDoesNotThrow()

  • 예외가 발생하지 않는다고 기대하는 Assertion 메서드
 assertDoesNotThrow(() -> getCryptoCurrency("XRP"));

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

@AfterEach

  • @BeforeEach와 동작 방식 같음
  • 호출되는 시점만 반대

@AfterAll

  • @BeforeAll과 동작 방식 같음
  • 호출되는 시점만 반대

Assumption을 이용한 조건부 테스트

Assumption 기능 = 특정 환경에만 테스트 케이스가 실행 되도록 하는 기능

연습코드 GitHub주소

assumeTrue()

  • 매개변수로 전달된 조건이 true 일 때에만 테스트를 실행
  • 테스트가 실행되지 않은 경우, 해당 테스트는 통과한 것으로 처리된다.
  • 테스트의 전제 조건을 검증하는데 사용

매개변수로 false가 들어갈 경우

assertTrue()

  • 매개변수로 전달된 조건이 true 인지를 검증한다.
  • 테스트 결과가 true 가 아닌 경우, 해당 테스트를 실패로 처리한다.
  • 결과값을 검증하는데 사용

매개변수로 false가 들어갈 경우

위 두가지 메서드 모두 조건 검증에 사용되는 메서드이지만, assumeTrue 메서드는 검증이 실패해도 해당 테스트를 통과한 것으로 처리하고, 해당 테스트를 실행하지 않는다. 반면 assertTrue 메서드는 검증이 실패하면 해당 테스트를 실패로 처리한다. 따라서, assumeTrue 메서드는 전제 조건을 검증할 때 사용하고, assertTrue 메서드는 결과 값을 검증할 때 사용한다.


Hamcrest를 사용한 Assertion

Hamcrest

  • JUnit 기반의 단위 테스트에서 사용할 수 있는 Assertion Framework
  • Assertion을 위한 매쳐(Matcher)가 자연스러운 문장으로 이어지므로 가독성이 향상 된다.
  • 테스트 실패 메시지를 이해하기 쉽다.
  • 다양한 Matcher를 제공한다.

연습내용 GitHub 주소


슬라이스 테스트(Slice Test)

API 계층 테스트

슬라이스 테스트

  • 개발자가 각 계층에 구현해 놓은 기능들이 잘 동작하는지 특정 계층만 잘라서(Slice) 테스트하는 것

@SpringBootTest

  • Spring Boot 기반의 애플리케이션을 테스트 하기 위한 Application Context를 생성

@AutoConfigureMockMvc

  • Controller 테스트를 위한 애플리케이션의 자동 구성 작업을 해준다.

Gson 라이브러리

  • JSON 데이터를 Java 객체로 변환하거나 Java 객체를 JSON 데이터로 변환하는 기능을 제공해주는 라이브러리
  • Gson 라이브러리를 사용하기 위해서는 build.gradle의 dependencies {...}에 implementation 'com.google.code.gson:gson' 를 추가해야 한다.

Controller 테스트

MockMvc 클래스

  • 일종의 Spring MVC 테스트 프레임워크
  • Tomcat 같은 서버를 실행하지 않고 Spring 기반 애플리케이션의 Controller를 테스트 할 수 있게 해줌
  • MockMvc로 테스트 대상 Controller의 핸들러 메서드에 요청을 전송하기 위해서는 기본적으로 perform() 메서드를 먼저 호출해야 한다.

mockMvc.perform()

  • Spring MVC Test 프레임워크에서 제공하는 가상의 HTTP 요청을 생성, 처리하는 가상의 서버를 실행해주는 메서드
  • 컨트롤러와의 상호작용을 시뮬레이션 해주는 것
  • ResultActions 타입의 객체를 리턴, ResultActions 객체를 이용해서 전송한 request에 대한 검증을 수행 가능

MockMvcRequestBuilders 클래스

  • 빌더 패턴을 통해 request 정보를 채워 넣는데 사용하는 클래스
  • post("/v11/members")
    • HTTP POST METHOD와 request URL을 설정
  • accept(MediaType.APPLICATION_JSON)
    • 리턴 받을 응답 타입을 JSON 타입으로 설정
  • contentType(MediaType.APPLICATION_JSON)
    • Content Type을 JSON 타입으로 설정
  • content()
    • request body 데이터를 설정하는데 사용

연습내용 풀코드 GitHub 주소


데이터 액세스 계층 테스트

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

@DataJpaTest

  • 인메모리 데이터베이스를 사용하여 테스트 데이터베이스 환경을 구성한다.
  • EntityManager를 사용하여 JPA 엔티티를 테스트한다.
  • 리포지토리의 CRUD 기능을 테스트한다.
  • 스프링부트 애플리케이션 컨텍스트를 제공
  • @Transactional 애너테이션을 포함하고 있음
    (하나의 테스트 케이스 실행이 종료되는 시점에 데이터베이스에 저장된 데이터는 rollback 처리)

@SpringBootTest와 @DataJpaTest의 차이점

@SpringBootTest

  • 스프링 부트 애플리케이션을 테스트하기 위한 애너테이션이다.
  • 실제 애플리케이션과 유사한 환경을 구성하기 때문에, 통합 테스트(Integration Test)를 수행할 때 사용된다.
  • 모든 빈(Bean)들을 로드하기 때문에 느리고, 실제 데이터베이스와 연동된다.
  • 테스트할 때 사용하는 환경에 따라 달라진다.

@DataJpaTest

  • JPA 기능만을 테스트하기 위한 애너테이션이다.
  • 실제 데이터베이스 대신 메모리 내 데이터베이스(H2)를 사용한다.
  • JPA와 관련된 빈들만 로드하기 때문에 빠르고, 테스트 시간을 단축할 수 있다.
  • 테스트할 때 사용하는 환경에 따라 달라진다.

MockMvc를 사용한 Controller 슬라이스 테스트 실습

풀코드 GitHub 주소

import com.codestates.member.dto.MemberDto;
import com.google.gson.Gson;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.transaction.annotation.Transactional;

import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Transactional
@SpringBootTest
@AutoConfigureMockMvc
public class MemberControllerHomeworkTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Gson gson;

    @Test
    void postMemberTest() throws Exception {
        // given
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com",
                "홍길동",
                "010-1234-5678");
        String content = gson.toJson(post);


        // when
        ResultActions actions =
                mockMvc.perform(
                        post("/v11/members")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // then
        actions
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", is(startsWith("/v11/members/"))));
    }

    @Test
    void patchMemberTest() throws Exception {
        // TODO MemberController의 patchMember() 핸들러 메서드를 테스트하는 테스트 케이스를 여기에 작성하세요.
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com","홍길동","010-1111-1111");
        String postContent = gson.toJson(post);

//        ResultActions postActions =
                mockMvc.perform(
                        post("/v11/members")
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(postContent)
                );

        MemberDto.Patch patch = new MemberDto.Patch("홍길동","010-1111-1111");
        String patchContent = gson.toJson(patch);

        mockMvc.perform(
                patch("/v11/members/1")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(patchContent)
        )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.name").value(patch.getName()))
                .andExpect(jsonPath("$.data.phone").value(patch.getPhone()));
    }

    @Test
    void getMemberTest() throws Exception {
        // given: MemberController의 getMember()를 테스트하기 위해서 postMember()를 이용해 테스트 데이터를 생성 후, DB에 저장
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com","홍길동","010-1111-1111");
        String postContent = gson.toJson(post);

        ResultActions postActions =
                mockMvc.perform(
                        post("/v11/members")
                                .contentType(MediaType.APPLICATION_JSON)
                                .accept(MediaType.APPLICATION_JSON)
                                .content(postContent)
                );
        long memberId;
        String location = postActions.andReturn().getResponse().getHeader("Location"); // "/v11/members/1"
        memberId = Long.parseLong(location.substring(location.lastIndexOf("/") + 1));

        // when / then
        mockMvc.perform(
                        get("/v11/members/" + memberId)
                                .accept(MediaType.APPLICATION_JSON)
                )
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.email").value(post.getEmail()))
                .andExpect(jsonPath("$.data.name").value(post.getName()))
                .andExpect(jsonPath("$.data.phone").value(post.getPhone()));
    }

    @Test
    void getMembersTest() throws Exception {
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com","홍길동","010-1111-1111");
        String postContent = gson.toJson(post);

        MemberDto.Post post2 = new MemberDto.Post("hgd2@gmail.com","둘길동","010-2222-2222");
        String postContent2 = gson.toJson(post2);

        mockMvc.perform(
                post("/v11/members")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(postContent)
        );

        mockMvc.perform(
                post("/v11/members")
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(postContent2)
        );


        mockMvc.perform(get("/v11/members?page=1&size=10").accept(MediaType.APPLICATION_JSON))
                .andExpectAll(
                        status().isOk(),
                        jsonPath("$.data[0].email").value(post2.getEmail()), // 페이지네이션 역순정렬 때문에 반대로 해야 함
                        jsonPath("$.data[1].email").value(post.getEmail())
                );

//        MvcResult result =
//                mockMvc.perform(
//                        get("/v11/members?page=1&size=10")
//                                .accept(MediaType.APPLICATION_JSON)
//                )
//                        .andExpect(status().isOk())
//                        .andExpect(jsonPath("$.data[0].email").value(post2.getEmail()))
//                        .andExpect(jsonPath("$.data[1].email").value(post.getEmail()));
    }

    @Test
    void deleteMemberTest() throws Exception {
        MemberDto.Post post = new MemberDto.Post("hgd@gmail.com","홍길동","010-1111-1111");
        String postContent = gson.toJson(post);

        ResultActions postActions =
            mockMvc.perform(
                    post("/v11/members")
                            .accept(MediaType.APPLICATION_JSON)
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(postContent)
            );

        long memberId;
        String location = postActions.andReturn().getResponse().getHeader("Location");
        memberId = Long.parseLong(location.substring(location.lastIndexOf("/")+1)); // Id 가져오기

        mockMvc.perform(
                delete("/v11/members/" + memberId)
                        .accept(MediaType.APPLICATION_JSON)
                        .contentType(MediaType.APPLICATION_JSON)
        ).andExpect(status().isNoContent());


//        mockMvc.perform(
//                delete("/v11/members/1")
//                        .accept(MediaType.APPLICATION_JSON)
//                        .contentType(MediaType.APPLICATION_JSON)
//        ).andExpect(status().isNoContent());
    }
}
  • import 자동추가 안되는 메서드들(get,post 등)은 하드코딩해서 import해야한다.
  • delete test만드는 과정에서 처음에 memberId를 따로 안뽑아오고 주석처럼 하드코딩으로 했더니 class단위로 테스트를 실행시킬 때와 deleteTest메서드 하나만 실행할 때 결과가 다르게 나왔다.
    이전 테스트에서 post한 내용이 뒷 테스트에 영향을 줘서 그런 것!!
  • Dto 기본생성자 존재 여부에 따른 mapper 자동생성 오류 조심하자

0개의 댓글