군만두의 IT 공부 일지

[스터디2] 10. 테스트 가능성 본문

프로그래밍/객체지향

[스터디2] 10. 테스트 가능성

mandus 2025. 2. 13. 02:10

목차

     

    15 . 테스트 가능성

    테스트를 '개발이 완료된 후 작성하는 것'이 아니라 '개발 전에 미리 작성하는 것', '개발을 하면서 함께 작성하는 것'으로 보기 시작하면, 개발자는 테스트를 어떻게 하면 쉽게 작성할 수 있을지 고민함으로써 코드의 품질을 높일 수 있다.

    요약
    1. Testability: '테스트 가능성'이라는 뜻으로, 테스트하기 쉬운 코드일수록 Testability가 높다.
    2. 테스트하기 쉬운 코드일수록 좋은 설계일 확률이 높다.

    15.1 테스트를 어렵게 만드는 요소

    • 테스트 가능성(Testability): '테스트하기가 얼마나 쉬운가?'를 뜻하는 용어
    • 어떤 코드가 테스트하기 쉬운 코드인지는 테스트하려는 대상의 입력과 출력에 있음.
    • 테스트는 테스트하려는 대상의 입력을 쉽게 변경할 수 있고, 출력은 쉽게 검증할 수 있을 때 작성하기 쉬움. 반면 테스트하려는 대상에 숨겨진 입력이나 숨겨진 출력이 있을 때 테스트를 검증하기 어려움.

    15.1.1 숨겨진 입력

    • 숨겨진 입력: 메서드를 실행하는 데 필요하지만 외부에서는 이를 알 수 없는 감춰진 입력. 외부 사용자가 코드를 사용할 때 코드가 어떤 식으로 동작할지 예상할 수 없게 만들어 테스트를 작성하기 어려움.

    시스템에 사용자가 로그인하면 현재 시각을 사용자의 마지막 로그인 시각으로 기록해야 하는 요구사항이 있다고 가정한다. login 메서드를 호출한 후 정말로 마지막 로그인 시각이 현재 시각으로 변경됐는지 확인하려고 한다.

     

    ☑️ 테스트 대상 코드

    // 마지막 로그인 시간을 현재 시간으로 바꾸는 코드
    @Getter
    @Builder
    public class User {
        private String email;
        private long lastLoginTimestamp;
    
        public void login() {
            this.lastLoginTimestamp = Clock.systemUTC().millis();
        }
    }

    다음과 같은 테스트 코드들을 고려할 수 있다.

     

    1. 유저 객체를 만들고 곧바로 user.login 메서드를 실행한다. 그리고 마지막에 user의 마지막 로그인 시각이 변했는지를 확인한다.

     비결정적으로 동작하기 때문에 좋은 테스트가 아니다.

     

    2. 로그인 시각이 '어떻게 변경됐는지' 확인하는 것이 아니라 '변경됐는지' 정도만 확인하는 것이라면 isGreaterThan(0) 같은 비교 메서드를 활용한다.

    3. ClocksystemUTCMockito 같은 프레임워크를 이용해 강제로 Stub으로 만든다.

     부정확해서 임시방편으로 사용할 수 있다.

     

    4. '어떻게 강제로 테스트할 수 있을까?'보다 '왜 이런 일이 발생했을까?'를 생각해본다. 위 코드는 숨겨진 입력 때문에 'Testability가 낮은 코드'이다. login 메서드는 의존성이 없는 것처럼 보이지만, 마지막 로그인 시각을 기록하기 위해 현재 시각을 알 수 있어야 한다. 따라서 Clock 클래스의 전역 메서드에 의존하고 있다.

     

    기존 코드는 Clock 클래스에 고정적으로 의존한다. 그리고 login 메서드가 Clock 클래스에 의존하고 있다는 사실을 외부에서 알 수 없다. 다음과 같이 의존하는 코드와 필요한 입력을 외부로 드러내는 형태로 작성한다.

     

    ✅ 개선된 테스트 대상 코드

    // 숨겨진 입력을 드러내도록 바꾼 login 메서드
    @Getter
    @Builder
    public class User {
        private String email;
        private long lastLoginTimestamp;
    
        public void login(long currentTimestamp) {
        	// ...
            this.lastLoginTimestamp = currentTimestamp;
        }
    }

    바뀐 코드는 현재 시각을 알기 위해 꼭 Clock 클래스를 사용하지 않아도 된다. login 메서드가 모종의 이유로 메서드 실행을 위해 현재 시각이 필요하다는 사실을 외부에서 알 수 있다.

     

    ✅ 테스트 코드

    // 숨겨진 입력이 드러나면 테스트하기 쉽다
    public class UserTest {
        @Test
        public void 로그인을_호출할_경우_사용자의_마지막_로그인_시간이_갱신된다() {
            // given
            User user = User.builder()
                .email("foobar@localhost.com")
                .build();
    
            // when
            long currentTimestamp = Clock.systemUTC().millis();
            user.login(currentTimestamp);
    
            // then
            assertThat(user.getLastLoginTimestamp()).isEqualTo(currentTimestamp);
        }
    }

     login 메서드를 실행할 때 현재 시각의 epochmillis를 메서드의 인수로 받기 때문에, 숨겨진 의존성이나 숨겨진 입력이 없다. 따라서 테스트를 작성하기 쉽다.

     

    테스트를 쉽게 하는 법을 고민하면서 '숨겨진 입력을 제거하라'라는 설계 원칙을 자연스럽게 따르게 되었다는 것을 알 수 있다. 테스트를 작성함으로써 코드를 더 작은 단위로 바라보게 되었다.

    1️⃣ 정리

    - 문제점:
       - Clock.systemUTC().millis();를 직접 호출하면 실행 시점마다 값이 다르게 나오므로, 테스트 실행 시마다 결과가 달라질 수 있음.
       - 비결정적(non-deterministic) 테스트를 유발함.
    - 해결책: 시간을 주입받도록 변경하여 테스트 가능성을 높임.

     

    구분 설명
    ☑️ 테스트 대상 코드(기존) Clock.systemUTC().millis(); 직접 호출 → 테스트가 어려움
     개선된 테스트 대상 코드 login(long currentTimestamp) → 테스트 시 현재 시간을 주입 가능
     테스트 코드 currentTimestamp를 직접 주입하여 테스트 가능성을 높임

     

    Clock 클래스 사용을 외부로 미뤘을 뿐이지 근본적인 문제가 해결된 것은 아니다. Clock 클래스를 직접 사용하는 의존성을 제거했지만, 여전히 UserServiceClock을 필요로 한다는 점에서 의존성이 존재하기 때문다.

     

    UserService 컴포넌트에서의 login 메서드도 테스트하기 어렵다는 문제가 있다. 이를 해결하기 위해서 의존성 주입의존성 역전을 동시에 사용한다.

     

    ✅ 테스트 대상 코드

    // 시간을 처리하는 컴포넌트를 의존성 주입으로 받는다.
    import org.springframework.stereotype.Service;
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    public class UserService {
        private final UserRepository userRepository;
        private final ClockHolder clockHolder;
    
        public User login(String email) {
            User user = userRepository.getByEmail(email);
            user.login(clockHolder.now());
            user = userRepository.save(user);
            return user;
        }
    }

    UserService 컴포넌트는 ClockHolder라는 타입의 변수를 의존성 주입을 통해 받는다.

    // 현재 시각을 알려주는 메서드가 있는 ClockHolder
    public interface ClockHolder {
        long now();
    }

    그리고 이 인터페이스를 구현하는 스프링 컴포넌트를 만든다. 작성된 SystemClockHolder 클래스를 스프링이 알아서 인스턴스화하고 의존성을 주입해줄 것이다.

    // SystemClockHolder 클래스 (ClockHolder 구현체)
    import org.springframework.stereotype.Component;
    import java.time.Clock;
    
    @Component
    public class SystemClockHolder implements ClockHolder {
        @Override
        public long now() {
            return Clock.systemUTC().millis();
        }
    }

     

    ClockHolder 인터페이스에 대한 Stub를 생성함으로써 ClockHolder 인터페이스가 고정된 값만 반환하는 코드가 됐고, 테스트가 결정적으로 변했다.

     

    ✅ 테스트 코드

    // 테스트에는 ClockHolder 역할이 Stub이 사용되게 할 수 있다.
    public class UserServiceTest {
    
        @Test
        public void 로그인을_호출할_경우_사용자의_마지막_로그인_시간이_갱신된다() {
            // given
            final long current = 1672498800000L; // 2023-01-01 00:00:00+09:00
            ClockHolder clockHolder = new ClockHolder() {
                @Override
                public long now() {
                    return current;
                }
            };
    
            UserRepository userRepository = new FakeUserRepository();
            userRepository.save(User.builder()
                .email("foobar@localhost.com")
                .build());
    
            // when
            UserService userService = UserService.builder()
                .userRepository(userRepository)
                .clockHolder(clockHolder)
                .build();
    
            User result = userService.login("foobar@localhost.com");
    
            // then
            assertThat(result.getLastLoginTimestamp()).isEqualTo(current);
        }
    }

    이 코드처럼 익명 클래스를 만들어 Stub를 생성하는 방법 말고, ClockHoder 역할에 사용될 대역을 사용하는 방식으로 시간이 흐르는 상황을 가정할 수도 있다.

    2️⃣ 정리

    - 문제점: Clock.systemUTC() 에 강하게 의존 → 테스트할 때 제어 불가능
    - 해결책: ClockHolder 인터페이스를 만들어 의존성 주입을 활용하여 테스트 가능성을 높임.

     

    구분 설명
    ☑️ 테스트 대상 코드(기존) Clock.systemUTC().millis(); 직접 호출 → 테스트가 어려움
     개선된 테스트 대상 코드 ClockHolder를 의존성 주입하여 테스트 가능성 증가
     테스트 코드 ClockHolder를 Stub으로 대체하여 시간을 제어할 수 있도록 개선

     

    그리고 의존성 주입과 의존성 역전을 사용한 코드는 선택적으로 컴포넌트를 사용할 수 있다. 의존성 주입과 의존성 역전을 사용하는 코드가 인터페이스와 책임에 집중하기 때문에, 구현체에 의존하지 않고 환경에 따라 다른 구현체를 실행되게 할 수 있다.


    즉, 테스트 시에도 항상 일정한 값을 반환하는 결정적 테스트(deterministic test)를 보장할 수 있다.

     

    테스트하기 쉬운 코드는 좋은 설계일 확률이 높다.
    - 어떤 코드가 더 나은 방식인지 고민된다면 테스트하기 쉬운 쪽을 선택하라.
    - 테스트하기 쉬운 코드라면 어떤 코드여도 괜찮다.

    15.1.2 숨겨진 출력

    • 숨겨진 출력: 메서드 호출 결과가 반환값이 아닌 경우. 반환값 외에 모든 부수적인 출력. 대표적으로 메서드 호출 결과로 시스템 출력이나 로그 출력이 발생하는 경우가 있음.

    사용자 로그인 예시를 확장하면, 감사(audit)를 위해 로그인한 사용자가 있다면 이를 로그로 기록해야 한다고 가정한다.

     

    ☑️ 테스트 대상 코드1

    // 숨겨진 출력이 있는 도메인 객체
    @Getter
    @Builder
    public class User {
    
        private String email;
        private long lastLoginTimestamp;
    
        public void login(ClockHolder clockHolder) {
            // ...
            this.lastLoginTimestamp = clockHolder.now();
            System.out.println("User(" + email + ") login!");
        }
    }
    User(foobar@localhost.com) login!

    사용자가 로그인하고 로그인한 내용을 위와 같은 메시지로 출력되길 원한다. 하지만 테스트 검증 단계에서 출력을 확인할 방법은 없다.

     

    인터페이스를 정의하면서 입력(매개변수), 출력(반환값), 시그니처(메서드 이름)만 사용해 메서드를 정의한다. 따라서 메서드 호출의 출력 결과는 반환값을 통해 드러나는 것이 좋다. 메서드 사용자 입장에서 메서드를 호출한 후 어떤 출력을 받을 수 있을지 예상할 수 있고, 실제 출력 결과를 바로바로 비교해 예상과 맞는지 확인할 수 있기 때문이다.

     

    테스트 대상 코드2

    // 숨겨진 출력을 가장 쉽게 드러내는 방법
    @Getter
    @Builder
    public class User {
    
        private String email;
        private long lastLoginTimestamp;
    
        public String login(ClockHolder clockHolder) {
            // ...
            this.lastLoginTimestamp = clockHolder.now();
            return "User(" + email + ") login!";
        }
    }

    1. login 메서드의 실행 결과에 로그로 출력할 메시지를 반환한다. 그리고 user.login 메서드를 호출하는 곳에 System.out 메서드나 로거(logger)을 이용해 로그를 출력하면 테스트하기 쉽다.

     

    login 메서드의 반환값이 String 타입이기 때문에 해결책이 미묘하다.

     

    2. 반환값을 위한 DTO를 만든다. DTO login의 반환값이 되게 만든다.

     

    ✅ 테스트 대상 코드3

    // login 메서드 반환용 DTO
    @Builder
    public class LoginSuccess {
        public final String auditMessage;
        // 다른 값이 더 있는 경우 추가로 작성...
    }
    // DTO를 반환하는 메서드
    @Getter
    @Builder
    public class User {
    
        private String email;
        private long lastLoginTimestamp;
    
        public LoginSuccess login(ClockHolder clockHolder) {
            // ...
            this.lastLoginTimestamp = clockHolder.now();
            return LoginSuccess.builder()
                .auditMessage("User(" + email + ") login!")
                .build();
        }
    }

    반환값에서 auditMessage 변수가 어떻게 만들어졌는지 확인만 하면 되므로 테스트하기 십다. 원래 메서드의 반환값이 이미 존재했어도 DTO에 함께 담으면 되기 때문에 해결이 가능하다.

     

    3. 이벤트라는 클래스를 만들고 메서드의 반환값으로 이벤트를 반환한다.

     

    1번, 2번, 3번 중 어떤 코드가 더 나은 코드인지는 정할 수 없다. 모두 테스트하기 쉬운 코드이기 때문이다. 함수형 프로그래밍은 부수 효과(숨겨진 입출력)를 최대한 줄이는 방향으로 프로그래밍하는 것다. 테스트하기 쉬운 코드를 만들면 다양한 설계 원칙들이 자연스럽게 적용될 것이다.

    3️⃣ 정리

    - 문제점:
       - System.out.println()을 사용하면 출력을 직접 검증할 방법이 없음테스트 불가능
       - 따라서 출력값을 반환하도록 수정하여 테스트 가능성을 높임.
    - 해결책:
       - 출력값을 반환값으로 변경
       - DTO(LoginSuccess)를 활용하여 확장 가능성을 높임.

     

    구분 설명
    ☑️ 테스트 대상 코드1(기존) System.out.println() 사용 → 출력 검증 불가능(테스트 어려움)
     테스트 대상 코드2(반환값 검증) 반환값을 검증하여 테스트 가능성을 높임
     테스트 대상 코드3(DTO 검증) DTO 내부의 auditMessage를 검증

    15.2 테스트가 보내는 신호

    Q. 테스트의 입출력을 확인할 수 없는데? 이런 경우에는 어떻게 하지?
    A. 테스트의 입출력을 확인할 수 있는 구조로 코드를 변경해야 한다. 숨겨진 입력이 존재한다면 외부로 드러내고, 숨겨진 출력이 존재한다면 반환값을 이용해서 출력되도록 변경한다.
    Q. private 메서드는 어떻게 테스트해야 하지?
    A. private 메서드는 테스트할 필요가 없다. 테스트하고 싶은 생각이 든다면 책임을 잘못 할당할 경우일 것이다.
    Q. 서비스 컴포넌트의 간단한 메서드를 테스트하고 싶을 뿐인데, 이를 위해 필요도 없는 객체를 너무 많이 주입해야 하네?
    A. 서비스 컴포넌트를 더 작은 단위로 나누라는 의미일 수 있다. 서비스 컴포넌트를 기능 단위로 더 세분화한다.
    Q. 메서드의 코드 커버리지를 100% 달성하려면 테스트해야 할 케이스가 너무 많아지는데?
    A. 긴 코드로 테스트 케이스가 너무 많아진다면 해당 메서드에 책임이 너무 많이 할당된 것일 수도 있다. 메서드를 분할하고 다른 객체에 일의 내용을 위임한다. 메서드의 코드 커버리지를 100%으로 유지하기 보다는 테스트가 책임을 제대로 수행하고 검증하고 있는지가 중요하다.

     

    테스트가 보내는 신호는 '설계가 잘못됐을 확률이 높으니 좋은 설계로 변경해 봐'라고 말한다. '코드 작성자' 입장에서 코드를 바라보는 것이 아니라 '코드 사용자' 입장에서 바라볼 수 있게 되기 때문에, 개발자는 코드를 알고리즘이 아닌 요구사항 위주로 바라볼 수 있다.

    테스트를 공부한다
    ≠ Mockito를 다루는 법을 공부한다
    ≠ Junit을 공부한다

     

    테스트하기 좋은 코드에 대해서 숨겨진 입출력에 대해 새로운 것들을 많이 배운 것 같다. 마지막으로 16장까지 학습한 후, 프로젝트에 테스트를 적용할 생각이다.

     

    이 글은 『자바/스프링 개발자를 위한 실용주의 프로그래밍』 책을 학습한 내용을 정리한 것입니다.
    Comments