Spring/Test

[Test] SpringBoot 이용하여 단위테스트(UnitTest) 기초 이해하기

hyeon.q 2024. 9. 20. 00:47
728x90

 

들어가며


평소 통합 테스트 위주로 작성을하였고, 복잡한 비즈니스를 매번 엔드포인트 단위로 테스트를 하는것에 지쳐
단위 테스트를 연습하여 점점 확장되는 복잡한 비즈니스에도 사소한 에러를 발생시키지 않게 하기 위해 공부를 해보았다.

 

 

 

[UnitTest]

  • 단위 테스트는 개발 초기 단계에서 버그를 발견하고 수정하는데 도움을 준다.
  • 단위 테스트는 코드의 리팩토링 과정에서 중요하다.
    • 기존의 테스트 케이스를 통해 리팩토링 후 기능이 올바르게 동작하는지 체크할 수 있기 때문이다.

 

단위 테스트는 Repository -> Service -> Controller 순으로 테스트를 할 것이다

 

 

말 그대로 단위 테스트는 단위(=Unit) 별로 테스트를 하는 것이다.

즉 1개의 메소드를 작성하면 그 메소드가 올바르게 동작하는지 확인하는 테스트 입니다.

 

통합 테스트의 경우는 중간 과정은 생략하고 API 요청/응답을 기반으로 올바른 응답이 오는지만 테스트를 했다면,

단위 테스트는 API 요청에 모든 과정을 테스트하는 것이다.

 

처음에는 정말 귀찮고 힘들겠지만, 점진적으로 발전해나가는 비즈니스 복잡성을 최소화 하기 위해서는 위 방법이 제일 좋다고 판단하였다. 

 

 

[실행환경]

  • Java 21
  • SpringBoot 3.3
  • PostgreSQL
  • H2
  • Spring Data JPA
  • Hibernate 6.1
  • Gradle

 


 

 

[Repository Layer 에서 단위 테스트 하기]

* 모든 테스트는 다른 테스트에 독립적으로 작동해야 하며, 영향을 받지 않도록 설계를 해야 한다.

  • given / when / then 으로 구분하여 테스트를 하자.
    • AAA 라고도 불린다 Arrange / Act / Assert
      • 또는 BDD 행위 중심 개발 이라고도 불린다.
  • Repository Layer 에서는 100% 테스트 커버리지를 만들 필요는 없다.

* 단위 테스트의 이름을 정하는 것은 중요하다

  • 단위 테스트 이름은 일관성있게 짓는 것이 중요하다.
    • 아니면 귀찮게 @DisplayName() 으로 설명해야 한다.

 

[예시]

Ex) 클래스이름_수행할기능_기대할내용() {}

PokemonRepository_saveAll_ReturnPokemon() {}

 

Spring Data JPA 를 사용한다는 가정하에 Repository 는 JpaRepository<Entity, Object> 를 가진다

JpaRepository 는 이미 추상화가 잘되어있고, 기능상 문제가 없다. 그러므로 단위 테스트를 깊게 작성할 필요는 없다

이미 충분히 검증된 메소드 들이기 때문이다.

 

 

[기본 JPA 사용 단위 테스트 예제]

@DataJpaTest
@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) // h2 DB 사용하기 위함
class PokemonRepositoryUnitTest {
    @Autowired
    private PokemonRepository pokemonRepository;

    @BeforeEach
    public void setup() {
        System.out.println("UnitTest Setup");
    }

    @Test
    void PokemonRepository_SaveAll() {
        // given -> Builder 사용 권장
        Pokemon pokemon = Pokemon.builder()
            .name("피카츄")
            .type("전기")
            .build();

        // when
        Pokemon savedPokemon = pokemonRepository.save(pokemon);

        // then
        Assertions.assertThat(savedPokemon).isNotNull();
        Assertions.assertThat(savedPokemon.getId()).isGreaterThan(0);
    }

}

 

  • 기본적으로 given/when/then 으로 구문을 나눠 작성을 하였다.
  • 위 방식이 대표적인 테스트 방법이다. 
    • 단위 테스트 뿐만 아니라 내가 작성했던 대부분에 테스트 코드 들에서는 given/when/then 으로 나눠서 작성을 하였다.

 

 

 

