[Section 3] Testing

Kim·2022년 11월 9일
0

Boot Camp

목록 보기
45/64

테스트

테스트는 어떤 대상에 대한 일정 기준을 정해놓고, 그 대상이 정해진 기준에 부합하는지 부합하지 못하는지 검증하는 과정이다.
테스트를 해야 하는 이유는 테스트 대상이 무엇이든 간에 테스트를 거쳐서 테스트 대상이 검증 과정에 통과하게 하여 최대한 더 나은 결과를 얻기 위해서이다.

단위 테스트

  • 기능 테스트

    • 테스트 범위가 가장 큰 테스트 == 단위가 가장 큰 테스트
    • 애플리케이션을 사용하는 사용자 입장에서 애플리케이션이 제공하는 기능이 올바르게 동작하는지를 테스트
    • API 툴이나 DB 등 애플리케이션과 연관 대상이 많아 단위 테스트라고 부르기 어려움
  • 통합 테스트

    • 클라이언트 툴 없이 개발자가 작성한 테스트 코드를 실행시키는 테스트 방식
    • 애플리케이션의 여러 계층이 연관되어 있고 DB까지 연결되어 있어 독립적인 테스트가 불가능 == 단위 테스트라고 하기 어려움
  • 슬라이스 테스트

    • 애플리케이션을 특정 계층으로 쪼개는 테스트 방식
    • Mock 객체로 계층별로 끊어서 테스트 할 수 있어 테스트 범위를 좁힐 수 있음
    • 단위 테스트라고 부르기엔 단위가 큰 테스트
    • 애플리케이션의 일부만 테스트하므로 부분 통합 테스트라고도 함
  • 단위 테스트

    • 대부분 메서드 단위로 작성
    • 구현한 코드가 의도대로 동작하는지 빠르게 확인할 수 있음
    • 작은 단위의 테스트로 버그를 미리 찾을 수 있음

🥇F.I.R.S.T 원칙

  • Fast
    일반적으로 작성한 테스트 케이스는 빨라야 한다.

  • Indepnedent
    각각의 테스트 케이스는 독립적이어야 한다.
    어떤 테스트 케이스를 먼저 실행시켜도 순서와 상관없이 정상적으로 동작해야 한다.

  • Repeatable
    어떤 환경에서도 반복 실행이 가능해야 한다.
    외부 서비스나 리소스가 연동된 경우, 동일한 테스트 결과를 보장할 수 없다. == 외부 서비스나 리소스 연동을 끊어주는 것이 바람직하다.

  • Self-validating
    성공 혹은 실패라는 검증 결과를 보여줘야 한다.
    테스트 케이스 스스로 결과가 옳은지 그른지 판단할 수 있어야 한다.

  • Timely
    기능 구현을 하기 전에 작성해야 한다. (TDD)
    구현하고자 하는 기능을 단계적으로 업그레이드하며 테스트 케이스도 단계적으로 업그레이드 하는 방식이 낫다.


JUnit 없이 단위 테스트 적용

💡Given - When - Then

  • Given

    • 테스트를 위한 준비 과정 명시
    • 테스트에 필요한 전제 조건들이 포함됨
    • 테스트 데이터(테스트 대상에 전달되는 입력 값)
  • When

    • 테스트 할 동작(대상) 지정
  • Then

    • 테스트 결과 검증
    • 기대 값(expected)과 결과 값(actual)을 비교해서 기대한대로 동작하는지 검증(Assertion)하는 코드가 포함됨

Assertion?

테스트 케이스의 결과가 반드시 true여야 한다는 것을 논리적으로 표현한 것이다.
== 예상하는 결과 값이 true이길 바라는 것


JUnit

JUnit은 Java로 만들어진 애플리케이션을 테스트하기 위한 오픈 소스 테스트 프레임워크로, Java의 표준 테스트 프레임워크라고 할 수 있다.

💡Spring Boot Intializr를 이용해서 프로젝트를 생성하면
기본적으로 testImplementation >'org.springframework.boot:spring-boot-starter-test' 스타터가 포함되며 JUnit도 포함되어 있다.

✔ JUnit을 사용한 테스트 케이스의 기본 구조

import org.junit.jupiter.api.Test;

