테스트 원칙 (F.I.R.S.T)

1. F (Fast) : 테스트는 빨라야 한다.
2. I (Independent) : 각 테스트는 독립적으로 실행되어야 한다.
3. R (Repetable) : 테스트는 반복해서 실행될수 있어야 한다.
4. S (Self-Validating) : 테스트 결과는 성공(true) or 실패(flse)로 나타나야 한다
5. T (Timely) : 테스트는 적절한 시기에 작성되어야 한다

테스트 작성법

  1. Positive뿐만 아니라 Negative 케이스도 작성하라
  2. 구현기반 테스트가 아닌 설계기반 테스트 케이스를 작성하라
    • 구현은 언제든지 변경이 가능하기에 현재 구현방식에 의존하지 않은 케이스로 작성
  3. 경계값 분석 케이스를 작성하라
  4. 테스트 하나에 최소의 assert문을 사용하라
  5. 테스트 하나에 1개의 개념만 테스트해라
  6. 되도록 테스트를 위한 setup은 테스트 메서드 안에 위치시켜라
    • 테스트가 길어져도 상관 없다
  7. 검증에 필요한 값만 설정해라

BDD기반 3-step

(1) given : 주어진 것
(2) when : 실행했을때
(3) then : 결과

// 도메인
@Getter
@Setter
@NoArgsConstructor
public class Member {
      private Long id;
      private String name;
}

// 서비스
public class MemberService {
      private final MemberRepository memberRepository;

      public MemberService(MemberRepository memberRepository) {
          this.memberRepository = memberRepository;
      }

      public Long signup(MemberDTO dto) {
          return memberRepository.save(dtoToEntity(dto)).getId();
      }

      public MemberDTO getOne(Long id) {
          Member entity = memberRepository.findById(id)
                .orElseThrow(() -> new IllegalStateException("해당 회원이 존재하지 않습니다."));

          return entityToDTO(entity);
      }
      
      default dtoToEntity(MemberDTO dto) {
          return Member.builder()
                    .id(dto.getId())
                    .name(dto.getName())
                    .build();
      }

      default entityToDTO(Member entity) {
          return MemberDTO.builder()
                    .id(entity.getId())
                    .name(entity.getName())
                    .build();
      }
}

// 리포지토리 인터페이스
public interface MemberRepository {
      Member save(Member member);
      Optional<Member> findById(Long id);
}

// 리포지토리 구현체
public class MemberRepositoryImpl implements MemberRepository {
      private static Map<Long, Member> store = new HashMap<>();
      private static long sequence = 0L;

      @Override
      public Member save(Member member) {
          member.setId(++sequence);
          store.put(member.getId(), member);
          return member;
      }

      @Override
      public Optional<Member> findById(Long id) {
          return Optional.ofNullable(store.get(id));
      }

      public void clearStore() {
          store.clear();
        }
}

1. Repository 계층

@SpringBootTest
class MemberRepositoryTest {

    @Autowired
    private MemberRepository repository;

    @AfterEach
    public void afterEach() {       // 여러번 테스트를 위해 매 테스트가 끝나면 cleanup
        repository.clearStore();
    }

    @Test
    public void save() {
        // 1. given
        Member member = new Member();
        member.setName("spring");

        // 2. when
        repository.save(member);

        // 3. then
        Member result = repository.findById(member.getId()).get();
        assertThat(result).isEqualTo(member);
    }
}


◼︎ JPA 테스트

  • @DataJpaTest 사용
    • JPA를 다루는데 필요한 Bean만 가져오므로 @SpringBootTest 보다 가볍다
    • 해당 어노테이션은 @ExtendWith(SpringExtend.class)을 포함한다
    • 해당 어노테이션은 @Transactional을 포함하기 때문에 테스트 후 자동으로 RollBack 된다
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)  // 자동설정되는 테스트 내장 DB 해제
@Import(TestConfig.class)            // Test용 config 파일 import
class MemberRepositoryTest {

    @Autowired
    private MemberRepository repository;

    @Test
    public void save() {
        // 1. given
        Member member = new Member();
        member.setName("spring");

        // 2. when
        repository.save(member);

        // 3. then
        Member result = repository.findById(member.getId()).get();
        assertThat(result).isEqualTo(member);
    }
}

