Test 코드를 작성하는 법을 알아보기 전에 Test 코드의 필요성에 대해서 알아보겠습니다. 

1. 왜 Test 코드를 작성하는가?

 

크게 2가지 이유가 있습니다.'

 

1-1. Test 코드를 작성하지 않고 결과를 검증하는 과정은 비용이 많이 든다.

 

Test코드 사용 X (싱글 애플리케이션 (Monolithic Arichitecture)에서의 모습)

  1. 검증 코드 작성
  2. 애플리케이션 실행
  3. PostMan 혹은 브라우저 Request 요청
  4. log 혹은 print로 결과 검증
  5. 원하지 않는 결과 발생 시 애플리케이션 종료
  6. 다시 코드 작성

위와 같은 로테이션이 원하는 결과를 얻을 때까지 돌아가게 됩니다. 간단한 애플리케이션이라도 실행하고 종료하는데 비용이 많이 듭니다. 하지만 Test 코드를 작성한다면 이야기가 달라집니다.

 

Test 코드를 사용

 

  1. Test 코드 작성
  2. Test 코드 실행
  3. 결과 검증
  4. Test 코드 수정

애플리케이션을 실행, 종료할 필요가 없습니다. 따라서 비용이 줄어들고, Test 코드를 통해서 명확한 결과 검증이 가능합니다. Test 코드를 작성하지 않았을 때와 비교하면 이점이 명확합니다.  

 

1-2. Spring은 계층 구조로 일반적으로 아래와 같이 구성돼 있습니다. 

 

Controller : 클라이언트 요청을 받고 클라이언트에게 결과를 반환 (Presentation Layer)

Service : 비즈니스 로직을 실행하고 결과 반환(Service Layer)

Repository : database에 쿼리를 이용해서 CRUD를 하는 계층(Data Access Layer)

Domain : Entity 클래스

 

그렇기에 애플리케이션을 실행해서 Test를 진행한다면, 어느 계층에서 잘못된 코드가 있는지 파악하는데 많은 비용이 듭니다. 하지만 Test 코드를 통해서 계층별로 Test를 진행한다면 어느 부분이 잘못된 지 파악을 쉽게 할 수 있습니다. 

 

2. Springboot Test

 

Spring Initailizer를 통해서 프로젝트를 생성하면 spring-boot-starter-test dependency가 자동으로 추가됩니다. 저희는 이것을 이용해서 Test 코드를 작성하면 됩니다. 

 

2-1 spring-boot-test-starter 구성요소

 

1. spring-boot-test: 테스트에 필요한 핵심 기능 라이브러리

2. spring-boot-test-autoconfigure: 테스트 진행 위한 Configuration 라이브러리

 

2-2 Junit 이란? 

 

1. Java에서 독립된 단위 테스트를 지원해주는 프레임워크

2. Assert(검증)을 이용해서 결과를 기댓값과 실제 값을 비교

3. @Test 어노테이션마다 독립적으로 테스트가 진행

 

2-3 단위 테스트와 통합 테스트

 

단위(unit) 테스트: 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위 테스트 -> 쉽게 말하면 하나의 기능 혹은 메서드라고 이해하면 됩니다.

통합(integration) 테스트: 모듈을 통합화는 과정에서 모듈 간의 호환성을 확인하는 테스트 -> unit이 하나였다면 반대로 여러 개의  계층이 테스트에 참여한 것이라고 생각하면 쉬울 거 같습니다.

 

2-3-1 단위 테스트 장단점

 

장점

  • 새로운 기능에 대해서 빠르게 작성 가능
  • Test 코드 자체가 하나의 문서
  • 시간과 비용의 절감

 

단점

  • 독립적인 테스트이므로 다른 객체와 상호작용 처리를 위해서 가짜 객체 정의 필요함
  • 가짜 객체의 답변 작성 필요함
  • 실제 운영 환경과 다른 답변을 내놓을 수 있는 가능성이 있음

 

2-3-2 통합 테스트 장단점

 

장점

  • 실제 객체를 사용하므로 가짜 객체 사용하지 않아 정의하지 않아도 됨
  • 실제 운영 환경과 같은 값을 도출 가능함

 

단점

  • 테스트 하나의 많은 비용이 들어감
  • 어느 계층에서 발생한 문제인지 파악하기 힘듦

 

2-3-3 단위 테스트, 통합 테스트 선택하자.

 

단위 테스트, 통합 테스트 모두 장단점이 명확합니다. 하지만 통합 테스트의 경우 비용을 절감할 수 있는 방법이 없습니다. 단위 테스트는 단점들을 개선해 나갈 수 있습니다. 그래서 좋은 단위 테스트 작성에 대해서 알아보겠습니다. 

 

