티스토리 뷰

Note

테스트 코드

장일영 2024. 5. 23. 13:07

관련 개념

테스트 하려는 대상(System Under Test)을 SUT라고 한다. 유저의 어떤 행동에 대한 결과를 테스트하는 경우 유저는 SUT다. 예를 들어 유저가 어떤 글을 저장하는 로직에 대한 테스트 코드가 있다면 SUT는 유저다. 저장을 하는 행위가 SUT라고 생각하기 쉽지만 SUT는 테스트하고자 하는 주요 대상이 되는 Unit을 의미한다. 

 

테스트는 행동에 집중해야 한다. 유저가 시스템을 사용하는 User Story를 생각해야 하고, 시나리오를 고려해야 한다. 이를 지키기 위해 테스트 코드를 작성할 때 given-when-then 뼈대를 사용하기도 한다. 어떤 상황이 주어졌을 때(given), 어떤 행동을 하면(when), 결과가 이러해야 한다(then)는 구조다.

 

대상 함수의 구현을 호출하지 않으면서 그 함수가 어떻게 호출되는지를 검증하는 기법을 상호작용 테스트(Interaction Test)라고 한다. 일반적으로 메서드가 실제로 호출이 이루어졌는지 검증하는 테스트는 좋은 방법이라고 하기 어렵다. 사실상 내부 구현을 어떻게 했는지 감시하는 것에 가깝기 때문이다. 이는 캡슐화에 위배된다. 객체에 책임을 위임하면, 객체가 이 작업을 제대로 수행하는지 테스트하면 된다.

 

상호 작용 테스트는 행위 기반 검증 방식(behaviour-based-verification)이다. 어떤 값을 시스템에 넣었을 때 협력 객체의 어떤 메서드를 실행하는가? 이를 확인하는 것이 행위 기반 검증이다. 상태 기반 검증(state-based-verification)은 어떤 값을 시스템에 넣었을 때 나오는 결괏값을 기댓값과 비교하는 방식이다. 

 

테스트에 필요한 자원을 생성하는 것을 Test Fixture라고 한다. `@BeforeEach`를 통해  DB에 미리 데이터를 삽입하거나 테스트 클래스에서 전역적으로 사용할 객체를 미리 생성하는 작업이 있다면 이는 Test Fixture에 해당한다. SUT를 생성할 수도 있고, SUT에 들어가야 하는 의존성이 될 수도 있다. 코드 중복이 심한 경우가 아니라면 지양하는 것이 낫다.

 

Test Double은 일종의 대역을 의미한다. 회원가입 로직에 이메일 인증 메일 발송이 포함되어 있다면 이 부분을 가짜 객체로 대체할 수 있다. 이를 Test Double이라고 한다. Dummy는 아무런 동작도 하지 않는다. 코드가 정상적으로 돌아가도록 하기 위해 전달하는 객체다. Fake 역시 가짜 객체라는 점에서 Dummy와 동일한 역할을 하지만 자체적인 로직이 있다. Stub은 미리 준비된 값을 출력하는 객체다. 주로 외부 연동 컴포넌트에 사용한다. Mockito 프레임워크를 이용해서 구현하는 경우가 많다. 

class StubUserRepository implements UserRepository {
    public User getByEmail(String email) {
        if(email.equals("user@gmail.com")) {
            return User.builder()
                .email("user@gmail.com")
                .build();
        }
        throw new UsernameNotFoundException(email);
    }
}
// given
given(userRepository.getByEmail())
    .willReturn(User.builder()
        .email("user@gmail.com")
        .build());
        
// when
// then

 

Mock은 메서드 호출을 확인하기 위한 객체로 자가 검증 능력을 갖췄다. 사실상 Test Double과 같은 의미로 사용된다. Stub도 Mock이고, Dummy도 Mock이고, Fake도 Mock이라고 부른다. 그러나 개념적으로는 메서드 호출을 확인하기 위한 객체다. Spy는 여기에서 한 단계 더 나아가 모든 메서드 호출을 기록해두고 있는 객체다. 이러한 Test Double의 각 분류를 완전히 구분하는 것은 사실 큰 의미는 없다. 그보다 테스트 코드를 작성하기 위해 왜 이러한 Test Double들이 필요한지 고민하는 것이 더 중요하다는 생각이 든다.

 

 

테스트 3분류

  • Small Test(80%)
  • Medium Test(15%)
  • Large Test(5%)

Small Test

