본문 바로가기

interviewPrep 프로젝트

단위 테스트 및 인수 테스트 작성

이 글에서는 단위 테스트 작성에 대해서 다루겠습니다.

 

 

글의 목차는 다음과 같습니다.

1) 단위 테스트와 인수 테스트

2) 단위 테스트 및 인수 테스트 작성하기 

 

 

 

1) 단위 테스트와 인수 테스트

 

(1) 단위 테스트

- 단위 테스트는 일반적으로 비즈니스 로직의 기본 단위인 메소드를 대상으로 작성되는 테스트를

  의미합니다.  단위 테스트를 통해서 내가 작성한 비즈니스 로직이 정확하고 안정적으로 동작함을 

  배포 전에 검증할 수 있습니다. 

  또한, 단위 테스트 개발은 TDD와 결합되어 사용되기도 합니다. TDD는 Test-Driven-Development

  의 줄임말로, 우리나라 말로는 테스트 주도 개발이라고 합니다. TDD는 테스트를 먼저 작성하고,

  그 테스트를 통과할 수 있는 비즈니스 로직을 작성하는 방식으로 개발을 진행합니다.

  단위 테스트에 TDD를 결합했을 때의 장점은, 

  (1-1) 비즈니스 로직을 테스트 가능한 단위로 단순하게 개발 가능함

  (1-2) 테스트 작성으로 예외 사항이 선제적으로 검증되므로, 비즈니스 로직을 재설계하는 시간이 감소함

  입니다. 

 

 

(2) 인수 테스트

 

- 인수 테스트는 사용자 시나리오에 맞춰 수행하는 테스트입니다. 즉, 소프트웨어가 사용자 요구사항에 맞게 

잘 동작하는지를 테스트합니다. 특정한 기능 하나에 초점을 맞추는 단위 테스트와 비교할 때, 인수 테스트는 

통합 테스트에 가깝다고 볼 수 있습니다.

스프링에서 사용하는 인수 테스트 프레임워크에는 MockMvc, RestAssured, WebTestClient 등이 있습니다. 

 

 

 

2) 단위 테스트 및 인수 테스트 작성하기

- 단위 테스트와 인수 테스트를 각각 AnswerService 클래스, AnswerController 클래스에 대해

  작성해보겠습니다. AnswerServiceTest는 Create에 대해, AnswerController 클래스는 Read에 대해 설명 드리겠습니다.  우선 AnswerServiceTest부터 설명드리겠습니다. 

 

 

(1) AnswerServiceTest

- AnswerService 클래스에 대한 테스트 클래스입니다.

  글에서는 CRUD 중 Create에 대한 테스트를 설명드리겠습니다. 

package com.example.interviewPrep.quiz.Answer.service;

import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.response.AnswerResponse;
import com.example.interviewPrep.quiz.heart.repository.HeartRepository;
import com.example.interviewPrep.quiz.jwt.service.JwtService;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.member.repository.MemberRepository;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.question.repository.QuestionRepository;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;


public class AnswerServiceTest {

    @Autowired
    private AnswerService answerService;

    private final JwtService jwtService = mock(JwtService.class);

    private final AnswerRepository answerRepository = mock(AnswerRepository.class);
    private final QuestionRepository questionRepository =  mock(QuestionRepository.class);
    private final MemberRepository memberRepository =  mock(MemberRepository.class);
    private final HeartRepository heartRepository = mock(HeartRepository.class);

    @BeforeEach
    public void setUp(){
        answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
    }


    @Test
    @DisplayName("답안 생성")
    public void createAnswer(){

        AnswerRequest answerRequest = AnswerRequest.builder()
                        .content("hello")
                        .questionId(1L)
                        .build();

        Member member = Member.builder()
                .id(1L)
                .email("abc@gmail.com")
                .name("abc")
                .password("1234")
                .build();

        Question question = Question.builder()
                   .id(1L)
                   .title("Question 1")
                   .type("java")
                   .difficulty("easy")
                   .build();

        given(jwtService.getMemberId()).willReturn(1L);
        given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
        given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));

        AnswerResponse answerResponse = answerService.createAnswer(answerRequest);

        assertThat(answerResponse.getQuestionId()).isEqualTo(1L);
        assertThat(answerResponse.getContent()).isEqualTo("hello");

    }



}

