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

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