2-3-4 좋은 단위 테스트 

 

1. 1개의 테스트는 1개의 기능에 대해서만 테스트

2. 테스트 주체와 협력자를 구분하기. ( 여기서 주체는 테스트를 할 객체이며, 협력자는 테스트를 진행하기 위해 정의하는 가짜 객체입니다.)

3. Given, when, then으로 명확하게 작성하기

  • Given: 테스트를 진행할 행위를 위한 사전 준비
  • when: 테스트를 진행할 행위
  • then: 테스트를 진행한 행위에 대한 결과 검증

 

3. Springboot Test 코드 작성 시작 (단위 테스트, 통합 테스트)

 

간단하게 만들어봤습니다.

1. Member를 만들 수 있습니다.

2. Member는 이름과 나이를 가지고 Name은 중복이 불가능합니다. 

3. Member는 나이를 변경할 수 있습니다. 

4. Member의 리스트를 받을 수 있습니다.

테스트에 사용될 객체들 (테스트에 관한 포스팅이므로 설명은 따로 하지 않겠습니다.)

 

1. Member

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {


    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "age")
    private int age;



    @Builder
    public Member(String name, int age){
        this.name=name;
        this.age=age;
    }

    @Override
    public boolean equals(Object obj) { // 협력자에서 이름 중복 예외를 검증하기 위함
        Member me = (Member) obj;
        return this.name.equals(me.name) && this.age==me.age;
    }

    public void changeAge(int age){
        this.age=age;
    }
}

 

2. MemberRepository

public interface MemberRepository extends JpaRepository<Member,Long> {

    Optional<Member> findByName(String name);
}

 

3. MemberService, MemberServiceImpl

public interface MemberService {

    List<MemberResponseDto.ListDto> findAll();
    Long createMember(String name, int age);
}

 

@Service
@Transactional
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService{

    private final MemberRepository memberRepository;
    @Override
    public List<MemberResponseDto.ListDto> findAll(){
        return memberRepository.findAll().stream().map(a -> new MemberResponseDto.ListDto(a.getName(),a.getAge()))
                .collect(Collectors.toList());
    }

    @Override
    public Long createMember(String name, int age){
        memberRepository.findByName(name).ifPresent(a -> {
            throw new IllegalStateException("이미 있는 아이디");
        });

        Member member = Member.builder()
                .age(age)
                .name(name).build();

        return memberRepository.save(member).getId();
    }
}

 

4. MemberController, MemberControllerAdvice

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members")
    public List<MemberResponseDto.ListDto> getMemberList(){
        return memberService.findAll();
    }

    @PostMapping("/members")
    public Long createMember(@RequestBody MemberRequestDto.CreateDto createDto){
        return memberService.createMember(createDto.getName(), createDto.getAge());
    }
}

 

@RestControllerAdvice(basePackages = "com.example.fortest.domain.member.controller")
public class MemberControllerAdvice {

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String duplicate(IllegalStateException e){
        return e.getMessage();
    }
}

 

5. MemberDto

public class MemberRequestDto {

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class CreateDto {
        String name;
        int age;
    }
}
public class MemberResponseDto {

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ListDto {
        String name;
        int age;
    }
}

 

3-1 Test 코드 생성

 

test 디렉터리 밑에 존재하는 FortestApplicationTests가 속해 있는 디렉터리에 생성하면 됩니다.

 

일일이 디렉터리와 클래스를 생성하기 귀찮다면 테스트를 진행하고 싶은 객체에 ctrl+shift+t를 눌러주면 됩니다.

 

 

3-1 AssertJ 라이브러리

 

Assertions는 assertJ 라이브러리를 사용합니다. Junit jupiter를 사용하지 않는 이유는 assertJ의 가독성이 좋기 때문입니다. 간단한 기능을 알아보겠습니다.

1. assertThat은 값 검증에 쓰입니다.

assertThat(실제값). isEqualTo(기댓값)

assertThat(실제 객체). isInstanceOf(객체 예상 타입)

assertThat(실제값). isNull()

등등 실제 값, 값의 타입을 비교하는 여러 연산자들과 쓰이는 메서드입니다.

2. asserThatThrownBy는 예외 발생 검증에 쓰입니다.

asserThatThrownBy( () -> 예외를 발생시킬 로직). isInstanceOf(예외 클래스)

예외가 발생한다면 테스트를 통과하고 발생하지 않는다면 실패하는 메서드입니다. 

 

3-2 Domain Test

 