public class JunitDefaultStructure {
    @Test
    public void test1() {
        // 테스트 하고자 하는 대상에 대한 테스트 로직 작성
    }

    @Test
    public void test2() {
        // 테스트 하고자 하는 대상에 대한 테스트 로직 작성
    }

    @Test
    public void test3() {
        // 테스트 하고자 하는 대상에 대한 테스트 로직 작성
    }
}

✔ Assertion 메서드

  • assertEquals(기대하는 문자열, 실제 결과 값)

    • 기대하는 값과 실제 결과 값이 같은지를 검증
  • assertNotNull(테스트 대상 객체, 테스트 실패시 표시할 메시지)

    • 테스트 대상 객체가 null이 아닌지를 테스트
  • assertThrows(발생이 기대되는 예외 클래스, <람다 표현식>테스트 대상 메서드)

    • 예상한 예외(Exception)가 발생했는지 테스트
      @Test
       public void assertionThrowExceptionTest() {
           assertThrows(NullPointerException.class, () -> getFruit("APPLE"));
       }private String getFruit(String unit) {
           return CryptoCurrency.map.get(unit).toUpperCase();
       }


      ➡️ 테스트 케이스를 실행하면 getFruit() 메서드가 호출되고, 파라미터로 전달한 "APPLE"에 해당하는 과일이 있는지 map에서 찾는다.
      map에 "APPLE"이 존재하지 않으면 null이 반환될 것이다. map에서 반환된 값이 null인 상태에서 toUpperCase()를 호출하여 대문자로
      변환하려고 했기 때문에 NullPointerException이 발생하게 될 것이다.
      NullPointerException이 발생할 것이라 기대했으므로 테스트 결과는 ✅passed이다.

      NullPointerException.class 대신 RuntimeException.class 혹은 Exception.class로 입력 값을 바꿔도 결과는 ✅passed이다.
      NullPointerException 은 RuntimeException 을 상속하는 하위 타입이고, RuntimeException 은 Exception 을 상속하는 하위 타입이기 때문이다.

      ❗assertThrows() 를 사용해서 예외를 테스트 하기 위해서는 예외 클래스의 상속 관계를 이해한 상태에서 테스트 실행 결과를 예상해야 된다.

📒 JUnit, AssertJ의 개념 및 기초적인 사용법

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

테스트 케이스 실행 전, 어떤 객체나 값에 대한 초기화 작업 등의 전처리 과정이 필요할 때가 있다.
이 경우 JUnit에서 사용할 수 있는 애너테이션이 @BeforeEach@BeforeAll()이다.

  • @BeforeEach
    • 클래스 레벨에서 사용
    • 각각의 테스트 케이스가 실행될 때마다, @BeforeEach 애너테이션을 추가한 메서드는 테스트 케이스 실행 직전에 먼저 실행되어 초기화 작업 등을 진행
public class BeforeEachTest {
    private Map<String, String> map;@BeforeEach
    public void init() {
        map = new HashMap<>();
        map.put("S", "Small");
        map.put("M", "Medium");
        map.put("L", "Large");
    }@DisplayName("Test case 1")
    @Test
    public void beforeEachTest1() {
        map.put("XL", "Extra large");
        assertDoesNotThrow(() -> getSize("XL"));
        // passed --> Assertion 하기 전, map에 "XL"를 추가함
    }// Test case 2 실행 전, init() 메서드가 호출되면서 map 객체 초기화
// --> Test case 1에서 XL를 추가했더라도 다시 초기화되어 이전 상태로 돌아감
    @DisplayName("Test case 2")
    @Test
    public void beforeEachTest2() {
        System.out.println(map);
        assertDoesNotThrow(() -> getSize("XS"));
        // failed
    }private String getSize(String unit) {
        return map.get(unit).toUpperCase();
    }
}
  • @BeforeAll()
    • 클래스 레벨에서 사용
    • 테스트 케이스를 한 번에 실행시키면, 테스트 케이스가 실행되기 전에 단 1번만 초기화 작업을 할 수 있게 함
    • @BeforeAll() 애너테이션을 추가한 메서드는 꼭 정적 메서드(static method)여야 함