- 코드를 나눠서 살펴보겠습니다.

 

(1-1) Fixture 생성

    @Autowired
    private AnswerService answerService;

    private final JwtService jwtService = mock(JwtService.class);

    private final AnswerRepository answerRepository = mock(AnswerRepository.class);
    private final QuestionRepository questionRepository =  mock(QuestionRepository.class);
    private final MemberRepository memberRepository =  mock(MemberRepository.class);
    private final HeartRepository heartRepository = mock(HeartRepository.class);

    Answer answer;

    @BeforeEach
    public void setUp(){
        answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
    }

 

- 테스트를 위한 Fixture를 생성했습니다. 

   Fixture란, 테스트 실행을 위해 필요한 객체 혹은 데이터들을 초기화하는 것을 의미합니다. 

   Fixture를 클래스 내에서 한 번 선언해서, 여러 테스트에서 재사용할 수 있게 할 수 있고, 

   혹은 각각의 메소드마다 Fixture를 생성할 수도 있습니다. 

 

- AnswerServiceTest 클래스에서는 AnswerService의 createAnswer 메소드와 readAnswer를 테스트하므로,

   AnswerService 클래스의 객체가 필요합니다. 

   이 때, AnswerService 클래스의 객체는 @Autowired로 선언하고, AnswerService가 의존하고 있는 

    의존 객체들은 Mockito.mock()을 통해서 생성했습니다. 

 

- 객체를 모킹하는 방법에는 크게 3가지가 있습니다.

   그것은 1) Mockito.mock()  2) @Mock  3) @MockBean입니다.   

   이 중에서 @Mock은 Mockito.mock()과 같은 것으로, Mockito.mock()을 축약한 것입니다. 

   그리고 @MockBean은 스프링 컨텍스트에서 사용 가능하다는 특징을 갖고 있습니다. 

 

- AnswerService의 객체는 setUp 메소드에 선언했고, setUp 메소드에는 @BeforeEach를 추가했습니다. 

  @BeforeEach로 인해 setUp 메소드는 모든 테스트가 실행되기 전에 실행이 됩니다.

 

  

(1-2) createAnswer 테스트

package com.example.interviewPrep.quiz.Answer.service;

import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.response.AnswerResponse;
import com.example.interviewPrep.quiz.heart.repository.HeartRepository;
import com.example.interviewPrep.quiz.jwt.service.JwtService;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.member.repository.MemberRepository;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.question.repository.QuestionRepository;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;


public class AnswerServiceTest {

    @Autowired
    private AnswerService answerService;

    private final JwtService jwtService = mock(JwtService.class);

    private final AnswerRepository answerRepository = mock(AnswerRepository.class);
    private final QuestionRepository questionRepository =  mock(QuestionRepository.class);
    private final MemberRepository memberRepository =  mock(MemberRepository.class);
    private final HeartRepository heartRepository = mock(HeartRepository.class);

    @BeforeEach
    public void setUp(){
        answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
    }


    @Test
    @DisplayName("답안 생성")
    public void createAnswer(){

        AnswerRequest answerRequest = AnswerRequest.builder()
                        .content("hello")
                        .questionId(1L)
                        .build();

        Member member = Member.builder()
                .id(1L)
                .email("abc@gmail.com")
                .name("abc")
                .password("1234")
                .build();

        Question question = Question.builder()
                   .id(1L)
                   .title("Question 1")
                   .type("java")
                   .difficulty("easy")
                   .build();

        given(jwtService.getMemberId()).willReturn(1L);
        given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
        given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));

        AnswerResponse answerResponse = answerService.createAnswer(answerRequest);

        assertThat(answerResponse.getQuestionId()).isEqualTo(1L);
        assertThat(answerResponse.getContent()).isEqualTo("hello");

    }




}

 

- createAnswer 테스트를 위해 필요한 AnswerRequest 객체, Member 객체, Question 객체, Answer 객체를

  생성합니다.

  그 다음 Mockito.mock()을 통해 Mock 객체를 생성했으므로, 필요한 동작들을 Stubbing 해줍니다. 

 

given(jwtService.getMemberId()).willReturn(1L);
given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));

 