가장 단위가 작은 Member 객체에 대해서 단위 테스트입니다. 도메인에 대한 테스트가 가장 비용이 적게 듭니다.  

@Test 어노테이션이 반드시 필요하며, 반환하는 것이 없도록 void여야 합니다.

@DisplayName을 통해서 테스트 진행 시 나오는 테스트명을 정할 수 있습니다. 

 

@Test
@DisplayName("멤버가 생성되는지 확인하는 테스트")
void createMember(){
    /*
    given
     */
    Member member = Member.builder().age(10).name("hi").build();

    /*
    when, then
     */
    Assertions.assertThat(member.getAge()).isEqualTo(10);
    Assertions.assertThat(member.getName()).isEqualTo("hi");
}
  • Builder를 이용해서 멤버를 생성했을 때 올바르게 생성됐는지 테스트 

@Test
@DisplayName("멤버의 나이 바뀌는지 확인하는 테스트")
void changeAgeTest(){
    /*
    given
     */
    Member member = Member.builder().age(10).name("hi").build();

    /*
    when
     */
    member.changeAge(13);

    /*
    then
     */
    Assertions.assertThat(member.getAge()).isEqualTo(13);
}
  • Member의 나이를 바꿨을 때 올바르게 바뀌는지 테스트

@Test
@DisplayName("멤버의 나이 바뀌는지 확인하는 테스트")
void changeAgeTest(){
    /*
    given
     */
    Member member = Member.builder().age(10).name("hi").build();

    /*
    when
     */
    member.changeAge(13);
    /*
    then
     */
    assertThat(member.getAge()).isEqualTo(12);
}
  • 나이를 바꾸고, 검증이 다르게 나와 Test가 실패했을 때 

  • 테스트를 실패했다는 알림과 함께 왜 실패했는지를 알려줍니다.

여러 개의 테스트를 동시에 실행도 가능합니다. 

  • 모두 독립적으로 실행되기에 같이 돌려도 결과에 미치는 영향은 없습니다.

 

3-3 Jpa를 사용하는 Repsitory Test

 

@DataJpaTest: Jpa를 사용하는 Repository에 대한 검증을 수행할 때 사용하는 어노테이션입니다. 

@DataJpaTest는 @Transaction을 포함하고 있어서 1개 의 테스트가 끝나면 Rollback 해 다른 테스트에게 영향을 미치지 않습니다.

@DataJpaTest로 검증할 수 있는 목록은 아래와 같습니다. 

  • DataSource에 대한 설정
  • CRUD가 제대로 동작하는지

 

@AutoConfigurationDatabase에 Replace.NONE설정을 주면 실제 DB로 검증할 수 있습니다. 따로 명시하지 않을 시 내장된 임베디드 DB를 사용합니다. 

@DataJpaTest
public class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    @DisplayName("멤버 만들기")
    void createMember(){
        /*
        given
         */
        Member member1 = Member.builder().name("hi1").age(10).build();
        Member member2 = Member.builder().name("hi2").age(20).build();

        /*
        when
         */
        Member result1 = memberRepository.save(member1);
        Member result2 = memberRepository.save(member2);

        /*
        then
         */
        assertThat(result1.getAge()).isEqualTo(member1.getAge());
        assertThat(result2.getAge()).isEqualTo(member2.getAge());

    }

    @Test
    @DisplayName("멤버의 리스트를 반환 하는지 확인")
    void MemberList(){
        /*
        given
         */
        Member member1 = Member.builder().name("hi1").age(10).build();
        Member member2 = Member.builder().name("hi2").age(20).build();
        memberRepository.save(member1);
        memberRepository.save(member2);

        /*
        when
         */
        List<Member> result = memberRepository.findAll();

        /*
        then
         */
        assertThat(result.size()).isEqualTo(2);
    }
    
}
  • Member를 저장이 잘 됐는지 검증
  • Member를 저장하고, Member List를 잘 반환하는지 검증

 

3-4 Service 계층 Test

 

Service 계층은 Repository객체를 Spring에게 주입받고 있습니다.

따라서 Service계층의 Test는 주체가 Service 객체이며, 협력자는 Repository객체입니다. 

그렇기에 Repository는 가짜 객체로서 응답을 설정해줘야 합니다.

 

Junit5 기능을 사용하고, Test에서 가짜 객체를 사용하기 때문에 @ExtendWith(SpringExtension.class)를 붙여줘야 합니다. 

 

 

@ExtendWith(SpringExtension.class)
public class MemberServiceTest {

    // Test 주체
    MemberService memberService;

    // Test 협력자
    @MockBean
    MemberRepository memberRepository;