public class BeforeAllTest {
    private static Map<String, String> map;@BeforeAll
    public static void initAll() {
        map = new HashMap<>();
        map.put("S", "Small");
        map.put("M", "Medium");
        map.put("L", "Large");System.out.println("initialize Size map");
        // 전체 케이스에서 초기화가 한 번만 진행되기 때문에 "initialize Color map" 한 번만 출력됨
    }@DisplayName("Test case 1")
    @Test
    public void beforeEachTest1() {
        assertDoesNotThrow(() -> getSize("XRP"));
    }@DisplayName("Test case 2")
    @Test
    public void beforeEachTest2() {
        assertDoesNotThrow(() -> getSize("ADA"));
        // passed --> Test case1 진행 전에 초기화가 딱 한 번 되므로, 
        // Test case1에서 저장한 값이 사라지지 않기 때문에 조회 가능
    }private String getSize(String unit) {
        return map.get(unit).toUpperCase();
    }
}

Hamcrest

  • JUnit 기반의 단위 테스트에서 사용할 수 있는 Assertion Framework

  • Assertion을 위한 Matcher가 자연스러운 문장으로 이어져 가독성이 향상

  • 테스트 실패 메시지를 이해하기 쉬움

  • 다양한 Matcher를 제공

➡️ 이러한 이유로 JUnit에서 지원하는 Assertion 메서드보다 더 많이 사용된다.

✔ Hamcrest의 Matcher

  • assertThat(테스트 대상의 실제 결과 값, 기대하는 값);
    • assertThat(actual, is(equalTo(expected)));
      • 위의 Assertion 코드는 assert that actual is equal to expected라는 자연스러운 영어 문장으로 읽힘
        == 결과 값(actual)이 기대 값(expected)과 같다는 것을 검증한다.
    • 📞 Hemcrest Matcher의 failed 메시지
      Expected: is "Hello, World"
        but: was "Hello, JUnit"
      • 자연스럽게 읽혀지는 Assertion 문장을 구성할 수 있어 가독성이 높아짐

✔ JUnit의 Assertion 메서드

  • assertEquals(expected, actual);
    파라미터로 입력된 값의 변수명을 통해 대략적으로 어떤 검증을 하려는지 알 수 있으나, 구체적인 의미는 유추가 필요함
  • 📞 JUnit의 failed 메시지 예시
expected: <Hello, World> but was: <Hello, JUnit>
Expected :Hello, World
Actual   :Hello, JUnit

➡️ 자연스러운 의미 파악이 어려움


Slice Test

Smoke Test

  • 애플리케이션의 특정 수정 사항으로 인해 영향을 받을 수 있는 범위에 한하여 제한된 테스트를 진행하는 것
  • 📑 Smoke testing

✔ API 계층 테스트

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

🌿Spring에서 Controller를 테스트 하기 위한 방법

✔ Controller 테스트를 위한 테스트 클래스 구조

@SpringBootTest
@AutoConfigureMockMvc
public class ControllerTestDefaultStructure {
    @Autowired
    private MockMvc mockMvc;@Test
    public void postMemberTest() {
        // given : 테스트용 request body 생성// when : MockMvc 객체로 테스트 대상 Controller 호출
        	// MockMvc 객체를 통해 요청 URI와 HTTP 메서드 등을 지정하고 테스트용 request body를 추가한 뒤에 request를 수행// then : Controller 핸들러 메서드에서 응답으로 수신한 HTTP Status, response body 검증 
    }
}
  • @SpringBootTest :
    • Spring Boot 기반의 애플리케이션을 테스트 하기 위한 Application Context를 생성함
    • Application Context에는 애플리케이션에 필요한 Bean 객체들이 등록되어 있음
  • @AutoConfigureMockMvc :
    • Controller 테스트를 위한 애플리케이션의 자동 구성 작업을 함
    • MockMvc 같은 기능을 사용하려면 반드시 추가해야 함
  • DI로 주입 받은 MockMvc는 Spring 기반 애플리케이션의 Controller를 테스트 할 수 있는 환경을 지원함
    • 일종의 Spring MVC 테스트 프레임워크

@WebMvcTest를 이용한 Controller 테스트

  • Spring에서 Controller를 테스트 하기 위한 전통적인 방법
    • @WebMvcTest 애너테이션을 사용하면 Controller에서 의존하는 컴포넌트들을 모두 일일이 설정해야 함
    • 때에 따라 데이터 액세스 계층에서 의존하는 설정이나 의존 객체들도 모두 설정해야 할 수도 있음
  • 이러한 불편함으로 @SpringBootTest, @AutoConfigureMockMvc를 이용해서 Controller 테스트를 위한 구성의 복잡함을 해결