Stubbing이란 Mock 객체의 동작을 정의하는 것입니다.

   Mocking을 통해 Mock 객체를 생성한다면, 필수적으로 Stubbing을 통해 해당 Mock 객체의 동작을 

   정의해줘야 합니다. 

   왜냐하면 Mock 객체는 말 그대로 '가짜' 객체이므로, 그 동작을 정의하지 않으면 실제 객체와는 다르게

   해당 동작을 수행하지 않기 때문입니다. 

 

- Stubbing을 할 때, BDDMockito의 given 메소드를 사용했습니다.

   BDDMockito는 Mockito 클래스를 상속한 클래스이며, 

   BDDMockito의 given 메소드는 Mockito의 when 메소드와 같은 기능을 한다고 이해하면 됩니다. 

 

  

(2) AnswerReadWebControllerTest

- 다음은 인수 테스트인 AnswerReadWebControllerTest입니다.   

  인수 테스트를 위한 프레임워크로는 MockMvc를 사용했습니다. 

 

package com.example.interviewPrep.quiz.Answer.controller;

import com.example.interviewPrep.quiz.answer.controller.AnswerController;
import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.config.CustomAuthenticationEntryPoint;
import com.example.interviewPrep.quiz.exception.advice.CommonException;
import com.example.interviewPrep.quiz.filter.JwtAuthenticationFilter;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;


import java.util.Optional;

import static com.example.interviewPrep.quiz.exception.advice.ErrorCode.NOT_FOUND_ANSWER;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(AnswerController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)
public class AnswerReadWebControllerTest {
    @Autowired
    MockMvc mockMvc;

    @MockBean
    AnswerService answerService;

    @MockBean
    AnswerRepository answerRepository;

    @MockBean
    CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    
    @MockBean
    JwtAuthenticationFilter jwtAuthenticationFilter;
    @Autowired
    ObjectMapper objectMapper;
    String validJsonRequest;

    String invalidJsonRequest;


    @BeforeEach
    void setUp() throws Exception {

        AnswerRequest validAnswerRequest = AnswerRequest.builder()
                .content("new answer")
                .questionId(2L)
                .build();


        AnswerRequest invalidAnswerRequest = AnswerRequest.builder()
                .content("new answer")
                .questionId(2L)
                .build();


        validJsonRequest = objectMapper.writeValueAsString(validAnswerRequest);
        invalidJsonRequest = objectMapper.writeValueAsString(invalidAnswerRequest);
    }


    @Test
    @DisplayName("유효한 답안 읽기")
    void readValidAnswer() throws Exception{

        Member member = Member.builder()
                .id(1L)
                .email("abc@gmail.com")
                .name("abc")
                .password("1234")
                .build();

        Question question = Question.builder()
                .id(1L)
                .title("Question 1")
                .type("java")
                .difficulty("easy")
                .build();


        Answer answer = Answer.builder()
                        .content("hello")
                        .member(member)
                        .question(question)
                        .build();

        Long id = 1L;
        given(answerRepository.findById(id)).willReturn(Optional.ofNullable(answer));

        mockMvc.perform(get("/api/v1/answers/"+id))
                .andDo(print())
                .andExpect(status().isOk());

        verify(answerService).readAnswer(id);
    }


    @Test
    @DisplayName("유효하지 않은 답안 읽기")
    void readInValidAnswer() throws Exception{

        Long id = 2L;
        given(answerService.readAnswer(id)).willThrow(new CommonException(NOT_FOUND_ANSWER));

        mockMvc.perform(get("/api/v1/answers/"+id))
                .andDo(print())
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.error.errorCode").value("not_found_answer"))
                .andReturn();

    }

}

- 우선 클래스에 사용된 어노테이션들부터 하나씩 살펴보겠습니다. 

 

(2-1) 클래스 어노테이션

@WebMvcTest(AnswerController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)

 

- @WebMvcTest(AnswerController.class)는 Application Context를 시작시키지 않고, 컨트롤러 계층만 테스트하고 싶을 때 사용합니다. 여기서 AnswerController.class를 지정했으므로, AnswerController 클래스만 지정하여 컴포넌트 스캔을 하게 됩니다. 

 

- @MockBean(JpaMetamodelMappingContext.class)가 사용된 이유를 이해하려면,

   우선 메인 클래스인 InterviewPrepApplication 클래스를 살펴봐야 합니다. 