@TestConfiguration
public class TestConfig {       // QueryDSL 설정 추가       
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

→ 슬라이스 테스트시 해당 Bean이 Persistance Layer에 속하지 않아 Bean을 찾을 수 없다
→ Test Configuration에 따로 빈을 등록해 import 해준다

2. Service 계층

◼︎ SpringBootTest 사용할 경우

@SpringBootTest(classes = MemberServiceImpl.class)
class MemberServiceTest {
    @Autowired
    MemberService memberService;

    @MockBean
    MemberRepository memberRepository;

    @Test
    public void signup() {
        ...
}

◼︎ Mockito 프레임워크

  • @ExtendWith(MockitoExtension.class) : Mockito 프레임워크 사용
  • @Mock : 해당 객체의 Mock(가짜 객체) 생성
  • @Spy : 해당 객체의 실제 객체 사용
  • @InjectMocks : @Mock, @Spy으로 선언된 의존 객체를 해당 객체에 주입
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {

    @InjectMocks
    private MemberServiceImpl memberService;

    @Mock
    private MemberRepository memberRepository;

    @Test
    public void signup() {
        // 1. given
        MemberDTO dto = MemberDTO.builder()
                .id(1L)
                .name("europani")
                .build();
        Member entity = memberService.dtoToEntity(dto);

        // 리턴값 설정
        given(memberRepository.save(any(Member.class)))
                .willReturn(entity);

        // 2. when
        Long id = memberService.signup(dto);

        // 3. then
        assertThat(id).isEqualTo(1L);
        verify(memberRepository).save(any(Member.class));
    }

    @Test
    @DisplayName("회원 조회 성공")
    void getOne() {
        // given
        Long id = 1L;
        MemberDTO dto = MemberDTO.builder().id(id).build();
        Member entity = memberService.dtoToEntity(dto);

        given(memberRepository.findById(id)).willReturn(Optional.of(entity));

        // when
        MemberDTO result = memberService.getOne(id);

        // then
        assertThat(result.getId()).isEqualTo(id);
        verify(memberRepository).findById(id);
    }

    @Test
    @DisplayName("회원 조회 실패")
    void getOneFail() {
        // given
        Long id = 0L;

        // when
        Exception exception = assertThrows(IllegalStateException.class, () -> memberService.getOne(id));

        // then
        assertEquals("해당 회원이 존재하지 않습니다.", exception.getMessage());
        verify(memberRepository).findById(id);
    }


}

Mockito가 제공하는 메서드

: 다음 메서드들은 Mock을 사용할 때 실제 메서드가 실행 될 수 없기 때문에 특정 메서드가 실행 됐을 때 리턴값을 설정한다 (= Mock 객체 메서드 설정)

  • when(행위) : 테스트 행위 정의 [BDD : given()]
  • thenReturn(리턴값) : 주어진 상황에 결과를 설정 [BDD : willReturn()]
  • thenThrow(예외) : 주어진 상황에 발생하는 예외를 설정
  • verify(객체).메소드 : 해당 객체의 메소드가 실행되었는지 검증 [BDD : then().should()]
mockedList.add("once");

mockedList.add("twice");
mockedList.add("twice");

mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");

// "once"가 1번 add 됐는지 검증
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");

verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");

verify(mockedList, never()).add("never happened"); // 호출된 적 없음

verify(mockedList, atLeastOnce()).add("three times"); // 최소 한 번
verify(mockedList, atLeast(2)).add("five times"); // 최소 두 번
verify(mockedList, atMost(5)).add("three times"); // 최대 다섯 번

3. Controller 계층 (MVC 테스트)

  • @WebMvcTest : MVC 슬라이스 테스트
    • @WebMvcTest@Controller, @RestController, @ControllerAdvice를 읽는다
    • @Service, @Repository, @Component는 읽지 않는다
    • excludeFilters 속성을 사용하여 특정 필터를 테스트에서 제외시킬수 있다
  • MockMvc클래스 : MVC 테스트 기능을 제공하는 클래스
  • @MockBean : 테스트를 위한 가짜 Bean 등록

