개발하는 자몽

Java/Spring 테스트 - 2 본문

Java

Java/Spring 테스트 - 2

jaamong 2024. 11. 4. 20:42

지난 포스팅에 이어서 작성합니다.

2024.10.02 - [Java] - Java/Spring 테스트 - 1

 

 

의존성

의존성

  • 컴퓨터 공학에서 말하는 의존성(Dependency)은 결합(Coupling)과 같은 의미로, 다른 객체의 함수를 사용하는 상태를 말함   ⇒  A는 B를 사용하기만 해도 의존한다고 할 수 있음
  • 의존성을 약하게 만드는 기술 중 하나가 의존성 주입
    • 필요한 값을 `new`해서 직접 인스턴스화하는 것이 아닌 외부에서 넣어주는 것
      • `new`를 이용하여 인스턴스화 하는 것은 하드 코딩
    • 의존성 주입은 의존성을 약화시키는 것, 의존성을 완전히 없애는 방식이 아님
    • 의존성 제거 == 객체 간의 협력 부정 / 시스템 간의 협력 부정
    • 대부분의 디자인 패턴이나 설계는 어떻게 하면 의존성을 약화시킬 수 있는지 고민한 결과물

 

의존성 역전

      • 의존성 역전(DI) ≠ 의존성 주입(DIP)
        • Dependency Inversion ≠ Dependency Injection Principle
      • DIP
        • 의존성 역전이란?
          1. 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
          2. 추상화는 세부 사항에 의존해서는 안된다. 세부 사항이 추상화에 의존해야 한다.
        • 다형성의 원리 중 하나
        • 모듈 간의 상하 관계를 포함하는 개념
        • 화살표의 방향을 바꾸는 기술
        • 의존성 역전은 고수준 코드에서 세부 사항에 의존해서는 안됨  <RF. 로버트. C. 마틴 저, 클린 아키텍처>
        • `import`에 사용되는 경로에는 인터페이스나 추상 클래스만을 사용해야 함
        • 인터페이스나 추상 클래스 같은 추상적인 선언을 정책으로 볼 수 있고, 변동성이 큰 구체적인 요소를 세부 사항으로 볼 수 있음

 

의존성과 테스트 (의존성과 테스트의 상관관계)

테스트를 잘하려면 다음의 것을 잘 다룰 수 있어야 한다.

  1. 의존성 주입
  2. 의존성 역전

 

예시 | 마지막 로그인 시간

가정  사용자가 로그인할 때마다, 마지막 로그인 시간을 기록해야 함

class User {

	private long lastLoginTimestamp;
	
	public void login() {
		// ...
		this.lastLoginTimestamp = Clock.systemUTC().millis();
	}
}
  • 의존성이 숨겨져 있는 경우에 해당
    • 내부 로직에서 `login`은 `Clock`에 의존적
    • 하지만 외부에서 `login` 함수를 호출할 때는 `login`함수가 `Clock`을 사용하는지 모르기 때문
  • 일반적으로 의존성이 숨겨져 있으면 좋지 않은 신호
    class UserTest {
    
        @Test
        public void login_테스트() {
            // given
            User user = new User();
    				
            // when
            user.login();
    				
            // then
            assertThat(user.getLastLoginTimestamp()).isEqualTo(???);
        }
    }
    • 마지막으로 로그인한 시간을 비교해야 하는데 `???`에는 어떤 값이 들어가야 하는지 알 수 없음
    • `login`을 호출한 시간과 결과를 비교하는 시점의 시간은 다를 수밖에 없음 → 테스트 방법 X
      • 시간을 강제로 stub 해 주는 라이브러리가 있지만, 너무 부자연스러움 → 라이브러리가 없으면 테스트가 불가능함
    • 이러한 코드를 테스트하는 것은 불가능하고 일관되게 유지하는 것도 어려움
    ⇒ 이런 식으로 테스트 코드를 작성하면서 ‘이건 테스트가 불가능한데?’, ‘테스트가 mock 프레임워크 없이는 불가능한데?’ 라면 생각한다면, 이는 테스트가 보내는 신호!

 

 

예시의 문제점 해결

 

1. 의존성 주입으로 해결 - 시계를 외부에서 주입받는 경우

class User {

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