@EnableCaching
@EnableJpaAuditing
@EnableAspectJAutoProxy
@SpringBootApplication
public class InterviewPrepApplication {

    public static void main(String[] args) {
        SpringApplication.run(InterviewPrepApplication.class, args);
    }

}

 

- 메인 클래스인 InterviewPrepApplication 클래스에는 @EnableJpaAuditing이 선언되어 있습니다.

  이것 때문에 모든 테스트들이 JPA 관련 빈들을 필요로 하게 됩니다. 하지만 @WebMvcTest는 컨트롤러

  계층만 컴포넌트 스캔하고, JPA 관련 빈들을 로드하지 않으므로 문제가 발생합니다. 

 

- 따라서 @MockBean(JpaMetamodelMappingContext.class)를 통해 AnswerReadWebControllerTest에서

  JPA 관련 빈들을 Mocking하면 이 문제를 해결할 수 있습니다. 

 

- @AutoConfigureMockMvc(addFilters=False)는 Spring Web Layer에서 테스트 시 Filter도 같이 스캔하는데, Filter가 없이 테스트하기 위해 지정해 주었습니다. 

 

 

(2-2) Fixture

    @Autowired
    MockMvc mockMvc;

    @MockBean
    AnswerService answerService;

    @MockBean
    AnswerRepository answerRepository;

    @MockBean
    CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    
    @MockBean
    JwtAuthenticationFilter jwtAuthenticationFilter;
    @Autowired
    ObjectMapper objectMapper;
    
    String validJsonRequest; 

    String invalidJsonRequest;


    @BeforeEach
    void setUp() throws Exception {

        AnswerRequest validAnswerRequest = AnswerRequest.builder()
                .content("new answer")
                .questionId(2L)
                .build();


        AnswerRequest invalidAnswerRequest = AnswerRequest.builder()
                .content("new answer")
                .questionId(2L)
                .build();


        validJsonRequest = objectMapper.writeValueAsString(validAnswerRequest);
        invalidJsonRequest = objectMapper.writeValueAsString(invalidAnswerRequest);
    }

 

- MockMvc 객체를 @Autowired로 선언해줍니다. MockMvc 객체는 스프링 MVC 테스트의 메인 엔트리 포인트 역할을 합니다. 

 

- AnswerRepository, CustomAuthenticationEntryPoint, JwtAuthenticationFilter, ObjectMapper 객체를 선언합니다.

  CustomAuthenticationEntryPoint는 Authentication과 관련한 Exception을 처리하는데 필요하고,

  JwtAuthenticationFilter는 Jwt과 관련한 Filter를 Mocking하는데 필요합니다. 

  그리고 ObjectMapper는 요청 객체를 String으로 변환하는데 사용됩니다. 

 

- AnswerRequest 객체를 각각 valid한 경우와 invalid한 경우에 대해서 선언을 해주고, 

  각각을 String으로 변환합니다. 

 

 

(2-3) readValidAnswer

    @Test
    @DisplayName("유효한 답안 읽기")
    void readValidAnswer() throws Exception{

        Member member = Member.builder()
                .id(1L)
                .email("abc@gmail.com")
                .name("abc")
                .password("1234")
                .build();

        Question question = Question.builder()
                .id(1L)
                .title("Question 1")
                .type("java")
                .difficulty("easy")
                .build();


        Answer answer = Answer.builder()
                        .content("hello")
                        .member(member)
                        .question(question)
                        .build();

        Long id = 1L;
        given(answerRepository.findById(id)).willReturn(Optional.ofNullable(answer));

        mockMvc.perform(get("/api/v1/answers/"+id))
                .andDo(print())
                .andExpect(status().isOk());

        verify(answerService).readAnswer(id);
    }

 

- readValidAnswer는 유효한 답을 테스트 하기 위한 테스트 메소드입니다.

  Answer 객체 생성에 필요한 Member, Question 객체를 fixture로 만들어주고,

  AnswerService 클래스에 존재하는 answerRepository.findById를 stubbing합니다. 

 

- 그리고 /api/v1/answers에 대해 읽기 요청을 하면 200 Ok 응답이 반환됨을 확인할 수 있습니다. 

 

 