💡 response body 응답 데이터에 포함된 한글이 깨질 경우

  • JSON 데이터에서 한글이 깨져 보일 경우 application.yml 파일에 아래 설정을 추가
...
server:
  servlet:
    encoding:
      force-response: true

✔ 데이터 액세스 계층 테스트

  • 실습에서 데이터 액세스 계층에서 사용하고 있는 기술은 Spring Data JPA

❗데이터 액세스 계층을 테스트 하기 위한 규칙

  • DB의 상태를 테스트 케이스 실행 이전으로 되돌려서 깨끗하게 만든다.
    • JUnit으로 작성한 테스트 케이스는 항상 일정한 순서로 테스트가 실행되지 않음
    • ➡️ 각각의 테스트 케이스에 독립성이 보장되어야 함

✔ Repository 테스트를 위한 테스트 클래스 구조

@DataJpaTest
public class RepositoryTestDefaultStructure {
    @Autowired
    private MemberRepository memberRepository;@Test
    public void saveMemberTest() {
    // 테스트하고자 하는 Controller 핸들러 메서드의 테스트 케이스 작성// given : 테스트 할 데이터 준비// when : Repository에 정보 저장// then : 정보가 잘 저장되었는지 검증(Assertion)
    }
}
  • @DataJpaTest :
    • Spring에서 데이터 액세스 계층을 테스트 하기 위한 핵심적인 방법
    • Spring이 해당 Repository의 기능을 정상적으로 사용하기 위한 Configuration을 자동으로 해줌
    • @Transactional 어노테이션을 포함하기 때문에 하나의 테스트 케이스 실행이 종료되는 시점에 DB에 저장된 데이터는 rollback 처리 됨

💡 Spring JDBC나 Spring Data JDBC 환경에서 테스트 환경을 손쉽게 구성할 수 있는 방법

  • 아래와 같은 어노테이션을 사용하면 손쉽게 데이터 액세스 계층에 대한 테스트를 진행할 수 있음
    • Spring JDBC 환경 : @JdbcTest
    • Spring Data JDBC 환경 : @DataJdbcTest

📑 Annotation Interface DataJpaTest


Mockito

✔ Mock-up

  • 모형 혹은 가짜
  • 실제 제품과 유사한 디자인일 수도 있고, 모든 기능이 동작하진 않지만 일부 기능을 테스트 할 수 있는 모형 제품

✔ 테스트에서의 Mock

  • 가짜 객체
  • 단위 테스트나 슬라이스 테스트 등에 Mock 객체를 사용하는 것은 Mocking

✔ 테스트에서의 Mock 객체를 사용하는 이유

✅ Mock 객체를 사용하지 않은 슬라이스 테스트 프로세스 예시

MemberControllerTest 클래스의 postMemberTest()
➡️ MemberController 클래스의 postMember()
➡️ MemberService 클래스의 createMember()
➡️ MemberRepository 인터페이스의 save()
➡️ H2
➡️ 위 과정을 역순으로 진행하여 테스트 케이스에 도달

  • 위 과정은 완전한 슬라이스 테스트라고 보기 어려움
  • 서비스 계층을 거쳐서 데이터 액세스 계층, DB까지 거쳐서 되돌아 오기 때문
  • 통합 테스트에 더 가깝다.

⭐ 슬라이스 테스트의 목적은 해당 계층 영역에 대한 테스트에 집중하는 것이다.
❗Mock 객체를 사용하면 MemberController에 진정한 슬라이스 테스트를 적용할 수 있다.

✅ Mock 객체를 사용한 슬라이스 테스트 프로세스

MemberControllerTest 클래스의 postMemberTest()
➡️ MemberController 클래스의 postMember()
➡️ MemberService 클래스의 createMember()
➡️ 위 과정을 역순으로 진행하여 테스트 케이스에 도달

  • Mock 객체를 이용함으로써 다른 계층과 단절되어 불필요한 과정을 줄일 수 있다.

✔ 슬라이스 테스트에 Mockito 적용