    @Test
    public void login_테스트() {
        // given
        User user = new User();
        Clock clock = Clock.fixed(Instant.parse("시간"), ZoneId.of("UTC"));
				
        // when
        user.login(clock);
				
        // then
        assertThat(user.getLastLoginTimestamp()).isEqualTo(milliseconds로 환산한 시간);
    }
}

필요한 값을 만들고, 그 값을 `login`에 전달함. 그다음 결과 값을 지정한 시간과 비교함.

 

🔹알게 된 점

  1. 숨겨진 의존성은 테스트하기 힘들게 만든다.
  2. 의존성은 드러내는 것이 좋다.

 

🔹문제점

  • 외부에서 주입받아 해결했는데, 결국 `login`을 사용하는 곳에서도 `Clock`이라는 숨겨진 의존성을 사용해야 함
    class UserService {
    		
        public void login(User user) {
            // ...
            user.login(Clock.systemUTC()); // 숨겨진 의존성인 Clock 사용 
        }
    }
  • 따라서 `UserService` 테스트가 어려워짐
    class UserServiceTest {
    	
        @Test
        public void login_테스트() {
            //given
            User user = new User();
            UserService userService = new UserService();
    				
            //when
            userService.login(user);
    				
            //then
            assertThat(user.getLastLoginTimestamp()).isEqualTo(???);
        }
    }
    • 이를 해결하기 위해 또 다른 의존성 주입을 사용하는 것은 해결방법이 아님 → `UserService`를 사용하는 또 다른 코드에 `Clock`이 숨겨지는 결과. 결국 어딘가에서는 고정된 값을 입력하여 넣어줘야만 함 → 또 테스트하기 힘들어짐 ⇒ 폭탄 돌리기

 

2. 의존성 역전으로 해결 - 현재 시간을 알려주는 인터페이스 정의

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 login(User user) {
        // ...
        user.login(clockHolder);
    }
}
  • `User`는 이제 `Clock`이 아닌 `ClockHolder`에 의존
  • `ClockHolder`는 외부에서 주입받도록 함
  • `UserService`는 `ClockHolder`를 멤버 변수로 받도록 되어 있음
    • 유저가 로그인할 때 `clockHolder` 값을 건네줌

 

🔹장점

우선 `ClockHolder`의 두 가지 구현체를 생성해 보자.

@Component
class SystemClockHolder implements ClockHolder {
		
    @Override
    public long getMillis() {
        return Clock.systemUTC().millis();
    }
}
  • 실제 배포 환경에서 사용할 `SystemClockHolder`
  • 현재 시간을 요청하면 시스템 시간을 반환
@AllArgsConstructor
class TestClockHolder implements ClockHolder {

    private Clock clock;
		
    @Override
    public long getMillis() {
        return clock.millis();
    }
}
  • 위 구현체는 객체를 생성할 때 `Clock`을 받도록 함

 

🔹실제 테스트

class UserServiceTest {

    @Test
    public void login_테스트() {
        // given
        Clock clock = Clock.fixed(Instant.parse("시간"), ZoneId.of("UTC"));
        User user = new User();
        UserService userService = new UserService(new TestClockHolder(clock));
				
        // when
        userService.login(user);
				
        // then
        assertThat(user.getLastLoginTimestamp()).isEqualTo(milliseconds로 환산한 시간);
    }
}
  • `UserService`를 생성할 때 `TestClockHolder`를 주입
    • `User`가 받게 될 `ClockHolder`는 지정한 `Clock`만 내려주는 `ClockHolder`가 될 것
    • ⇒ 쉽게 깨지지 않는 테스트 코드. 항상 같은 결과만 내려주는, 일관된 테스트가 됨
  • 배포 환경에서는 어떨까?
    • `SystemClockHolder`는 스프링 빈으로 등록되어 있기 때문에 스프링이 `UserService`를 만들 때 알아서 잘 주입해 줄 것.
  • 결과적으로 의존성 주입과, 의존성 역전 기술 모두 사용함
    • 의존성 역전을 사용했을 뿐인데, 배포 환경과 테스트 환경을 분리할 수 있게 됨 ⇒ 추상화 의존 결과
      • 의존성 역전 `Port-Adapter 패턴`으로도 부름

 

Testability

Testability는 테스트 가능성으로, 얼마나 쉽게 input을 변경할 수 있고, 얼마나 쉽게 output을 검증할 수 있는가를 의미한다.

 

