목차

1️⃣ Test Guide

☝🏻 Spring Boot에서의 테스트

🐘 spring-boot-starter-test

🔷 JUnit 훑어보기

📂 Persistence Layer

🤖 Business Layer

🚦 Presentation Layer

✌🏻 TDD 묻고 BDD로 가!

🐙 TDD란?

🦀 BDD란?

2️⃣ Test Scenario Guide

☝🏻 성공 케이스

✌🏻 실패 케이스

👋🏻 @Nested

3️⃣ 환경 통합하기

☝🏻 ControllerTest & DataCleaner

🧐 ControllerTest

🫨 DataCleaner

✌🏻 Data Maker

🫠 구조

🫥 사용

Testing

1️⃣ Test Guide

☝🏻 Spring Boot에서의 테스트

Spring Boot에서 테스트를 어떻게 해야할까요?

Spring Boot는 Spring Application의 테스트를 위해 많은 기능을 지원해줍니다!

🐘 spring-boot-starter-test

spring-boot-starter-test는 테스트의 주요 종속성입니다. 여기에는 테스트에 필요한 대부분의 요소가 포함되어 있습니다. (JUnit, Mockito 등등…)

의존성을 추가하고 나면 다음과 같은 애플리케이션 테스트 방법들을 사용할 수 있습니다.

종류요약Bean 범위
@SpringBootTest전체 테스트 어노테이션애플리케이션에 주입된 Bean 전체
@WebMvcTestController Layer 테스트MVC 관련 Bean (Controller, ControllerAdvice)
@DataJpaTestJPA(DB I/O) 테스트JPA 관련 Bean (EntityManager)
@RestClientTestRest API 테스트RestTemplate 등 일부 Bean
@JsonTestJson 데이터 테스트Json 관련 일부 Bean

이중에서 가장 많이 사용되는 테스트는 @SpringBootTest@WebMvcTest입니다. 때문에 조금 더 자세히 살펴보겠습니다.

  • SpringBootTest
    • SpringBootTest는 실제 애플리케이션을 자신의 로컬 위에 올려서 포트 주소가 Listening 되어지고, 실제 Database와 커넥션이 붙어지는 상태에서 진행되는 Live 테스트 방법입니다.
  • WebMvcTest
    • MVC를 위한 테스트입니다. 컨트롤러가 예상대로 작동되는지 테스트하기 위해 사용됩니다.
    • Web Layer만 로드하며, @WebMvcTest 어노테이션을 사용시 아래의 항목들만 스캔하도록 제한하여 보다 빠르고 가벼운 테스트가 가능합니다. ex) @Controller, @ControllerAdvice, @JsonComponent, @Convert, Filter, WebMvcConfigurer 등등…
    • WebApplication과 관련된 Bean들만 등록하기 때문에 @SpringBootTest보다 빠릅니다. 또한 통합테스트를 진행하기 어려운 테스트를 개별적으로 진행 가능합니다.

언뜻보면 두 테스트간의 차이는 전체 애플리케이션을 띄운다는 점과 일부 Controller만을 띄운다는 점으로 보여지는데, 목적으로만 본다면 이 점이 가장 핵심이며 애플리케이션의 규모가 커지게 되는 경우 테스트 시간이 그만큼 길어지지 때문에 신규 기능이나 버그 패치 등 일부 기능만을 테스트하고자 할 때는 WebMvcTest가 적당하다고 볼 수 있습니다.


🔷 JUnit 훑어보기

  1. JUnit이란?

    JUnit이란 자바 프로그래밍 언어용 테스트 프레임워크로 어노테이션 기반 테스트를 지원합니다. 또한 단정문을 통해서 테스트 케이스의 기대값에 대해 수행 결과를 확인할 수 있습니다.

  2. JUnit Annotation

    JUnit에서 가장 많이 사용되는 어노테이션입니다. @Test 어노테이션과 생명주기에 관련된 어노테이션을 가지고 있습니다.

    AnnotationDescription
    @Test테스트용 메서드를 표현하는 어노테이션
    @DisplayName테스트 메서드 이름을 지정하는 어노테이션
    @BeforEach각 테스트 메서드가 시작되기 전에, 실행되어야 하는 메소들 표현
    @AfterEach각 테스트 메서드가 시작된 후 실행되어야 하는 메소드르 표현
    @BeforeAll테스트 시작 전에 실행되어야 하는 메서드를 표현 (Static 처리 필요)
    @AfterAll테스트 종료 후에 실행되어야 하는 메서드를 표현 (Static 처리 필요)
  3. Assertions

    JUnit에서 지원해주는 대표적인 검증 함수들입니다.

    MethodDescription
    assertEquals(a, b)a와 b의 값이 동일한지 확인
    assertSame(a, b)a와 b의 객체가 동일한지 확인
    assertNull(a)a가 null인지 확인
    assertNotNull(a)a가 true인지 확인
    assertTrue(a)a가 false인지 확인
    assertFalse(a)a가 false인지 확인
    assertThrows(입셉션 에러 종류 a, 발생하는 로직 b)b 로직 시에 a 인셉션이 발생하는지 확인
    assertThat어떤 조건이 참인지 확인