✅ MemberController의 postMember() 테스트에 Mockito 적용

  • @MockBean :
    Application Context에 등록되어 있는 Bean에 대한 Mockito Mock 객체를 생성하고 주입해주는 역할

    • @MockBean 어노테이션을 필드에 추가하면 해당 필드의 Bean에 대한 Mock 객체를 생성한 후 필드에 주입(DI)
  • @Autowired :
    Dto 클래스를 @Autowired 어노테이션으로 감싸서 response, request를 처리할 mapper 클래스를 필드에 주입(DI)

  • MockMemberService(가칭) 클래스는 우리가 테스트하고자 하는 Controller의 테스트에 집중할 수 있도록 다른 계층과의 연동을 끊어주는 역할

✅ MemberService의 createMember() 테스트에 Mockito 적용

  • @ExtendWith(MockitoExtension.class) :
    클래스 레벨에서 @ExtendWith(MockitoExtension.class) 어노테이션을 추가하면 Spring이 아닌 JUnit에서 Mockito 기능을 사용하겠다는 의미

  • @Mock 어노테이션을 추가하여 해당 필드의 객체를 Mock 객체로 생성

  • @InjectMocks 어노테이션으로 Mock 객체를 주입할 Service 클래스를 지정하고 필드에 주입(DI)


TDD

  • TDD : Test Driven Development, 테스트 주도 개발

DDD vs TDD

  • DDD :
    • 도메인 중심의 설계 기법
    • 도메인 모델은 애플리케이션 개발의 핵심 역할 == 도메인 모델이 없으면 애플리케이션도 없음
  • TDD :
    • 테스트 중심
    • 테스트를 먼저 하고 구현은 그 다음에 한다.

전통적인 개발 방식

  • 일반적인 개발 절차 :
    서비스에 대한 요구 사항 수집 ➡️ 구체적인 기능 요구 사항 정의 ➡️ 기능 요구 사항에 맞게 애플리케이션 디자인
  • 애플리케이션 디자인 과정에서 백엔드 개발자의 일반적인 흐름 :
    도메인 모델 도출 ➡️ 엔드포인트, 비즈니스 로직 등으로 큰 그림 설계 ➡️ 클래스와 인터페이스의 큰 틀 작성 ➡️ 메서드를 정의하며 세부 동작 설계 ➡️ 테스트 ➡️ 디버깅

⭐ TDD는 전통적인 개발 방식과는 다르게, 애플리케이션 개발 흐름이 선 구현, 후 테스트가 일반적이다.

✔ TDD의 특성

  • TDD는 모든 조건에 만족하는 테스트를 먼저 진행한 다음, 조건에 만족하지 않는 테스트를 단계적으로 진행하며 실패하는 테스트를 점진적으로 성공시켜 간다.
    • failed인 테스트 케이스를 지속적으로, 단계적으로 수정하면서 테스트 케이스 실행 결과가 passed가 되도록 만든다.
  • TDD에서는 테스트가 passed될 만큼의 코드만 우선 작성한다.
  • TDD 개발 방식
    • 실패하는 테스트 → 실패하는 테스트를 성공할 만큼의 기능 구현 → 성공하는 테스트 → 리팩토링 → 실패하는 테스트와 성공하는 테스트 확인 흐름을 반복한다.

✔ 장단점

👍 TDD의 장점

  • 한 번에 많은 기능을 구현할 필요가 없다.
  • 단순한 기능에서 복잡한 기능으로 확장되면서 그때 그때 검증을 빼먹지 않고 할 수 있다.
  • 리팩토링 할 부분이 있으면 그때 그때 진행하기 때문에 리팩토링 비용이 상대적으로 적어진다.
  • 리팩토링을 통해 코드를 개선하므로 코드 품질을 일정 부분 유지할 수 있다.
  • 코드 수정 이후, 바로 테스트를 진행할 수 있으므로 코드 수정 결과를 빠르게 피드백 받을 수 있다.

👎 TDD의 단점

  • TDD의 개발 방식이 익숙하지 않다.
  • 테스트 코드 작성에 익숙하지 않거나, 테스트 코드 작성을 원치 않는 사람에겐 부정적인 방식일 수 있다.
  • 팀 단위로 개발을 진행하므로 팀원들 간 사전 협의가 필요하다.

0개의 댓글