테스트는 어떤 대상에 대한 일정 기준을 정해놓고, 그 대상이 정해진 기준에 부합하는지 부합하지 못하는지 검증하는 과정이다.
테스트를 해야 하는 이유는 테스트 대상이 무엇이든 간에 테스트를 거쳐서 테스트 대상이 검증 과정에 통과하게 하여 최대한 더 나은 결과를 얻기 위해서이다.
기능 테스트
통합 테스트
슬라이스 테스트
단위 테스트
Fast
일반적으로 작성한 테스트 케이스는 빨라야 한다.
Indepnedent
각각의 테스트 케이스는 독립적이어야 한다.
어떤 테스트 케이스를 먼저 실행시켜도 순서와 상관없이 정상적으로 동작해야 한다.
Repeatable
어떤 환경에서도 반복 실행이 가능해야 한다.
외부 서비스나 리소스가 연동된 경우, 동일한 테스트 결과를 보장할 수 없다. == 외부 서비스나 리소스 연동을 끊어주는 것이 바람직하다.
Self-validating
성공 혹은 실패라는 검증 결과를 보여줘야 한다.
테스트 케이스 스스로 결과가 옳은지 그른지 판단할 수 있어야 한다.
Timely
기능 구현을 하기 전에 작성해야 한다. (TDD)
구현하고자 하는 기능을 단계적으로 업그레이드하며 테스트 케이스도 단계적으로 업그레이드 하는 방식이 낫다.
Given
When
Then
expected
)과 결과 값(actual
)을 비교해서 기대한대로 동작하는지 검증(Assertion
)하는 코드가 포함됨Assertion?
테스트 케이스의 결과가 반드시 true여야 한다는 것을 논리적으로 표현한 것이다.
== 예상하는 결과 값이 true이길 바라는 것
JUnit은 Java로 만들어진 애플리케이션을 테스트하기 위한 오픈 소스 테스트 프레임워크로, Java의 표준 테스트 프레임워크라고 할 수 있다.
💡Spring Boot Intializr를 이용해서 프로젝트를 생성하면
기본적으로testImplementation >'org.springframework.boot:spring-boot-starter-test'
스타터가 포함되며 JUnit도 포함되어 있다.
import org.junit.jupiter.api.Test;
public class JunitDefaultStructure {
@Test
public void test1() {
// 테스트 하고자 하는 대상에 대한 테스트 로직 작성
}
@Test
public void test2() {
// 테스트 하고자 하는 대상에 대한 테스트 로직 작성
}
@Test
public void test3() {
// 테스트 하고자 하는 대상에 대한 테스트 로직 작성
}
}
assertEquals(기대하는 문자열, 실제 결과 값)
assertNotNull(테스트 대상 객체, 테스트 실패시 표시할 메시지)
assertThrows(발생이 기대되는 예외 클래스, <람다 표현식>테스트 대상 메서드)
@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()
@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(); } }
JUnit 기반의 단위 테스트에서 사용할 수 있는 Assertion Framework
Assertion을 위한 Matcher가 자연스러운 문장으로 이어져 가독성이 향상됨
테스트 실패 메시지를 이해하기 쉬움
다양한 Matcher를 제공함
➡️ 이러한 이유로 JUnit에서 지원하는 Assertion 메서드보다 더 많이 사용된다.
assertThat(테스트 대상의 실제 결과 값, 기대하는 값);
assertThat(actual, is(equalTo(expected)));
assert that actual is equal to expected
라는 자연스러운 영어 문장으로 읽힘결과 값(actual)이 기대 값(expected)과 같다는 것을 검증한다.
Expected: is "Hello, World"
but: was "Hello, JUnit"
✔ JUnit의 Assertion 메서드
assertEquals(expected, actual);
파라미터로 입력된 값의 변수명을 통해 대략적으로 어떤 검증을 하려는지 알 수 있으나, 구체적인 의미는 유추가 필요함
ᅠ- 📞 JUnit의 failed 메시지 예시
expected: <Hello, World> but was: <Hello, JUnit> Expected :Hello, World Actual :Hello, JUnit
➡️ 자연스러운 의미 파악이 어려움
Smoke Test
- 애플리케이션의 특정 수정 사항으로 인해 영향을 받을 수 있는 범위에 한하여 제한된 테스트를 진행하는 것
- 📑 Smoke testing
✔ 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 테스트를 위한 구성의 복잡함을 해결
application.yml
파일에 아래 설정을 추가...
server:
servlet:
encoding:
force-response: true
❗데이터 액세스 계층을 테스트 하기 위한 규칙
- 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 처리 됨
@JdbcTest
@DataJdbcTest
📑 Annotation Interface DataJpaTest
✔ Mock-up
- 모형 혹은 가짜
- 실제 제품과 유사한 디자인일 수도 있고, 모든 기능이 동작하진 않지만 일부 기능을 테스트 할 수 있는 모형 제품
✔ 테스트에서의 Mock
- 가짜 객체
- 단위 테스트나 슬라이스 테스트 등에 Mock 객체를 사용하는 것은 Mocking
✅ Mock 객체를 사용하지 않은 슬라이스 테스트 프로세스 예시
MemberControllerTest 클래스의
postMemberTest()
➡️ MemberController 클래스의postMember()
➡️ MemberService 클래스의createMember()
➡️ MemberRepository 인터페이스의save()
➡️ H2
➡️ 위 과정을 역순으로 진행하여 테스트 케이스에 도달
⭐ 슬라이스 테스트의 목적은 해당 계층 영역에 대한 테스트에 집중하는 것이다.
❗Mock 객체를 사용하면 MemberController에 진정한 슬라이스 테스트를 적용할 수 있다.
✅ Mock 객체를 사용한 슬라이스 테스트 프로세스
MemberControllerTest 클래스의
postMemberTest()
➡️ MemberController 클래스의postMember()
➡️ MemberService 클래스의createMember()
➡️ 위 과정을 역순으로 진행하여 테스트 케이스에 도달
@MockBean
:
Application Context에 등록되어 있는 Bean에 대한 Mockito Mock 객체를 생성하고 주입해주는 역할
@MockBean
어노테이션을 필드에 추가하면 해당 필드의 Bean에 대한 Mock 객체를 생성한 후 필드에 주입(DI)@Autowired
:
Dto 클래스를 @Autowired
어노테이션으로 감싸서 response, request를 처리할 mapper 클래스를 필드에 주입(DI)
MockMemberService(가칭) 클래스는 우리가 테스트하고자 하는 Controller의 테스트에 집중할 수 있도록 다른 계층과의 연동을 끊어주는 역할
@ExtendWith(MockitoExtension.class)
:
클래스 레벨에서 @ExtendWith(MockitoExtension.class)
어노테이션을 추가하면 Spring이 아닌 JUnit에서 Mockito 기능을 사용하겠다는 의미
@Mock
어노테이션을 추가하여 해당 필드의 객체를 Mock 객체로 생성
@InjectMocks
어노테이션으로 Mock 객체를 주입할 Service 클래스를 지정하고 필드에 주입(DI)
DDD vs TDD
- DDD :
- 도메인 중심의 설계 기법
- 도메인 모델은 애플리케이션 개발의 핵심 역할 == 도메인 모델이 없으면 애플리케이션도 없음
- TDD :
- 테스트 중심
- 테스트를 먼저 하고 구현은 그 다음에 한다.
전통적인 개발 방식
- 일반적인 개발 절차 :
서비스에 대한 요구 사항 수집 ➡️ 구체적인 기능 요구 사항 정의 ➡️ 기능 요구 사항에 맞게 애플리케이션 디자인
ㅤ- 애플리케이션 디자인 과정에서 백엔드 개발자의 일반적인 흐름 :
도메인 모델 도출 ➡️ 엔드포인트, 비즈니스 로직 등으로 큰 그림 설계 ➡️ 클래스와 인터페이스의 큰 틀 작성 ➡️ 메서드를 정의하며 세부 동작 설계 ➡️ 테스트 ➡️ 디버깅
⭐ TDD는 전통적인 개발 방식과는 다르게, 애플리케이션 개발 흐름이 선 구현, 후 테스트
가 일반적이다.
failed
인 테스트 케이스를 지속적으로, 단계적으로 수정하면서 테스트 케이스 실행 결과가 passed
가 되도록 만든다.passed
될 만큼의 코드만 우선 작성한다.실패하는 테스트 → 실패하는 테스트를 성공할 만큼의 기능 구현 → 성공하는 테스트 → 리팩토링 → 실패하는 테스트와 성공하는 테스트 확인
흐름을 반복한다.👍 TDD의 장점
👎 TDD의 단점