(2-4) readInValidAnswer

    @Test
    @DisplayName("유효하지 않은 답안 읽기")
    void readInValidAnswer() throws Exception{

        Long id = 2L;
        given(answerService.readAnswer(id)).willThrow(new CommonException(NOT_FOUND_ANSWER));

        mockMvc.perform(get("/api/v1/answers/"+id))
                .andDo(print())
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.error.errorCode").value("not_found_answer"))
                .andReturn();

    }

 

- readInValidAnswer는 유효하지 않은 답을 테스트하기 위한 테스트 메소드입니다.

  id가 2L인 Answer 객체가 없다고 가정하여, 해당 조건에 대해 stubbing을 한 후에 테스트를 진행했습니다. 

 

- 프로젝트 전반에 예외 처리 시 CommonException과 CommonControllerAdvice를 적용했습니다. 

  이를 통해 Exception 처리를 일관성 있게 함과 동시에, Exception과 관련된 중복 코드를

  제거할 수 있었습니다. 

 

(2-4-1) CommonException

package com.example.interviewPrep.quiz.exception.advice;

import lombok.Getter;

@Getter
public class CommonException extends RuntimeException {
    private ErrorCode error;
    private String message;
    public CommonException(ErrorCode error) {
        this.error = error;
    }

    public CommonException(ErrorCode error, String message) {
        this.error = error;
        this.message = message;
    }
}

 

(2-4-2) CommonControllerAdvice

package com.example.interviewPrep.quiz.exception.advice;

import com.example.interviewPrep.quiz.response.ErrorResponse;
import com.example.interviewPrep.quiz.response.ResultResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static com.example.interviewPrep.quiz.exception.advice.ErrorCode.*;

@Slf4j
@RestControllerAdvice("com.example.interviewPrep.quiz")
public class CommonControllerAdvice {

    @ExceptionHandler()
    public ResponseEntity<ResultResponse<?>> commonExHandler(CommonException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ResultResponse.fail(ErrorResponse.of(ex.getError())));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultResponse<?> processValidationError(MethodArgumentNotValidException exception) {
        BindingResult bindingResult = exception.getBindingResult();

        StringBuilder builder = new StringBuilder();
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            builder.append("[");
            builder.append(fieldError.getField());
            builder.append("](은)는 ");
            builder.append(fieldError.getDefaultMessage());
            builder.append(" 입력된 값: [");
            builder.append(fieldError.getRejectedValue());
            builder.append("]");
        }

        return ResultResponse.fail(ErrorResponse.of(MISSING_PARAMETER.setMissingParameterMsg(builder.toString())));
    }

}

 

- 다만, 테스트에서는 에러가 발생 시 404 Not Found 에러를 리턴하도록 했다는 점을 고려해야 합니다.

  이를 위해서는 CommonControllerAdvice에서 CommonException 발생 시, 

  404 Not Found에러가 발생하도록 수정해야 합니다. 

 

- /api/v1/answers에 대해 읽기 요청을 하면 404 Not Found가 반환됨을 확인할 수 있습니다.

 

 

참고자료

단위 테스트 vs 통합 테스트 vs 인수 테스트 (techcourse.co.kr)  

[TDD] 단위 테스트(Unit Test) 작성의 필요성 (1/3) - MangKyu's Diary (tistory.com)

TDD, BDD란? (tistory.com) 

kotest가 있다면 TDD 묻고 BDD로 가! - kakaoTV 

[Spring] MockMvc 공부하자!! (velog.io)

인수테스트에서 테스트 격리하기 (techcourse.co.kr)    

Difference between Mockito.mock(), @Mock and @MockBean annotation in Spring Boot | Java67  

Importance of mocking and stubbing in Tests | by pandaquests | Medium

Mockito와 BDDMockito는 뭐가 다를까? (techcourse.co.kr)  

[Spring Boot] Controller 단위 테스트 (@WebMvcTest, MockMvc) (tistory.com)   

[Spring] @WebMvcTest에 의해 느려지는 테스트 속도와 해결 방법(컨트롤러에 대한 단위 테스트 작성하기) - MangKyu's Diary (tistory.com)  

Spring Boot jUnit Test : JPA metamodel must not be empty! 에러 해결 방법 (tistory.com)  

WebMvcTest 에러 (tistory.com)