Testing

2024. 1. 24. 20:54
728x90

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);

    // 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가 생활화된 개발자가 되자

 

 

728x90

BELATED ARTICLES

more