  • @WithMockUser : Spring Security가 제공하는 목유저 테스트
    • 테스트시 적용할 유저를 Mock으로 만들 수 있다
    • username, password, roles 등의 속성이 있다
    • roles속성 : default-USER
@Controller
public class MemberController {

    @Autowired
    private MemberService memberService;

    @GetMapping("/member")
    public String getMemberList(Model model) {
        List<Member> members = memberService.getList();
        model.addAttribute("members", members);

        return "member/list";
    }

    @GetMapping("/member/signup")
    public String signupForm() {
        return "members/signup";
    }
}

@WebMvcTest(MemberController.class)
public class MemberControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private MemberService memberService;

    @Test
    @DisplayName("멤버 전체조회")
    public void getMemberList() throw Exception {
        // 1. given
        List<Member> members = new ArrayList<>();
        members.add(Member.builder().name("europani").build());

        // memberService.getList() 실행되면 위에서 설정한 members 객체를 리턴한다고 설정
        given(memberService.getList()).willReturn(members);

        // 2. when
        mvc.perform(get("/member"))
                .andExpect(status().isOk())     // Http Code: 200 테스트
                .andExpect(view().name("member/list")  // 반환 뷰 테스트
                .andExpect(model().attributeExists("members")  // model Attribute 프로퍼티 "members" 존재하는지 테스트
                .andExpect(model().attribute("members", contains(members))  // "members" 프로퍼티에 members 객체가 존재하는지 테스트
                .andDo(print());
        
        // 3. then
        verify(memberService).getList();    // memberService.getList() 가 실행됐는지 검증
    }

    // 권한 테스트 (현재 /member는 ADMIN에게만 허용)
    @Test
    @WithMockUser
    @DisplayName("ROLE:USER 인가 테스트")
    public void userRoleUser() throws Exception {
        mvc.perform(get("/member/signup"))
                .andExpect(status().isForbidden())
                .andDo(print());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    @DisplayName("ROLE:ADMIN 인가 테스트")
    public void userRoleAdmin() throws Exception {
        mvc.perform(get("/member/signup"))
                .andExpect(status().isOk())
                .andDo(print());
    }
}
  • perform(HTTP 메서드) : HTTP Method 테스트
  • andExpect(기대값) : 기대값이 나왔는지 체크하는 메서드

API 테스트

@WebMvcTest(EventController.class)
public class EventControllerTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockBean
    private EventService eventService;

    @Test
    @DisplayName("이벤트 1건 조회")
    public void getEventTest() throw Exception {
        // given
        Event event = Event.builder()
                .id(1L)
                .title("test")
                .build();

        // when & then
        mvc.perform(get("/api/event/{id}", event.getId())
                .contentType(MediaType.APPLICATION_JSON)
                // .content(objectMapper.writeValueAsString(id)))       // Body 필요없음
            .andExpect(status().isOk())
            .andExpect(header().string(HttpHeaders.CONTENT_TYPE))
            .andExpect(jsonPath("$.id").value(1L))
            .andExpect(jsonPath("$.title").exists())
            .andDo(print());
    }
}


◼︎ @EnableJpaAuditing가 사용된 경우

@EnableJpaAuditing // JPA Auditing 활성화
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
  • 메인클래스가 실행되자마자 @EnableJpaAuditing이 작동한다. 이를 사용하기 위해서는 @Entity가 1개 이상이여야 한다
  • 하지만, @WebMvcTest는 Controller 관련 클래스만 스캔하기 때문에 JPA 관련 클래스를 읽을 수 없다
  • 그 결과 Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty!이 발생한다

  • 해결방안 : @EnableJpaAuditing@SpringBootApplication와 분리
@SpringBootApplication
public class Application {

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

@Configuration
@EnableJpaAuditing // JPA Auditing 활성화
public class JpaConfig {
}


◼︎ @Mock@MockBean의 차이

  • @Mock은 mockito 프레임워크가 제공하며 위치는 org.mockito 입니다.
  • @MockBean은 스프링 테스트에서 제공하며 위치는 org.springframework.boot.test.mock.mockito 입니다.
    Bean이 스프링 컨테이너에 존재 할 경우 해당 객체를 Mock으로 주입합니다.