    // Test를 실행하기 전마다 MemberService에 가짜 객체를 주입시켜준다. 
    @BeforeEach
    void setUp(){
        memberService = new MemberServiceImpl(memberRepository);
    }
}
  • @BeforeEach: Test를 실행하기 전 항상 실행하도록 하는 어노테이션입니다. 여기서는 가짜 객체를 주입하는 데 사용됐습니다.
  • @MockBean: 가짜 객체를 만드는 역할을 합니다. 물론 가짜 객체이므로 응답을 정의해줘야 합니다. Test의 협력자 역할을 합니다. 
  • MemberService: Test의 주체로서 가짜 객체를 주입받고, 자신의 로직을 실행하고 결과를 가지고 검증을 합니다.
@Test
@DisplayName("멤버 생성 성공")
void createMemberSuccess(){
    /*
    given
     */
    Member member3 = Member.builder().name("hi3").age(10).build();
    ReflectionTestUtils.setField(member3,"id",3l);

    Mockito.when(memberRepository.save(member3)).thenReturn(member3); // 가짜 객체 응답 정의
    /*
    when
     */
    Long hi3 = memberService.createMember("hi3", 10);
    /*
    then
     */
    assertThat(hi3).isEqualTo(3L);
}
  • Member 생성을 성공하는 Test입니다.
  • ReflectionTestUtils.setField() : test를 진행하면서 private로 선언된 필드 값을 넣어줄 수 있습니다. 
  • Mockito.when(가짜 객체의 로직 실행). thenReturn(실행되면 이것을 반환한다.)라고 말할 수 있습니다.
@Test
@DisplayName("멤버 생성시 member1 과 이름이 같아서 예외 발생")
void createMemberFail(){
    /*
    given
     */
    Member member1 = Member.builder().name("hi1").age(10).build();
    Mockito.when(memberRepository.findByName("hi1")).thenReturn(Optional.of(member1));

    /*
    when then
     */
    assertThatThrownBy(() -> memberService.createMember("hi1",10)).isInstanceOf(IllegalStateException.class);
}
  • assertThatThrownBy는 예외 발생을 검증하는 메서드입니다. 
  • memberRepository.findByName("hi1"))을 했을 경우 Optional.of(member1)이 반환됩니다.
  • MemberService내에는 name으로 중복 예외를 터트리는 로직이 있으므로 예외가 발생합니다.
  • 따라서 예외가 발생해서 테스트를 통과하는 모습을 볼 수 있습니다.

 

@Test
@DisplayName("멤버 생성시 member1 과 이름이 같아서 예외 발생")
void createMemberFail(){
    /*
    given
     */
    Member member1 = Member.builder().name("hi1").age(10).build();
    Mockito.when(memberRepository.findByName("hi1")).thenReturn(Optional.of(member1));

    /*
    when then
     */
    assertThatThrownBy(() -> memberService.createMember("hi2",10)).isInstanceOf(IllegalStateException.class);
}
  • 반대로 예외를 발생시키지 않는다면, 테스트를 실패합니다.

 

3-5 Controller 계층 

 

@WebMvcTest: Mvc를 위한 테스트로서 컨트롤러가 설계대로 동작하는지에 대해 검증하는데 필요한 어노테이션입니다.

아래 보이시는 것과 같이 Controller를 구체적으로 적을 수 있고, ControllerAdvice, Filter 등을 포함과 제외시킬 수 있어 Security에 대한 Test도 가능합니다. (Security에 대한 테스트는 진행하지 않겠습니다.)

@WebMvcTest(MemberController.class)
public class MemberControllerTest {

    @Autowired
    MockMvc mvc;

    @MockBean
    MemberServiceImpl memberService;

}
  • Test의 주체는 MemberController입니다. 따라서 WebMvcTest에 선언을 해줍니다.
  • MemberService는 협력자이므로 @MockBean을 등록해주고, Test에 응답을 정의합니다.
  • MockMvc는 실제로 서블릿 컨테이너를 사용하지 않고, 테스트용으로 Mvc 기능을 사용할 수 있게 해주는 역할을 합니다. 테스트 때 생성되는 WebApplicationContext에서 주입받습니다.