[Custom Query UnitTest]

    @Test
    void PokemonRepository_FindByType_ReturnsOnePokemonNutNull() {
        // given
        Pokemon pokemon = Pokemon.builder()
            .name("꼬마돌")
            .type("돌덩이")
            .build();

        // when
        pokemonRepository.save(pokemon);

        Pokemon pokemon1 = pokemonRepository.findByType(pokemon.getType()).get();

        Pokemon updatedPokemon = pokemonRepository.save(
            Pokemon.builder()
                .name("진화돌")
                .type("강력한돌")
                .build()
        );

        // then
        Assertions.assertThat(pokemon1).isNotNull();
        Assertions.assertThat(pokemon1.getType()).isEqualTo("돌덩이");
        Assertions.assertThat(updatedPokemon.getType()).isEqualTo("강력한돌");
    }

 

나머지 CRUD 테스트코드 들은 Git 을 참고하길 바란다.

 

 

 

[Mockito 알아보기]


Mocking 은 모의 객체를 만들어 테스트 하는 것이다.

모의 객체 = 가짜 객체 랑 같은 뜻으로 이해하면 된다.

 

테스트에서 직접적으로 DB 에 접근하는건 좋지 않다.

그러므로 Mock 객체를 사용하는 것이다

 

  • Mockito는 실제 데이터베이스에 접근하지 않고, DB와 상호작용하는 객체(예: Repository, Service)를 모킹(mocking)하는 데 사용된다.
  • 모킹은 단위 테스트에서 해당 객체의 동작을 흉내내어 특정 행동을 지정할 수 있도록 하여, 실제 DB 접근 없이 비즈니스 로직을 테스트할 수 있게 해줍니다.


Mockito 는 외부 의존성을 분리하고, 독립적으로 비즈니스 로직을 테스트할 때 유용하다
실제로 DB 에 연결되어 있지 않아도 save() 가 실행이 되도 특정 값을 반환할 수 있다

 

 

[Mockito 예제]

@ExtendWith(MockitoExtension.class) // Mock 객체 확장하기 위함
public class PokemonServiceUnitTest {

    @Mock // Mock 객체 선언
    private PokemonRepository pokemonRepository;

    @InjectMocks
    private PokemonServiceImpl pokemonService;

    @Test
    void PokemonService_CreatePokemon_ReturnsPokemonDTO () {
        // given
        Pokemon pokemon = Pokemon.builder()
            .id(1L)
            .name("피카츄")
            .type("전기")
            .build();

        PokemonDto pokemonDto = PokemonDto.builder()
            .name("피카츄")
            .type("전기")
            .build();

        // when
        // DB 에 접근하지 않고, Mock 을 이용해서 한다.
        // Mock 을 하는 대상은 실제로 DB 에 접근하려는 대상을 Mocking 한다??
        Mockito.when(pokemonRepository.save(Mockito.any(Pokemon.class))).thenReturn(pokemon);
        PokemonDto savedPokemonDto = pokemonService.createPokemon(pokemonDto);

        // then
        Assertions.assertThat(savedPokemonDto).isNotNull();
        Assertions.assertThat(savedPokemonDto.id()).isGreaterThan(0L);
    }

}

 

 

[Service Layer 에서 단위 테스트 하기]

 

[예시]

    @Test
    void PokemonService_GetPokemonById_ReturnsPokemonDTO () {
        // given
        Pokemon pokemon = Pokemon.builder()
            .name("피카츄")
            .type("전기")
            .build();

        PokemonDto pokemonDto = PokemonDto.builder()
            .name("피카츄")
            .type("전기")
            .build();

        // when
        Mockito.when(pokemonRepository.findById(1L)).thenReturn(Optional.ofNullable(pokemon));
        PokemonDto savedPokemonDto = pokemonService.getPokemonById(1L);

        // then
        Assertions.assertThat(savedPokemonDto).isNotNull();
    }

 

 