Unit Test. 단일 서버, 단일 프로세스, 단일 스레드에서 돌아가는 테스트다. Disk IO, Blocking Call이 없다. 그러므로 `Thread.sleep()`이 테스트 내에 있거나 H2를 사용하는 경우 Small Test가 아니다. 이런 특성 때문에 Small Test는 결과가 항상 결정적(Deteministic)이고, 속도가 빠르다.

Class Math {
    public static int add(int a, int b) {
        return a + b;
    }
}
Class MathTest {
    @Test
    void 정수를_더해_정수를_얻는다() {
        // given
        int a = 1;
        int. b = 10;
        
        // when
        int result = Math.add(a, b);
        
        // then
        assertThat(result).isEqualTo(11);
    }
}

 

이런 건 단위 테스트라고 볼 수 있다.

 

 

Medium Test

Integration Test. 단일 서버에서 동작한다. 멀티 프로세스와 멀티 스레드를 사용할 수 있다. 다시 말하면 H2와 같은 테스트용 DB를 사용할 수 있다.  Small Test보다 느리고 멀티 스레드 환경에서 어떻게 동작할지 장담할 수 없으므로 결과가 항상 같다는 보장이 없다. 테스트의 결과가 H2와 같은 외부 모듈의 동작에 따라 달라질 수 있기 때문이다. Medium Test를 많이 만드는 것이 테스트 실패의 원인이기도 하다. Small Test가 전체 테스트의 80%를 차지하려면 어떤 설계를 해야 하나? 이걸 고민해봐야 한다.

public interface UserRepository extends JpaRepository<User, Long> {}
@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySouce("classpath:test-application.properties")
class UserRepositoryTest {
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void 사용자_정보를_저장한다() {
        // given
        User user = new User();
        User.setEmail("user@gmail.com");
        User.setRole(UserRole.BASIC);
        
        // when
        User savedUser = userRepository.save(user);
        
        // then
        assertThat(savedUser.getId()).isNotNull();
    }
}

 

 

Large Test

E2E Test. 멀티 서버에서 동작한다.

 

 

 

의존성(Dependency)

컴퓨터 공학에서 말하는 의존성은 결합(Coupling)과 개념이 같다. 다른 객체의 함수를 사용하는 상태를 의미한다. 만약 A가 B를 사용한다면 A는 B에 의존하고 있다고 말할 수 있다.

 

의존성 주입(Dependency Injection)

의존성 주입은 의존성을 약화시키는 테크닉이다. 클래스 A의 메서드에서 다른 클래스의 인스턴스를 생성해 결과를 반환한다면 클래스 A는 사용하고 있는 다른 클래스에 전부 의존하고 있다. 같은 동작을 하는 클래스 A의 메서드를 내부에서 외부 클래스를 `new`로 인스턴스화 하지 않고 외부에서 넣어준다면 A 클래스와 다른 클래스 간의 의존성을 약화시킬 수 있다. 그러나 의존성 주입이 의존성을 완전히 없애는 것은 아니다. 메서드의 인자로 인스턴스를 받는다고 하더라도 여전히 그 인스턴스가 필요한 것은 동일하기 때문이다. 의존성을 제거한다는 것은 객체 간의 협력을 하지 않겠다는 것과 같다. 따라서 대부분의 디자인 패턴이나 설계는 의존성을 어떻게 약화시킬지를 고민한 결과물이다. 의존성을 없애는 것이 아니다.

 

의존성의 종류: Types of coupling

 

Coupling (computer programming) - Wikipedia

From Wikipedia, the free encyclopedia Degree of interdependence between software modules In software engineering, coupling is the degree of interdependence between software modules; a measure of how closely connected two routines or modules are;[1] the str

en.wikipedia.org

 

의존성 역전(Dependency Inversion Principle)

의존성 역전(DIP)은 원칙이다. 소프트웨어 모듈을 분리하는 특정 형식을 의미하며 의존성 주입과는 완전히 다른 개념이다. 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전시켜 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.

  • 첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
  • 둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.

A 클래스의 메서드가 B 인터페이스에 의존하고, B 클래스가 B 인터페이스의 구현체라면 두 조건을 모두 만족한다. A 클래스는 B 인터페이스에 의존하고, B 클래스는 B 인터페이스에 의존한다. 즉 상위 모듈인 A와 하위 모듈인 B가 모두 추상화인 B 인터페이스에 의존하게 된다. 또 세부 사항인 B 클래스가 추상화인 B 인터페이스에 의존하게 된다. 이 때 인터페이스는 일종의 정책이 된다. 변동성이 낮다. 반면 인터페이스를 구현한 클래스는 세부사항이 된다. 변동성이 큰 구체적인 요소라고 볼 수 있다.

 

의존성과 테스트

의존성 주입과 의존성 역전을 잘 다루는 것은 좋은 테스트와 관련이 있다.

