Testing
1. 소프트웨어 개발의 테스트
- 단위 테스트 : Controller, Service, Repository의 개별 메서드
- 통합 테스트 : Controller부터 Repository까지 이어진 기능.
~> 이들을 자동화하기 위한 테스트 코드를 작성할 수 있다.
장점 | - 잘못된 방향의 개발을 막는다 (이전에 작동하던 기능이 작동하지 않는 상황 등) - 전체 코드 품질이 상승 - 최종적으로 오류 상황에 대한 대처가 좋아져 전체적인 개발 시간이 줄어든다. |
단점 | - 코드 작성 = 개발 시간이 늘어난다. - 테스트코드 유지 보수가 필요하여 유지보수 비용이 늘어난다 - 테스트 작성법을 따로 배워야 한다. |
~~> 테스트 코드를 언제 써야할지, 언제 쓰지 말지 상황에 따라 판단하며 테스트 코드를 짜자. (변화가 큰 상황이라면 상황에 따라 테스트 코드를 짜지 않는 것이 나을 수도 있다...)
- 테스트 도구
- JUnit : 사싱상의 Java 어플리케이션 Testing 표준 라이브러리
- Spring Test : Spring 어플리케이션 Test 지원 라이브러리
- AssertJ : 가독성 높은 테스트 작성을 위한 라이브러리
- Hamcrest : Test 진행시 제약사항 설정을 위한 라이브러리
- Mockito : Test용 Mock 라이브러리
- JSONassert : JSON용 Assertion 라이브러리
- JsonPath : JSON 데이터 확인용 라이브러리...
- 주로 초기 단계 개발/테스트에서 많이 사용하는 데이터베이스 : H2
// build.gradle
runtimeOnly 'com.h2database:h2'
// application.yaml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password: password
jpa:
database: h2
database-platform: org.hibernate.dialect.H2Dialect
2. Repository, Service 단위 테스트
1) Repository 단위 테스트
- @DataJpaTest : SpringBoot에서 JPA 단위 테스트를 위해 제공하는 기능.
- @Autowired : 의존성 주입.
- given - when - then 패턴 적용.
- given : 테스트를 진행하기 위한 기본 조건을 만들어 두는 부분.
- when : 실제로 테스트를 진행하는 부분.
- then : when의 결과가 기대한대로 반환되었는지 검증.
1) 사용자추가 : userRepository.save();
2) 사용자_추가_실패 : username 중복시 save() 불가
3) 사용자_이름으로_조회 : userRepository.findByUsername(String username); User가 존재하고, username이 같아야한다.
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
// UserRepository의 단위 테스트를 위한 테스트들
public class UserRepositoryTests {
@Autowired //필드기반 DI
private UserRepository userRepository;
// 사용자를 추가하는 테스트
@Test
public void 사용자추가() {
//given
// 내가 만들고자 하는 User 엔티티가 있는 상황에서.
String username = "hehesim";
User user = new User(username, null, null, null);
//when
// userRepository.save(user)를 진행한다.
User result = userRepository.save(user);
//then
// userRepository.save()의 결과의 username이 본래 User의 username과 일치했는지
// + userRepository.save()의 결과의 id가 null이 아닌지.
assertEquals(result.getUsername(), username);
assertNotNull(result.getId());
}
// 사용자를 추가하는데 실패하는 테스트
// 중복된 사용자이름을 추가하는데 실패해야 한다.
@Test
public void 사용자_추가_실패() {
// given
String username = "hehesim";
User user = new User(username, null, null, null);
user = userRepository.save(user);
// when
User newUser = new User(username, null, null, null);
// then
assertThrows(Exception.class, () -> userRepository.save(newUser));
}
// 사용자를 조회하는 테스트
@Test
public void 사용자_이름으로_조회 () {
String username = "hehesim";
// given
// 내가 읽고자 하는 특정 username의 데이터가 DB에 저장된 이후의 상황
User user = new User(username, null, null, null);
userRepository.save(user);
// when
// 해당 username을 가지고 결과를 받아오면
Optional<User> foundUser = userRepository.findByUsername(username);
//then
// 돌아온 결과 Optional.isPresent() == true이고, (assertTrue)
// 돌아온 결과 Optional.get().getUsername == username이다.
assertTrue(foundUser.isPresent());
assertEquals(foundUser.get().getUsername(), username);
}
}
테스트 성공...
** 사용자_추가_실패 코드는 >> throw Exception을 해야 성공하는 것!!! 즉 같은 이름으로 저장이 되지 않아 Exception Throw가 이루어져야지 제대로 만들어진 코드라는 뜻.
++ 추가 테스트 코드 연습
4) 없는_이름으로_조회 : userRepository.findByUsername(String username); 없는 username인 경우 User 존재X
5) 존재하는지_조회 : userRepository.existsByUsername(String username); 존재하는 username일 경우 존재/ 존재하지 않는 username일 경우 존재X
6) id로_삭제: userRepository.deleteById(Long id); id를 가지고 User 삭제하면 존재X
4) username으로 찾기 실패
// 존재하지 않는 Username을 가지고 조회하면 ?
@Test
public void 없는_이름으로_조회() {
//given
String username = "hehesim";
User user = new User(username, null, null, null);
userRepository.save(user);
// when
String newUsername = "hehe";
Optional<User> foundUser = userRepository.findByUsername(newUsername);
//then
// assertFalse(foundUser.isPresent());
assertTrue(foundUser.isEmpty());
}
5) username으로 존재하는지 확인
// 이미 존재하는 username을 검색해서 존재하는지 확인.
@Test
public void 존재하는지_조회() {
//given
String username = "hehesim";
String usernameNotExists = "hehe";
User user = new User(username, null, null, null);
userRepository.save(user);
//when
boolean result1 = userRepository.existsByUsername(username);
boolean result2 = userRepository.existsByUsername(usernameNotExists);
//then
assertTrue(result1);
assertFalse(result2);
}
6) id로 user 삭제
// id를 가지고 User를 삭제한다.
@Test
public void id로_삭제() {
//given
String username = "hehesim";
User user = new User(username, null, null, null);
user = userRepository.save(user);
Long id = user.getId();
//when
userRepository.deleteById(id);
//then
boolean result = userRepository.existsById(id);
assertFalse(result);
}
2) Service 단위 테스트
: 이때 중요한 것은 UserRepository가 있어야 정상적으로 작동하지만,,,, UserRepository는 제대로 작동한다고 가정하고 UserService를 테스트 할 것이라는 것!!!!
>> 이 때 격리해서 테스트를 하기 위해 임시 객체인 Mock 객체를 필요로 한다.
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository repository;
1) @ExtendWith(MockitoExtension.class)
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {}
2) @Mock 객체 설정 : 실제 UserRepository가 아니라 Mock 객체로 설정해주는 것.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
}
3) @InjectMocks : 실제 UserRepository가 아닌 위에 만든 Mock Repository를 의존성으로 사용하겠다.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
}
>> 이렇게 작성하면 UserService.userRepository 필드에는 실제 데이터베이스와 소통하는 찐 UserRepository가 아닌 가짜 UserRepository가 할당된다!
이 Mock 객체는 찐UserRepository의 메서드 정의는 다 갖고 있지만,,,,, 동작하는 부분은 ? 없다!!!!!!
그래서 어떤 특정 인자를 받게될 경우 어떤식으로 동작할지를 구현해주어야 한다.
=>
1) UserDto를 인자로 받아 User 생성 (메서드 : userService.create(UserDto dto))
여기서 멘붕이 오는디...ㅎ ㅎ ㅎ ㅎ
//다른 것들은 잘 동작한다고 가정.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
// UserDto를 인자로 받아 User를 생성
@Test
public void Dto로_사용자_생성() {
//given
//1. userRepository가 특정 User를 전달받을 것을 가정한다.
String username = "hehesim";
// userRepository가 입력받을 user
User userIn = new User(username, null, null, null);
// 2. userRepository가 반환할 user
User userOut = new User(username, null, null, null);
userOut.setId(1L);
// 3. userRepository.save(userIn)의 결과를 userOut으로 설정
// Mock 객체의 행동 정의...내부적으로 save하면 id가 주어지며 저장이된다.
when(userRepository.save(userIn)).thenReturn(userOut);
//when - userDto 전달된다.
UserDto userDto = new UserDto(null, username, null, null, null, null);
UserDto result = userService.create(userDto);
//then
// username이 입력한 것과 같은지 확인.
assertEquals(username, result.getUsername());
// id가 실제와 같은지 확인.
assertEquals(userId, result.getId());
}
}
일단 현재 이해한 바로는...
given 부분에서 UserRepository 즉 Mock 객체의 기능을 정의하는 것이다.
와 진짜 이해가 아직은 잘 안되는데
일단은 들어가는 User 객체에는 id가 지정이 안된 상황이고, 얘를 userRepository.save 했을 때 반환은 모든 데이터가 정확히 들어가며, id가 지정된 User 객체가 반환이 된다는 상황을 정의해둔 것이다. //given..이런 상황일때!!!!
서비스단에서 UserDto를 받아서, create하면 > 반환이 UserDto로 된다. (result)
그래서 그 리턴이 입력한 값과 같은지를 테스트하면 된다는 것!!
음 여기서 의문이
지금은 간소화하여 나머지 필드들을 null로 설정한 것인데 만약 전체 필드 다 채워서 들어간다면...?
그렇다면 username 뿐만 아니라 모두 확인하는 코드를 짜야하는지요? 그렇겠지...?
거기다 유효성 검증에서도 걸리는지 안걸리는지 그런 것들을 모두 테스트 코드를 짜야하는건지요...?
첫 발을 들였는데 테스트 코드의 세계가 엄청 깊다는 느낌이 강하다 ㅠㅠ흑 일단 해보자 뭐든!!
++ 추가 테스트 코드 연습
1) userService.updateUser(Long id, UserDto dto);
@Test
@DisplayName("UserDto를 이용해 User 수정")
public void testUpdateUser() {
// given
User existingUser = new User("hehesim", "email", "phone", "bio");
existingUser.setId(1L);
// findById했을 때 > existingUser Optional 객체 반환.
when(userRepository.findById(existingUser.getId())).thenReturn(Optional.of(existingUser));
when(userRepository.save(any(User.class))).thenReturn(existingUser); // 명시적인 반환값 설정
// when
// Dto를 설정해주었다.
UserDto updateDto = new UserDto();
updateDto.setPhone("pphone");
updateDto.setBio("bbio");
// existingUser에 업데이트하기
UserDto updated = userService.updateUser(existingUser.getId(), updateDto);
// then
assertEquals(existingUser.getId(), updated.getId());
assertEquals("pphone", updated.getPhone());
assertEquals("bbio", updated.getBio());
}
2) userService.readUserByUsername(String username);
// username으로 UserDto 반환 readUserByUsername
@Test
@DisplayName("username으로 해당 UserDto 반환")
public void testReadUserByUsername() {
//given
String username = "hehesim";
User user = new User(username, "email", "phone", "bio");
when(userRepository.findByUsername(username)).thenReturn(Optional.of(user));
//when
UserDto foundUserDto = userService.readUserByUsername(username);
//then
assertEquals(user.getUsername(), foundUserDto.getUsername());
}
3) userService.updateUserAvatar(Long id, MultipartFile file);
![](https://blog.kakaocdn.net/dn/caTcWq/btsDUSbQl58/haKG2o2W7pbl6n6Wfg6yrk/img.png)
// User에 이미지파일 추가
@Test
@DisplayName("id로 찾고 해당 유저의 Avatar에 이미지 경로 추가")
public void testUpdateUserAvatar() {
//given
// 유저아이디를 가진 유저 객체가 있다는 뜻
Long userId = 1L;
User user = new User();
user.setId(userId);
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userRepository.save(any(User.class))).thenReturn(user);
//when
MultipartFile file = new MockMultipartFile("filename",
"originalFilename.png",
MediaType.IMAGE_PNG_VALUE,
"byte".getBytes());
UserDto updateAvatar = userService.updateUserAvatar(userId, file);
//then
assertEquals(userId, updateAvatar.getId());
assertEquals(String.format("/static/%d/profile.png", userId), updateAvatar.getAvatar());
}
3. Controller 단위 테스트
1) 똑같이 Controller도 세가지 단계 과정.
- @ExtendsWith(MockitoExtension.class)
- @Mock
- @InjectMocks
@ExtendWith(MockitoExtension.class)
public class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
}
2) MockMvc
Controller는 테스트 하기 위해서 HTTP 요청이 필요하다 !! > 이 또한 Mock으로 MockMvc 사용한다.
각 테스트 이전에 실행할 것으로 설정한다. (@BeforeEach)
- private MockMvc mockMvc 객체 필드설정
- mockMvc 객체 초기화해주기. >> MockMvcBuilders.standaloneSetup(userController).build()
== userController를 테스트하기 위한 엔드포인트만 설정한 서버를 Mock한다는 뜻!!
@ExtendWith(MockitoExtension.class)
public class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
private MockMvc mockMvc;
@BeforeEach
public void beforeEach() {
mockMvc = MockMvcBuilders
.standaloneSetup(userController)
.build();
}
}
3) JSON 문자열 변환기
JSON 데이터 처리!!!!
요청을 보낼 때 JSON 데이터를 보내기 위해서 간단한 객체를 만들어주었다. (테스트들이 있는 패키지에 만듦)
// JsonUtil.class
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class JsonUtil {
static byte[] toJson(Object object) throws IOException {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper.writeValueAsBytes(object);
}
}
4) given - when - then
- given : 일단 요청Dto로 서비스의 create메서드에 전달할 시 responseDto로 반환하도록 설정한다. (즉, 서비스의 내부에 문제가 없다고 가정하고서 진행하기 위한 설정이다.) id가 없이 보냈지만 반환은 id까지 입력된 채로 나온다.
- when : mockMvc를 이용해 요청을 보낸 상황을 만든다.
- mockMvc.perform() : HTTP 요청을 보냈다고 가정한다. 전달 인자로 요청의 세부사항을 조정한다.
- post() : 인자로 url을 정의해줌.
- 빌더패턴 형식으로 .content() : Request Body를 정의 => 우리는 요청Dto를 JSON타입으로 바꿔준 것!
- .contentType() : Content-Type 헤더를 정의. (우리는 JSON 타입)
- then : 이제 위 요청을 보냈을 때 어떤 결과가 나오는지를 가정한다.
- .andExpectAll() : 여러 assert 상황을 가정할 수 있음.
- status() : 응답 상태 코드 검증
- content().contentType() : 응답의 Content-Type 확인 (우리는 JSON)
- jsonPath() : JsonPath 형식으로 응답받은 JSON 데이터의 내용물이 기대한대로 응답되었는지를 확인! (여기서는 보낸 username이 응답으로도 같은지 확인 & id가 채워진 채로 저장이 되어야 하므로!! id가 null이 아닌지를 확인)
// UserDto 전송하여 UserDto 받기
@Test
public void UserDto_JSON요청을_보내면_UserDto_JSON으로_응답() throws Exception {
// given
String username = "hehesim";
UserDto requestDto = new UserDto(null, username, null, null, null, null);
UserDto responseDto = new UserDto(1L, username, null, null, null, null);
when(userService.create(requestDto))
.thenReturn(responseDto);
// when
ResultActions result = mockMvc.perform(
post("/user")
// 이 요청의 Body는 requestDto를 JSON으로 변환한 것.
.content(JsonUtil.toJson(requestDto))
// 이 요청의 Body는 JSON이라고 생각하고 이해할 것.
.contentType(MediaType.APPLICATION_JSON)
);
// then
// 응답의 코드가 2xx
// 내용이 Json 형식
// username이 변화하지 않았다.
// id가 null이 아니다.
result.andExpectAll(
status().is2xxSuccessful(),
content().contentType(MediaType.APPLICATION_JSON),
jsonPath("$.username", is(username)),
jsonPath("$.id", notNullValue()));
}
4. Controller 통합 테스트
- 통합 테스트는 일단은 하나만 해보았다... ㅠ ㅠ ㅠ 왜케 어렵니!!!!!
테스트 코드 정복하는 그날까지!!!!!!
- 통합 테스트에서는 Controller - Service - Repository 모두 거쳐 개발 기능이 정상적으로 작동하는지를 테스트하므로! Mock말고 진짜로 Repository를 끌고온다....
- @SpringBootTest(classes = 실제어플리케이션.class) : 통합 테스트임을 표시한다.
- @AutoConfigureMockMvc : 이전에 빌드해줬던 것을 자동으로 Bean 객체로 만들어준다!
- @Autowired : UserRepository 주입받아 활용. (실제 JpaRepository 객체임!!!!)
- 진행 후 마지막에 userRepository에 User가 정상적으로 작성 되었는지 확인.
package com.example.contents;
import com.example.contents.dto.UserDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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 static org.hamcrest.CoreMatchers.is;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(classes = ContentsApplication.class)
@AutoConfigureMockMvc
public class UserControllerIntegrationTests {
@Autowired
private MockMvc mockMvc;
@Autowired(required = false)
private UserRepository userRepository;
@Test
@DisplayName("User 생성 통합 테스트")
public void testCreateUserIntegrated() throws Exception {
String username = "hehesim";
String email = "email";
String bio = "biooo";
String phone = "phone";
UserDto dto = new UserDto(null, username, email, phone, bio, null);
mockMvc.perform(
post("/user")
.content(JsonUtil.toJson(dto))
.contentType(MediaType.APPLICATION_JSON))
.andExpectAll(
status().is2xxSuccessful(),
content().contentType(MediaType.APPLICATION_JSON),
jsonPath("$.username", is(username)),
jsonPath("$.email", is(email)),
jsonPath("$.bio", is(bio))
);
assertTrue(userRepository.existsByUsername(username));
}
}
계속 맨 위에 @SpringBootTest(classes = ContentsApplicationTests.class)로 했다가 오류 나고..ㅋ.ㅋㅋㅋ.ㅋ.ㅋ. 바보~~~
실제 어플리케이션 클래스로 설정해주셔야 합니다용
테스트 코드 작성 뿌셔~~! TDD가 생활화된 개발자가 되자
'Programming > Spring, SpringBoot' 카테고리의 다른 글
CustomUserDetailsManager과 CustomUserDetails 만들기 (1) | 2024.01.26 |
---|---|
Spring Security... 그리고 인터페이스 (0) | 2024.01.25 |
Exception & Validation (0) | 2024.01.23 |
File Handling (정적파일과 Multipart/form-data) (0) | 2024.01.22 |
Query Parameter (0) | 2024.01.22 |