[예시2]

@Mock
private ReviewRepository reviewRepository;
@Mock
private PokemonRepository pokemonRepository;

@InjectMocks
private ReviewServiceImpl reviewService;

private Pokemon pokemon;
private Review review;
private ReviewDto reviewDto;
private PokemonDto pokemonDto;

@Test
void ReviewService_UpdatePokemon_ReturnReviewDTO() {
  // given
  long pokemonId = 1L;
  long reviewId = 1L;

  pokemon.addReview(review);
  review.associatedWithPokemon(pokemon);

  // when
  Mockito.when(pokemonRepository.findById(pokemonId)).thenReturn(Optional.of(pokemon));
  Mockito.when(reviewRepository.findById(reviewId)).thenReturn(Optional.of(review));
  Mockito.lenient().when(reviewRepository.save(review)).thenReturn(review);

  ReviewDto updateReturn = reviewService.updateReview(pokemonId,reviewId,reviewDto);

  // then
  Assertions.assertThat(updateReturn).isNotNull();
}

 

 

혹시라도 Mockito Strict Stubbing UnnecessaryStubbingException 에러를 만났다면

 Mockito.lenient().when(reviewRepository.save(Mockito.any(Review.class))).thenReturn(review);

 

lenient() 메소드를 통해, 엄격한 Mockito 테스트를 느슨하게 만들어주면 된다.

 

위 메소드 사용시 Mockito 가 테스트 실행 중에 실현되지 않는 코드들을 느슨하게 만들어 에러를 발생시키지 않는다.

즉 위 오류가 나는 이유는 테스트 실행 중에 작성해둔 모든 코드들이 실행되야 하는데, 실행되지 않는 코드들이 생겨서 이다.

 

 

[MockMvc]

Controller 에서 엔드포인트(=API) 를 테스트하기 위해서 가짜 객체를 MockMvc 를 사용할 수 있다.

 

 

(예제)

@WebMvcTest(controllers = PokemonController.class) // controller 를 테스트하기 위한 어노테이션
@AutoConfigureMockMvc(addFilters = false) // 자동 mock 추가, Spring Security 우회 하기 위한 설정
@ExtendWith(MockitoExtension.class) // Mockito 확장
class PokemonControllerTest {
	@Autowired
	private MockMvc mockMvc;

	@MockBean
	private PokemonService pokemonService;

	@Autowired
	private ObjectMapper objectMapper;

	private Pokemon pokemon;
	private Review review;
	private ReviewDto reviewDto;
	private PokemonDto pokemonDto;

	@BeforeEach
	public void setup () {
		pokemon = Pokemon.builder().name("라이츄").type("강화전기").build();
		pokemonDto = PokemonDto.builder().name("라이츄").type("강화전기").build();

		review = Review.builder().title("무슨제목?").content("무슨내용?").stars(0).build();
		reviewDto = ReviewDto.builder().title("무슨제목?").content("무슨내용?").stars(0).build();
	}

	@Test
	void PokemonController_CreatePokemon_Return_ () throws Exception {
    		// given
      		// when
		BDDMockito.given(pokemonService.createPokemon(ArgumentMatchers.any()))
			.willAnswer(invocation -> invocation.getArgument(0));

		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.post("/api/pokemon/create")
			.contentType(MediaType.APPLICATION_JSON)
			.content(objectMapper.writeValueAsString(pokemonDto)));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isCreated())
			.andExpect(MockMvcResultMatchers.jsonPath("$.name", CoreMatchers.is(pokemonDto.name())))
			.andExpect(MockMvcResultMatchers.jsonPath("$.type", CoreMatchers.is(pokemonDto.type())))      
     			.andDo(MockMvcResultHandlers.print());
		
	}

}

 