📂 Persistence Layer

@DataJpaTest를 사용해 Repository Test를 해봅시다.

@DataJpaTest
class UserRepositoryTest {

	@Autowired
	private UserRepository userRepository;

	@Test
	void findEmailById() {
		// given
		User user = UserDataMaker.getSaveEntity();
		User saveUser = userRepository.save();

		// when
		String email = userRepository.findEmailById(saveUser.getId());

		// then
		assertEquals(email, saveUser.getEmail());
	}
}
  • @DataJpaTest는 JPA에 관련된 요소들만 테스트하기 위한 어노테이션으로 JPA 테스트에 관련된 설정들만 적용해줍니다.
  • 메모리상에 내부 데이터베이스를 생성하고 @Entity 클래스들을 등록하고 JPA Repository 설정들을 해줍니다. 각 테스트마다 테스트가 완료되면 관련한 설정들은 롤백됩니다.
  • 복잡한 data jpa repository의 함수를 테스트할 때 사용하기 적합합니다.

🤖 Business Layer

@SpringBootTest 사용해 Unit Test를 해봅시다.

@SpringBootTest
class UserServiceTest {
	
	@Autowired
	private UserService userService;

	@Autowired
	private UserDataMaker userDataMaker;

	@Test
	void saveSuccess() {
		// given
		UserSaveRequest request = UserDataMaker.getSaveRequest();

		// when
		UserSaveResponse result = userService.save(request);
	
		// then
		assertEquals(request.name(), result.name());
	}

	@Test
	void updateFail() {
		// given
		UserUpdateRequest request = UserDataMaker.getUpdateRequest();

		// when & then
		assertThatThrownBy(() -> userService.update(0, request))
			.isInstanceOf(BusinessException.class)
			.hasMessage(USER_NOT_EXIST);
	}

}
  • Unit Test를 할 때, @SpringBootTest를 사용합니다. @SpringBootTest🐘 spring-boot-starter-test 에서 설명했으므로 이하 생략합니다.
  • 추가로 @SpringBootTest를 사용하지 않고 Mockito를 활용해 테스트를 진행할 수 있습니다. 관련 내용은 아래 게시글을 참고하여 주세요.

🚦 Presentation Layer

@WebMvcTest를 사용해 MVC Test를 해봅시다.

@WebMvcTest(UserController.class)
class UserControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@Autowired
	private ObjectMapper objectMapper;

	@MockBean
	private UserService userService;

	@Test
	void getAll() throws Exception {
		// given
		UserSaveRequest request = UserDataMaker.getSaveRequest();
		given(userService.save(any())
			.willReturn(request);

		// when & then
		mockMvc.perform(get("/users"))
			.contentType(MediaType.APPLICATION_JSON)
			.andExpect(status().isOk())
			.andExpect(jsonPath("$.data.name").value("user"))
			.andExpect(jsonPath("$.data.age").value(21))
			.andDo(print());
	}

	@Test
	void save() throws Exception {
		// given
		UserSaveRequest request = UserDataMaker.getSaveRequest();

		// when & then
		mockMvc.perform(post("users"))
			.contentType(MediaType.APPLICATION_JSON)
			.content(objectMapper.writeValueAsString(request))
			.andExpect(status().isOk())
			.andExpect(jsonPath("$.data.name").value("user"))
			.andExpect(jsonPath("$.data.age").value(21))
			.andDo(print());
	}

}