얼마나 쉽게 Input을 변경...

  • Case1.1 - 의존성이 감춰져 있는 경우
    class User {
    
        private long lastLoginTimestamp;
    		
        public void login() {
            // ...
            this.lastLoginTimestamp = Clock.systemUTC().millis();
        }
    }
    • 위처럼 `Clock`같은 감춰진 의존성이 존재한다면 input을 외부에서 변경할 수 없음 ⇒ testability가 낮은 코드
  • Case1.2 - Input이 숨겨져 있는 경우
    @Getter
    @Builder(access = AccessLevel.PRIVATE)
    @RequiredArgsConstructor
    public class Account {
    		
        private final String username;
        private final String authToken;
    		
        public static Account create(String username) {
            return Account.builder()
                .username(username)
                .authToken(UUID.randomUUID().toString())
                .build();
        }
    }
      • `Account` 내부에는 인스턴스를 생성하는 팩토리 메서드가 있는데 괜찮을까?
    테스트 코드 1
    class AccountTest {
    
        @Test
        void create() {
    		
            //given
            String username = "foobar";
    				
            // when
            Account account = Account.create(username);
    				
            // then
            assertThat(account.getUsername()).isEqualTo("foobar");
        }
    }
    • `username` 말고도 `authToken`이 잘 생성되었는지 확인해야 함
    • `authToken`은 UUID로 생성 — "UUID가 사용된다는 이야기는 처음 듣는데요?" → 숨겨진 input: 호출자는 모르는 정보
      • 호출자가 해당 메서드를 타고 들어와서 내부 알고리즘을 확인함 → 객체지향의 핵심 가치 중 하나인 캡슐화가 깨짐
      • 알고리즘을 확인했어도 만들어진 `authToken` 값이 제대로 된 값인지 확인할 방법이 없음
    테스트 코드 2 - mock 프레임워크 이용해 보기: 강제 라이브러리들을 이용해서 지정된 UUID만 내려주도록 바꿔보자.
    class AccountTest {
    
        @Test
        void create() {
    		
            //given
            String username = "foobar";
            String expectedAuthToken = "...";
            PowerMockito.mockStatic(UUID.class);
            when(UUID.randomUUID()).thenReturn(UUID.fromString(expectedAuthToken));
    				
            // when
            Account account = Account.create(username);
    				
            // then
            assertThat(account.getUsername()).isEqualTo("foobar");
            assertThat(account.getAuthToken()).isEqualTo(expectedAuthToken);
        }
    }
    • UUID의 `randomUUID()`는 static 메서드
        • static 메서드를 mock 하는 건 `mockito` 만으로는 안됨 → java의 `PowerMock` 라이브러리를 활용해 봄
        • 하지만 찾아보니 UUID는 final class → 어떻게든 방법을 찾으면 해결할 수도 있음 but 이는 테스트가 보내는 신호를 무시하는 것; 잘못된 설계 
      ⇒ 의존성 역전으로 해결해 보자!
  • Case 2.1 - 파일 경로 하드 코딩
    public class Example {
        private static final File FILE = new File("data.txt");
    		
        public void processData() {
            //read from FILE and process data
        }
    }
    • File path가 고정되어 있다면, 고정된 파일에 지나치게 의존하게 돼서 input 변경이 힘듦 ⇒ 낮은 Testability
  • Case 2.2 - 외부 시스템 하드 코딩
    public class Example {
        public void processData(String data) {
            String processData = data.toUpperCase(); // 데이터 처리
            sendDataOverNetWork(processedData);
        }
    		
        private void sendDataOverNetwork(String data) {
            // HTTP를 이용한 네트워크 요청 
        }
    }
    • 스프링을 이용하다 보면 `WebClient`나 `RestTemplate` 같은 통신 클래스를 많이 사용하게 됨 → 이를 직접 사용하고 있을 것 → 테스트 어려움  

 

얼마나 쉽게 output을 검증

  • Case. 외부에서 결과를 볼 수 없는 경우
    public class Example {
        public void processData(int[] numbers) {
            int sum = 0;
            for (int number : numbers) {
                sum += number;
            }
            System.out.println("Sum: " + sum);
        }
    }
    • 외부에서 콘솔에 출력된 값이 어떤 값인지 확인할 수 없음 → 결과 확인 불가능 ⇒ 낮은 Testability

 

 

 

 

 

Comments