@Test
@DisplayName("리스트 반환받기")
void getList() throws Exception {
    /*
    given
     */
    List<MemberResponseDto.ListDto> list = List.of(new MemberResponseDto.ListDto("asd", 10)
            , new MemberResponseDto.ListDto("fsd", 12));
    Mockito.when(memberService.findAll()).thenReturn(list);

    /*
    when then
     */
    mvc.perform(MockMvcRequestBuilders.get("/members").contentType(MediaType.APPLICATION_JSON))
            .andDo(MockMvcResultHandlers.print())
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$[1].name").value("fsd"))
            .andExpect(MockMvcResultMatchers.jsonPath("$[0].name").value("asd"));
}
  • 협력자인 MemberService의 findAll() 호출 시 반환하는 list를 정의합니다.
  • mvc.perform(MockMvcRequestBuilders.get(). contentType(): 컨트롤러에게 요청을 보내는 역할을 합니다. uri를 만들고, contentType을 지정합니다.
  • andDo(): 요청에 대한 처리를 합니다. MockMvcResultHanlder.print()를 인자로 넣었으므로 요청과 응답에 대한 것들을 콘솔에 출력해줍니다.
  • andExpect(): 검증하는 로직입니다. MockMvcResultMatcher.status()는 HTTP 상태 코드를 검증하고, jsonPath는 Json로 넘어온 것들에 대한 값을 검증할 수 있습니다.
  • jsonPath("$. name"). value("fsd"): 단일 객체에 대한 값 검증
  • jsonPath("$[1]. name"). value("asd): 리스트를 반환받았을 때 지정하여 검증


4. 통합 테스트

 

단위 테스트와는 다르게 실제 spring을 실행했을 때와 같은 운영환경에서 잘 동작하는지 확인하는 테스트입니다. 전체적인 플로우를 확인하므로 Spring에 쓰이는 Bean들이 등록됩니다.

@SpringBootTest: 통합 테스트를 진행하기 위한 어노테이션입니다. 

(주의 @Transaction을 포함하고 있지 않기 때문에 Repository 계층까지 사용된다면 @Transaction도 붙여서 Rollback을 실행해줘야 합니다.)

 

public class MemberServiceTest {
    @Autowired
    MemberService memberService;

    @Autowired
    MemberRepository memberRepository;
}
  • 단위 테스트보다 쉽게 진행할 수 있습니다. SpringContainer를 만들기 때문에 Bean 있어 따로 가짜 객체를 정의하지 않아도 됩니다.
@Test
@DisplayName("멤버 만들기")
void createMemberSuccess(){
    Long memberId = memberService.createMember("hi1", 10);
    assertThat(memberId).isEqualTo(1l);
}

@Test
@DisplayName("이름 중복으로 만들기 실패")
void createMemberFail(){
    Long memberId = memberService.createMember("hi1", 10);
    assertThatThrownBy(() -> memberService.createMember("hi1",12)).isInstanceOf(IllegalStateException.class);
}

  • 검증이 통과한 것을 볼 수 있습니다.

5. 단위 테스트 VS 통합 테스트

 

1. Test를 진행하는 데 걸리는 시간을 살펴보겠습니다.

 

왼쪽 단위 테스트

오른쪽 통합 테스트

 

프로젝트를 진행하며, 테스트 코드가 적다면, 통합 테스트로 진행해도 많은 영향을 끼치진 않습니다. 하지만 애플리케이션이 커지면서, 테스트 코드가 점점 많아진다면 비용이 많이 발생할 것입니다. 

 

2. 오류가 발생한 곳 색출

 

단위 테스트의 경우 계층마다 나눠서 작성하기 때문에 오류가 발생해서 검증이 실패한다면, 그 부분만 수정하면 됩니다. 

반면에 통합 테스트는 어느 계층에서 오류가 발생한 지 색출하는데 오랜 시간이 걸립니다.


정리하기

 

단위 테스트, 통합 테스트 모두 장단점이 존재합니다.

단위 테스트의 단점은 테스트를 작성하는 노하우 등으로 커버가 가능합니다. 하지만 통합 테스트의 무거운 테스트로 인해서 발생하는 비용은 커버가 불가능합니다.

따라서 단위 테스트를 작성할 수 있는 능력이 중요하다고 생각합니다. 아래의 사항이 BestPractice는 아닙니다. 하지만 Test 코드를 작성하며, 제가 고려한 사항들입니다.  

 

좋은 단위 테스트 작성하기

1. 1개의 테스트는 1개의 기능에 대해서만 테스트

2. 테스트 주체와 협력자를 구분하기. ( 여기서 주체는 테스트를 할 객체이며, 협력자는 테스트를 진행하기 위해 정의하는 가짜 객체입니다.)

3. Given, when, then으로 명확하게 작성하기

  • Given: 테스트를 진행할 행위를 위한 사전 준비
  • when: 테스트를 진행할 행위
  • then: 테스트를 진행한 행위에 대한 결과 검증

 읽어 주셔서 감사합니다.