사용자가 로그인할 때마다 마지막 로그인 시간을 기록해야 하는 경우 아래와 같이 로직을 작성할 수 있다.

class User {
    private long lastLoginTimestamp;
    
    public void login() {
        // ...
        this.lastLoginTimestamp = Clock.systemUTC().millis();
    }
}

 

내부 로직을 보면 `login()`은 `Clock`에 의존적이다. 이런 경우를 의존성이 숨겨져 있다고 말한다. 외부에서 `login()` 메서드를 호출할 때는 이 메서드가 `Clock`을 사용하는지 알 수 없기 때문이다. 의존성이 숨겨져 있다는 것은 좋지 않은 신호다.

class UserTest {

    @Test
    public void login_test() {
        // given
        User user = new User();
        
        // when
        user.login();
        
        // then
        // assertThat(user.getLastLoginTimestamp()).isEqualTo(????);
    }
}

 

위와 같이 테스트 코드를 작성했을 때 마지막 로그인 시간을 어떻게 테스트할 수 있을까? `login()` 메서드 호출 시간과 결과를 비교하는 시점은 다를 수밖에 없다. 시간을 강제로 Stub 해주는 라이브러리를 사용하면 테스트 자체는 가능하지만 매우 부자연스럽다. 또 라이브러리가 없으면 테스트가 불가능하다. 이 시점에서 테스트가 불가능한데? Mock 프레임워크가 있어야 가능하겠는데? 하는 생각이 든다면 이는 테스트가 보내는 신호다. 

class User {
    private long lastLoginTimestamp;
    
    public void login(Clock clock) {
        // ...
        this.lastLoginTimestamp = clock.millis();
    }
}
class UserTest {

    @Test
    public void login_test() {
        // given
        User user = new User();
        Clock clock = Clock.fixed(Instant.parse("시간값"));
        
        // when
        user.login(clock);
        
        // then
        assertThat(user.getLastLoginTimestamp()).isEqualTo(시간값L);
    }
}

 

이 신호를 알아챌 수 있다면 동일한 동작을 하는 코드를 위와 같이 의존성 주입으로 수정할 수 있다. 그러나 다음 상황도 고려해야 한다. 만약 `login()` 메서드를  `UserService` 클래스에서 사용하고 있다면 이는 어떻게 테스트할 수 있을까?

class UserService {
    public void userLogin(User user) {
        // ..
        user.login(Clock.systemUTC());
    }
}
class UserServiceTest {
    
    @Test
    public void 유저_로그인_테스트() {
        // given
        User user = new User();
        UserService userService = new UserService();
        
        // when
        userService.userLogin(user);
        
        // then
        // assertThat(user.getLastLoginTimestamp()).isEqualTo(???);
    }
}

 

이 경우에도 의존성 주입으로 해결하려 하면 같은 상황이 계속 발생하게 된다. `userLogin()` 메서드를 사용하는 다른 메서드가 생긴다면 이 메서드는 다시 의존성이 숨겨져 있다는 문제를 가지게 된다. 의존성 주입은 테스트를 쉽게 만드는 테크닉 중 하나지만 모든 문제를 해결하는 은탄환은 아니다.

 

의존성 주입에 더해 의존성 역전으로 이 문제를 해결할 수 있다.

interface ClockHolder {

    long getMillis();
}
@Getter
class User {
    private long lastLoginTimestamp;
    
    public void login(ClockHolder clockHolder) {
        // ...
        this.lastLoginTimestamp = clockHolder.getMillis();
    }
}
@Service
@RequiredArgsConstructor
class UserService {

    private final ClockHolder clockHolder;
    
    public void userLogin(User user) {
        // ...
        user.login(clockHolder);
    }
}

 

`ClockHolder` 인터페이스에 현재 시간을 알려주는 메서드를 정의했다. 그러면 `User` 클래스의 `login()` 메서드는 `ClockHolder`라는 추상화에 의존한다. `UserService` 클래스는 `ClockHolder`를 멤버 변수로 들고 있다. 이 값을 `userLogin()` 메서드 호출 시 `login()` 메서드에 넘긴다.

 

`ClockHolder` 인터페이스를 구현하는 구현체를 두 개 만든다. 하나는 실제로 현재 시간을 반환하고, 다른 하나는 `Clock`을 그대로 반환한다. 테스트 시에는 `Clock`을 그대로 반환하는 구현체를 주입한다. 그러면 항상 같은 결과를 내려주는 일관된 테스트가 된다. 배포 환경과 테스트 환경을 분리할 수 있다.

 

 

Testability

