코딩하는 오징어

우리는 테스트 코드를 왜 짜야할까?? 본문

알쓸신잡

우리는 테스트 코드를 왜 짜야할까??

코딩하는 오징어 2020. 11. 28. 19:34
반응형

TDD가 유행하면서 테스트에 대한 관심이 많아졌다고 생각한다. 본인도 테스트는 반드시 작성해야한다고 주장하면서 다녔지만 정작 테스트는 어떻게 짜는게 좋고 테스트를 짰을 때 어떤 점이 좋아지는 것에 대한 고민을 깊게 해본것은 최근이었다.

테스트는 왜 짜야 할까?

  • 우리가 만든 함수에 대해 신뢰할 수 있다. (단, 그 함수의 입력 값, 출력 값 명세에 대한 범위에 한해서)
  • 리팩토링이 필요할 때 테스트 코드가 있다면 안심하고 코드를 고칠 수 있다. (리팩토링을 진행하면서 테스트 프레임워크로부터 빠르게 피드백을 받아 볼 수 있기 때문.)
  • 장애(예외)가 발생했을 때 해당 시점의 입력 값을 테스트 코드의 입력으로 주었을 때 어떤 부분이 잘못 됐는지 알 수 있다.(논리 오류로 인한 장애일 경우) 이때 방어 로직이 추가되고 테스트 코드도 보강 됨에 따라 어플리케이션은 더욱 단단해진다.
    • 개발자가 모든 경우의 수를 생각해서 테스트를 작성할 수 없다. 다만, 우리가 작성한 경우의 수 만큼은 안전하다는 것이 보장이 되고 예외가 발생했을 경우 빠르게 피드백을 받고 예외에대한 테스트를 보강해 나가면서 테스트는 더욱 견고해진다. 
  • 빠른 피드백을 통해 좋은 코드(아키텍쳐)가 산출된다. (빠른 실패를 통해 코드를 개선할 수 있음)
    • "테스트가 어려운 코드는 좋지 못한 코드다." 라는 말을 많이 들어왔다. 하지만 왜? → 하나의 메서드를 테스트하기 위해 수 많은 의존성 주입이 필요하고 생성해야될 객체 OR mocking할 것들이 많다면 테스트 대상의 책임이 너무 많은 건 아닐지 의심해보자.
  • 테스트는 개발자에게 좋은 문서다. 잘 짜여진 테스트를 통해 해당 API의 input과 output을 코드를 통해 확인할 수 있고 실제로 실행시켜 보면서 눈으로 동작을 확인해 볼 수 있다.

테스트 코드를 어떻게 짜야 할까??

  • 테스트 코드는 테스트 종류에 따라 테스트 범위가 달라진다. 
    • unit test < service test < integrate test
    • 테스트 범위가 큰 테스트가 많아 질수록 테스트 실행 속도는 느려지고 약한 테스트가 된다.
    • 범위가 넓어 질수록 테스트의 수는 작아져야한다.
  • 최대한 많은 case를 생각해야한다. sonarqube같은 코드분석 툴은 해당 함수가 몇번의 테스트 수행을 거쳐 통과됐는지 보여준다. 해당 함수가 겨우 한 번의 테스트를 거친 함수라면 안전하지 않다.
  • 개발자가 모든 경우의 수를 고려한 입력 값들을 준비하여 테스트를 만들 수 없다. 다만, 개발자가 고려한 입력 값 들에 대한 출력 값은 반드시 보장되어야한다.
  • 테스트를 쉽게 작성할 수 있을 때 작성한다. 시기를 놓치지말자 (테스트 코드부터 작성하는 TDD는 의견 충돌이 다분하다.. 적어도 테스트를 작성해야할 시기만은 놓치지 않으면 된다고 생각한다.)
  • 테스트는 기본적으로 빠르게 실행되고 빠르게 성공 OR 실패 되어야 한다.
    • 우리는 주로 Spring Framework를 사용한다. 테스트를 위해 Spring Context를 띄우는 건 테스트 시간이 길어지는 안좋은 요소 중 하나다. 여기에 MockBean까지 적용한다면 테스트마다 Spring Context를 새로 띄우게 된다. → 테스트 실행속도가 굉장히 느려진다.
    • 위의 문제를 해결하기위해 의존성 주입시 Construct Dependency Injection(생성자 주입)을 사용하자. (final 멤버 변수 + 생성자 주입)

Sample Test

// User.java
package com.example.test.entity;

import javax.persistence.*;
import java.util.Objects;