MockMvc 를 사용한 기본이 되는 코드이므로, 위 흐름을 잘 기억하도록 하자.

필자도 기억이 안날 때 마다 내 연습 코드를 보며 다시 되새기고는 한다.

 

 

[Controller Layer 에서 단위 테스트 하기]

 

(예제1 - 기본 코드 - 조회(페이징) API )

	@GetMapping("/api/pokemon")
	public ResponseEntity<PokemonPageableResponse> getPokemons(
		@RequestParam(value = "pageNo", defaultValue = "0", required = false) int pageNo,
		@RequestParam(value = "pageSize", defaultValue = "10", required = false) int pageSize
	) {
		return new ResponseEntity<>(pokemonService.getAllPokemon(pageNo, pageSize), HttpStatus.OK);
	}
@Builder
public record PokemonPageableResponse(
	List<PokemonDto> content,
	int pageNo,
	int pageSize,
	long totalElements,
	int totalPages,
	boolean last) {
}

 

 

(예제1- 테스트 코드)

	@Test
	void pokemonController_GetAllPokemon_ReturnResponseDTO () throws Exception {
		// given
		PokemonPageableResponse response = PokemonPageableResponse.builder()
			.pageSize(10)
			.pageNo(1)
			.last(true)
			.content(Collections.singletonList(pokemonDto))
			.build();

		// when
		Mockito.when(pokemonService.getAllPokemon(1, 10)).thenReturn(response);

		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/pokemon")
			.contentType(MediaType.APPLICATION_JSON)
			.param("pageNo", "1")
			.param("pageSize", "10"));

		// then
		resultActions.andExpect(MockMvcResultMatchers.status().isOk())
			.andExpect(MockMvcResultMatchers.jsonPath("$.content.size()", CoreMatchers.is(response.content().size())));
		System.out.println("/api/pokemon/" + "테스트 성공");
	}

 

페이징 하는 API 를 테스트 해보는 코드 입니다.

 

 

(예제2 - 기본 코드 - 연관관계 걸린 엔티티)

 

	@GetMapping("/api/pokemon/{pokemonId}/reviews")
	public List<ReviewDto> getReviewsByPokemonId(@PathVariable(value = "pokemonId") long pokemonId) {
		return reviewService.getReviewsByPokemonId(pokemonId);
	}

 

 

(예제2 - 테스트 코드)

	@Test
	void ReviewController_GetReviewsByPokemonId_ReturnReviewDTO() throws Exception {
		long pokemonID = 1L;

		Mockito.when(reviewService.getReviewsByPokemonId(pokemonID)).thenReturn(Collections.singletonList(reviewDto));

		ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/pokemon/1/reviews")
			.contentType(MediaType.APPLICATION_JSON)
			.content(objectMapper.writeValueAsString(pokemonDto)));

		resultActions.andExpect(MockMvcResultMatchers.status().isOk())
			.andExpect(MockMvcResultMatchers.jsonPath("$.size()",CoreMatchers.is(Collections.singletonList(reviewDto).size())));
	}

 

 

 

결론


[단위 테스트의 Best Practice]

  • 테스트 코드도 유지보수가 필요한 소프트웨어의 일부로 간주해야 한다 -> 이는 테스트 코드의 가독성과 유지보수성을 높이는데 중요하다
  • 테스트를 자주 실행해야 한다 -> CI 를 통해 자동화도 하며, 코드 변경 사항이 테스트를 통과하지 못할 경우 즉시 피드백을 받을 수 있다.
  • 테스트 커버리지를 모니터링 하는 것도 중요하다. -> High coverage 는 코드의 품질을 향상시키는데 도움이 된다.
  • 테스트 코드에 대한 리뷰 -> 테스트 코드 품질 향상 및 팀 내에서 테스트에 대한 공통된 이해를 구축하는데 기여한다.

 

Ref

https://www.youtube.com/watch?v=jqwZthuBmZY&list=PL82C6-O4XrHcg8sNwpoDDhcxUCbFy855E

 

728x90