MockMvc

  • 애플리케이션을 배포하지 않고도, 서버의 MVC 동작을 테스트할 수 있는 라이브러리입니다.
  • 주로 Controller 레이어 단위 테스트에 많이 사용됩니다.
  • MockMvc 메소드 살펴보기
    1. perform()
      • 요청을 전송하는 역할을 합니다.
      • 결과로 ResultActions 객체를 받으며, ResultActions 객체는 리턴 값을 검증하고 확인할 수 있는 andExcpect() 메소드를 제공해줍니다.
    2. get()
      • HTTP 메소드를 결정할 수 있습니다. (get, post, put, delete)
      • 인자로는 경로를 보내줍니다.
    3. params()
      • 키=값의 파라미터를 전달할 수 있습니다.
      • 여러 개일 때는 params()를, 하나일 때에는 param()을 사용합니다.
    4. andExpect()
      • 응답을 검증하는 역할을 합니다.
      • 상태 코드, 뷰, 리다이렉트, 모델 정보, 응답 정보 검증 등등을 할 수 있습니다.
    5. andDo()
      • 요청/응답 전체 메시지를 확인할 수 있습니다.

ObjectMapper

  • ObjectMapper는 텍스트 형태의 JSON을 object로 변경해 주거나 object를 텍스트 형태의 JSON으로 변경해 주는 것을 의미합니다.
  • 예를 들면 컨트롤러에 요청이 오면, Content-Type이 JSON 타입인 것을 object로 변경을 해주고, 처리 후 응답으로 클라이언트에게 자동으로 Object를 요청 형태에 해당되는 json으로 변경해줍니다.

@MockBean

  • 말 그대로 가짜 객체입니다.
  • 해당 단위 테스트에만 집중할 수 있도록 도와줍니다.
  • 여기서는 서비스를 MockBean으로 선언하였고, 서비스 내 의존성 연결고리를 신경쓰지 않아도 되며 서비스의 호출, 결과를 임의로 조작하여 테스트를 지원합니다.

✌🏻 TDD 묻고 BDD로 가!

자꾸 나왔던 given, when, then 궁금하시죠? 이 목차에서 다룹니다.

🐙 TDD란?

https://log-hannah.notion.site/00-TDD-50e14ffbbd2243ec90ffdff44d70f308?pvs=4

🦀 BDD란?

https://makeshop.notion.site/BDD-68296d4d1be3416c828563b2f5d6a129?pvs=4

TDD와 BDD의 차이

TDD - Test Driven DevelopmentBDD - Behavior Driven Development
테스트 코드의 목적은?기능 동작의 검증서비스 유저 시나리오 동작의 검증
테스트 코드의 설계중심은?제공할 모듈의 기능 중심서비스 사용자 행위 중심
테스트 코드 설계 재료는?모듈 사양 문서 (개발자가 작성)서비스 기획서 (서비스 기획자가 작성)
어떤 프로젝트에 적합한가?모듈/라이브러리 프로젝트서비스 프로젝트
장점은?설계 단계에서 예외 케이스들을 확인할 수 있다설계 단계에서 누락된 기획을 확인할 수 있다

BDD와 TDD는 서로 상호보완적인 관계입니다. BDD로 어떠한 행위를 테스트하고 해당 행위에서 깊게 테스트 하기 위해서 TDD가 필요합니다. 때문에 프로젝트에서 혼용하여 사용하는 것을 권장드립니다.


2️⃣ Test Scenario Guide

- findAll Method
  - 데이터가 3개 존재할 때
    - 리스트 count가 3이어야 한다
    - 응답 코드가 200이어야 한다
  - 데이터가 존재하지 않을 때
		- 리스트 count가 0이어야 한다
- findOne Method
	- 저장된 데이터를 단건 조회할 때
		- 저장된 데이터와 조회한 데이터가 같아야 한다
	- 저장되지 않은 데이터를 단건 조회할 때
		- 응답 코드가 404이어야 한다
		- ErrorCode는 NOT_EXIST이어야 한다

☝🏻 성공 케이스

모든 API에 대한 정상적인 응답을 테스트합니다.

✌🏻 실패 케이스

  • 조회 엔티티가 없는 경우
  • 연관 엔티티가 없는 경우
  • 바디 or 조회 쿼리가 올바르지 않은 경우
  • 그 외 비지니스 로직에서 Exception이 발생하는 경우

👋🏻 @Nested

  • BDD 패턴으로 테스트하기 위해서 사용하는 어노테이션입니다.

3️⃣ 환경 통합하기

☝🏻 ControllerTest & DataCleaner

🧐 ControllerTest