@Entity
@Table(name = "user")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private boolean useYn;

    public User(Long id, String name, String email, boolean useYn) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.useYn = useYn;
    }

    public User() { }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public boolean isUseYn() {
        return useYn;
    }

    public void deactivate() {
        this.useYn = false;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}


// UserRepository.java
package com.example.test.respository;

import com.example.test.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

    User findByEmailAndUseYn(String email, boolean useYn);
}


// UserService.java
package com.example.test.service;

import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getActivateUserByEmail(String email) {
        return userRepository.findByEmailAndUseYn(email, true);
    }

    @Transactional
    public User deactivateUserByEmail(String email) {
        User user = getActivateUserByEmail(email);
        user.deactivate();
        return userRepository.save(user);
    }
}

위의 코드를 설명하면 다음과 같다.

  • User → 사용자 정보를 담고 있는 Entity
  • UserRepository → Application과 DB를 연결 해주는 Class
  • UserService → 사용자에 필요한 비즈니스 로직을 책임지는 Class

위의 코드들 중 UserService의 deactivateUserByEmail(..) 함수를 다음과 같이 두 가지 방법으로 테스트 할 수 있다. (Repository는 외부 환경에 의존됨으로 mocking해야한다. h2 database를 사용하면 해결되지만 본글은 h2 database를 사용할 수 없는 환경이라고 가정하겠다.)

 

spring context를 띄워서 테스트

// SpringTestSupport.java
package com.example.test;

import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@ExtendWith(SpringExtension.class)
@ActiveProfiles("test")
@SpringBootTest
abstract class SpringTestSupport {

}

// TestWithSpringContext.java
package com.example.test;

import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import com.example.test.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;


import static org.mockito.Mockito.*;

public class TestWithSpringContext extends SpringTestSupport {

    @MockBean
    private UserRepository mockRepository;
    @Autowired
    private UserService sut;

    @Test
    void deleteTest() {
        //given
        String email = "taewoong.han.squid@navercorp.com";
        User dummy = new User(1L, "한태웅", email, true);
        User expect = new User(1L, "한태웅", email, false);
        when(mockRepository.findByEmailAndUseYn(any(), anyBoolean())).thenReturn(dummy);
        when(mockRepository.save(any())).thenReturn(expect);

        //when
        User dut = sut.deactivateUserByEmail(email);

        //then
        Assertions.assertFalse(dut.isUseYn());
    }
}

spring context를 띄우지 않고 테스트

// TestWithoutSpringContext.java
package com.example.test;

import com.example.test.entity.User;
import com.example.test.respository.UserRepository;
import com.example.test.service.UserService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.*;

public class TestWithoutSpringContext {

    private final UserRepository mockRepository = mock(UserRepository.class);
    private final UserService sut = new UserService(mockRepository);

    @Test
    void deleteTest() {
        //given
        String email = "taewoong.han.squid@navercorp.com";
        User dummy = new User(1L, "한태웅", email, true);
        User expect = new User(1L, "한태웅", email, false);
        when(mockRepository.findByEmailAndUseYn(any(), anyBoolean())).thenReturn(dummy);
        when(mockRepository.save(any())).thenReturn(expect);

        //when
        User dut = sut.deactivateUserByEmail(email);

        //then
        Assertions.assertFalse(dut.isUseYn());
    }
}

위의 두 테스트의 차이는 Spring Context를 띄우는 차이이다. 실행속도의 차이를 보면 어마어마하다.

  • spring context를 띄우는 테스트 : 106ms
  • spring context를 띄우지 않고 테스트 : 29ms

무려 3배 이상 차이가 난다. spring context의 bean이 반드시 필요한 테스트는 어쩔수 없지만 그렇지 않은 경우에는 테스트 해야할 대상을 직접 생성하여 테스트하면 테스트 실행 속도를 개선할 수 있다.

spring context없이 테스트를 하려면 의존성 주입을 생성자 주입으로 해야한다. 그렇지않고 필드 주입을 하게 된다면 mock bean을 사용할 수 밖에 없고 테스트 실행 속도는 현저하게 떨어진다.

+ 위의 테스트에는 어떠한 주석도 없지만 UserService의 deactivateUserByEmail이라는 메서드가 어떻게 동작하는지 쉽게 확인할 수 있다.

 

 

반응형

'알쓸신잡' 카테고리의 다른 글

Gradle Dependency Configuration  (0) 2021.09.26
JVM의 종료와 Graceful Shutdown  (2) 2021.05.19
Redirect 와 Forward  (2) 2020.04.27
OLTP와 OLAP  (1) 2020.01.05
데이터 모델링에서 정규화의 의의와 성능 논쟁  (0) 2020.01.05
Comments