소프트웨어가 테스트 가능한 구조인가?

이 질문은 다음과 같이 바꿀 수 있다. 쉽게 input을 변경하고, output을 쉽게 검증할 수 있는가? 

 

호출자가 모르는 입력이 존재하는 경우(감춰진 의존성) Testability가 낮다. 이 경우는 input을 외부에서 변경할 수 없다. 

@Getter
@Builder(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor
public class User {
    
    private final String username;
    private final String authToken;
    
    public static User create(String username) {
        return User.builder()
            .username(username)
            .authToken(UUID.randomUUID().toString())
            .build()
    }
}

 

위의 코드를 호출하는 외부 호출자의 입장에서는 위의 클래스를 다음과 같이 받아들인다.

@Getter
@Builder(access = AccessLevel.PRIVATE)
@RequiredArgsConstructor
public class User {
    
    private final String username;
    private final String authToken;
    
    public static User create(String username) {
       
    }
}

 

이 메서드에 대한 테스트를 작성해야 한다면 다음과 같다.

class UserTest {
    
    @Test
    void create() {
    
        // given
        String username = "username";
        
        // when
        User user = User.create(username);
        
        // then
        assertThat(user.getUsername()).isEqualTo("username");
    }
}

 

테스트를 진행하다보면 `create()` 메서드 내부에서 `authToken`이 필요하다는 것을 알게 된다. 코드를 타고 들어가 확인해보니 내부적으로 `authToken`을 생성하기 위해 UUID를 사용하고 있다. 이미 이 시점에서 캡슐화가 깨져버렸다. 게다가 내부 로직을 확인했음에도 만들어진 `authToken`의 값을 검증할 방법이 없다.

 

이를 보완하기 위해 라이브러리를 이용해 지정된 UUID를 반환하도록 변경한다. 될까? static 메서드를 Mocking 하기 위해 라이브러리를 끌어와 사용한다고 해도 UUID는 Final Class다. 당연히 Stub을 지원하지 않는다. 애초에 변경이 불가능하도록 선언했는데 가능할 수가 없다. 이 경우 역시 테스트가 설계 오류를 지적하고 있다.

 

하드 코딩이 된 경우 역시 Testability가 낮다. 고정된 파일 경로에 의존하게 되어 input을 변경하기 어렵다. 파일이 존재하지 않는 경우를 테스트할 수 없다. 

public class Example {
    private static final File FILE = new File("hard.txt");
    
    public void processData() {
        // read from FILE and process data
    }
}

 

외부 시스템 연동이 하드코딩 되어 있는 경우 역시 마찬가지로 Testability가 낮다. 예를 들면 `WebClient`나 `RestTemplate`를 서비스 클래스 내에서 직접 사용하는 경우 테스트가 어렵다. 이런 복잡한 클래스는 Stubbing 하기도 애매하다.

public class Example {
    public void processData(String Data) {
        String processedData = data.toUpperCase();
        sendDataOverNetwork(processedData);
    }
    
    private void sendDataOverNetwork(String data) {
        // HTTP를 이용한 네트워크 요청
    }
}

 

외부에서 결과를 볼 수 없는 경우 역시 Testability가 낮다. 아래와 같이 콘솔로 결과를 출력하는 경우 외부에서는 어떤 값이 출력되었는지 알 수 없다. 실제로 이렇게 하는 경우는 없지만...

public class Example {
    public void processData(int[] numbers) {
        int sum = 0;
        for (int number : numbers) {
            sum += number;
        }
        System.out.println("Sum: " + sum);
    }
}

 

 

 


 

  • private 메서드는 테스트하지 않아도 된다. private 메서드를 테스트 해야 할 것 같다는 생각이 든다면 사실 그 private 메서드는 private가 아니어야 하거나, 다른 클래스로 분리해 public으로 만들어야 한다. 
  • final 메서드를 stub해야 하는 상황은 피해야 한다. 이러한 상황이 생긴다면 역시 설계가 이상한 것이다. 애초에 final 메서드나 final 클래스는 더이상 변경을 하지 않겠다는 선언과 같다. 이를 강제로 stub 시켜야 하는 상황 자체가 말이 안 된다. 
  • 테스트를 할 때 만큼은 중복이 되더라도 가독성이 좋은 쪽이 낫다(DAMP).
  • 테스트 코드에는 논리 로직을 넣지 않은 쪽이 좋다. for문, if문, 덧셈, 뺄셈 같은 것들

 

 


Java/Spring 테스트를 추가하고 싶은 개발자들을 위한 오답노트 강의를 수강하며 개인적으로 필요한 부분을 정리한 글입니다. 좋은 강의입니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함