@SpringBootTest
@AutoConfigureMockMvc
public abstract class ControllerTest {

	@Autowired
	protected MockMvc mockMvc;

	@Autowired
	protected ObjectMapper objectMapper;

	@Autowired
	protected DataCleaner dataCleaner;

	@AfterEach
	void tearDown() {
		dataCleaner.clear();
	}

}

ControllerTest는 E2E 테스트를 위한 상위 abstract class입니다.

  • ControllerTest를 상속받으면 상속 받은 클래스에서 E2E 테스트를 위한 필수적인 MockMvc, ObjectMapper를 선언해주지 않아도 사용할 수 있습니다.
  • 또한 tearDown메소드를 활용해 테스트 단위별로 메모리 DB 남아있는 데이터들을 삭제할 수 있습니다. (테스트가 의존적이지 않도록 도움)
  • 추후 확장의 용이성을 위해서 abstract class로 생성을 하였습니다.

🫨 DataCleaner

@Component
public class DataCleaner {

	private static final String FOREIGN_KEY_CHECK_FORMAT = "SET REFERENTIAL_INTEGRITY %d";
	private static final String TRUNCATE_FORMAT = "TRUNCATE TABLE %s";

	private final List<String> tableNames = new ArrayList<>();

	@PersistenceContext
	private EntityManager entityManager;

	@PostConstruct
	public void findDatabaseTableNames() {
		List<Object[]> tableInfos = entityManager.createNativeQuery("SHOW TABLES").getResultList();
		for (Object[] tableInfo : tableInfos) {
			String tableName = (String) tableInfo[0];
			tableNames.add(tableName);
		}
	}

	@Transactional
	public void clear() {
		entityManager.clear();
		truncate();
	}

	private void truncate() {
		entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, 0)).executeUpdate();
		for (String tableName : tableNames) {
			entityManager.createNativeQuery(String.format(TRUNCATE_FORMAT, tableName)).executeUpdate();
		}
		entityManager.createNativeQuery(String.format(FOREIGN_KEY_CHECK_FORMAT, 1)).executeUpdate();
	}

}

DataCleaner는 데이터 삭제 지원을 위한 클래스입니다. 엔티티 매니저와 네이티브 쿼리를 사용해 직접 데이터를 삭제합니다.

  • findDatabaseTableNames(): 테스트 애플리케이션이 띄워질 때 테이블들(엔티티들)의 이름을 저장해놓습니다.
  • clear(): 엔티티 매니저의 clear()함수를 호출해 영속성 상태를 준영속성(엔티티들이 영속성 컨텍스트에 저장되었다가 분리된 상태)으로 만들어줍니다.
  • truncate(): h2 database의 native query를 통해 메모리 DB에 들어있는 데이터들을 직접 삭제해줍니다.

사용 권장 E2E 테스트 시 ControllerTest클래스를 상속받아서 사용하도록 합니다.


✌🏻 Data Maker

🫠 구조

도메인별 Data Maker 객체 구조는 다음과 같습니다.

@Component
public class CenterDataMaker {

	// 도메인 Repository (필요한 Repository)
	@Autowired
	private CenterRepository centerRepository;

	// 필요한 데이터 Repository 함수들
	public Center save() {
		Center center = getSaveEntity();
		return centerRepository.save(center);
	}

	// ...

	// 필요한 데이터 도우미 함수들 (주로 static 함수)
	public static Center getSaveEntity() {
		return new Center("", "", "");
	}

	// ...
}

🫥 사용

객체 사용안은 다음과 같습니다.

  1. 미리 저장해야할 데이터 저장시키기
  2. 길어지는 데이터 객체 대신 생성해주기
@TestInstance(PER_CLASS)
public class CenterControllerTest extends ControllerTest {

  @Autowired
	private CenterDataMaker centerDataMaker;

	@Test
	void saveCenter() throws Exception {
		// given
		CenterSaveRequest request = **CenterDataMaker.getSaveRequest()**;

		// when & then
		mockMvc.perform(post("/api/center"))
				.contentType(MediaType.APPLICATION_JSON)
				.content(objectMapper.writeValueAsString(request))
			.andExpect(status().isCreated());
	}
	
	@Test
	void findList() throws Exception {
		// given
		List<Center> centers = **centerDataMaker.saveAll(3)**;

		// when & then
		mockMvc.perform(get("/api/center"))
			.andExpect(status().isOk());
	}

}