독후감

[스프링] 테스트를 어렵게 하는 요소랄까

Choony 2024. 7. 24. 00:46

 

테스트 가능성이라는 단어를 들어봤나요? 영어 단어로 Testability라고 합니다.

테스트하기 쉬울수록 Testability가 높습니다. 더 나아가 테스트하기 쉬운 코드일수록 좋은 설계일 확률이 높습니다.

 

하지만 저를 포함한 많은 사람들이 테스트를 어려워하고 있습니다. 

테스트를 어렵게 만드는 요소는 무엇일까요?

 


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

테스트를 어렵게 만드는 요소의 결론부터 말하자면 테스트하려는 대상의 입력출력에 있습니다.

 

테스트는 테스트하려는 대상의 입력을 쉽게 변경할 수 있고, 출력을 쉽게 검증할 수 있어야 작성하기 쉽습니다.

하지만 숨겨진 입력이 존재하거나 숨겨진 출력이 존재한다면 테스트를 검증하기 어려워집니다.

 

그렇다면 숨겨진 입력숨겨진 출력이 대체 무엇일까요? 한번 알아봅시다.


숨겨진 입력

요구사항을 하나 들어봅시다.

사용자가 로그인하면 현재 시각을 '사용자의 마지막 로그인 시각'으로 기록해야 한다.

 

해당 요구사항을 반영하기 위해 코드를 작성해 봅시다.

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

 

이를 테스트해보려고 합니다.

@Test
public void 로그인을_호출한_경우_사용자의_마지막_로그인_시각이_갱신된다() {
	// given
    User user = user.builder()
    	.email("choon@naver.com")
        .build();
    
    // when
    user.login();
    
    // then
    long expected = Clock.systemUTC().millis();
    assertThat(user.getLastLoginTimeStamp()).isEqualsTo(expected);
}

 

이 테스트코드가 괜찮아 보이나요? 그렇다고 생각한다면 글을 한번 쭉 봐봅시다.

 

이 테스트코드는 좋은 테스트코드가 아닙니다. 왜냐하면 이 테스트는 비결정적으로 동작하기 때문입니다.

 

만약 성능이 매우 좋은 컴퓨터라면 이 테스트가 통과할 수도 있습니다. 하지만 그렇지 않은 컴퓨터에서는 이 테스트가 실패할 것입니다.

왜냐하면 테스트코드의 when 절에서 login 메서드를 실행하는 시점의 현재 시각과 then 절에서의 불러온 현재 시각이 다를 수 있기 때문입니다.

 

이 테스트 코드를 어떻게 하면 결정적으로 동작하게 할 수 있을까요?

 

코드부터 보도록 하겠습니다.

@Getter
public class User {
	private long lastLoginTimeStamp;
    
    	public void login(long currentTimestamp) {
    		// ...
        	this.lastLoginTimeStamp = currentTimestamp;
    	}
}

 

이 코드를 보자마자 숨겨진 입력이란 뭔지 번뜩이셨나요? 그랬다면 재능이 있습니다.

 

이 전의 코드를 보시면 매개변수가 하나도 없는 것을 볼 수 있습니다. 하지만 변경된 코드에서는 현재 시각을 매개변수로 받아오는데요. 

 

이 변경으로 인해 테스트 코드도 아래와 같이 변경할 수 있습니다.

@Test
public void 로그인을_호출한_경우_사용자의_마지막_로그인_시각이_갱신된다() {
	// given
    User user = user.builder()
    	.email("choon@naver.com")
        .build();
    
    // when
    long currentTimestamp = Clock.systemUTC().millis();
    user.login();
    
    // then
    assertThat(user.getLastLoginTimeStamp()).isEqualsTo(currentTimestamp);
}

 

이렇게 테스트코드가 구성되면 when절과 then 절에서의 현재 시각이 다를 일이 없으므로 결정적인 테스트를 구성할 수 있습니다.

 

코드를 보았으니 글을 한번 읽어보도록 합시다.

 

메서드의 매개변수란 이 메서드를 실행하는데 필요한 입력이 무엇인지 알려주는 수단이자 필요한 의존성이 무엇인지 알려주는 수단입니다.

// 변경 전
public void login()
// 변경 후
public void login(long currentTimestamp)

 

변경 전의 login 메서드에서는 아무런 입력과 의존성이 필요하지 않게 느껴지겠지만 실제 테스트를 짜보니 필요함을 느꼈습니다.

 

마지막 로그인 시각을 기록하기 위해 현재 시각을 알아야 하는데 매개변수에는 그런 의도가 전혀 보이지 않기 때문에 이를 숨겨진 입력이라 볼 수 있습니다.

 

즉, 메서드를 실행하는 데 필요하지만 외부에서는 이를 알 수 없는 감춰진 입력, 이를 숨겨진 입력이라 합니다.


숨겨진 출력

숨겨진 입력은 위에서 이해를 하고 넘어왔습니다. 하지만 숨겨진 출력은 무엇일까요?

 

또다시 요구사항을 봐보도록 하죠.

감사(audit)를 위해 로그인한 사용자가 있다면 이를 로그로 기록해야 한다.

 

이를 코드로 나타내봅시다.

@Getter
public class User {

	private String email;
	private long lastLoginTimeStamp;
    
    	public void login(long currentTimestamp) {
    		// ...
        	this.lastLoginTimeStamp = currentTimestamp;
            	System.out.println("User(" + email + ")login!");
    	}
}

 

이제 여러분은 이 코드를 테스트해야 합니다. 정확히는 로그인을 했을 때 출력 메시지가 원하는 대로 나오는지 확인해봐야 합니다.

 

하지만 어떻게 출력을 테스트할 수 있을까요? 방법이 떠오르나요? 그냥 눈으로 로그를 확인하고자 하나요..?

 

아쉽게도 테스트 검증 단계에서 이러한 부수적인 출력을 확인할 수 있는 길은 없습니다. 시스템 출력은 테스트 환경 밖에서 벌어지는 일이기 때문입니다. 그렇기에 이러한 출력은 자연스럽지가 않습니다.

 

우리는 인터페이스를 정의하면서 아래의 내용만을 사용해 메서드를 정의합니다.

  • 입력(매개변수)
  • 출력(반환값)
  • 시그니처(메서드 이름)

이는 인터페이스 계약이 이 세 가지만 이용해서 이뤄진다는 말과 동치입니다.

 

그래서 개발자가 메서드를 보고 알 수 있는 것도 이 세 가지가 전부입니다.

개발자는 입력, 출력, 시그니처만 가지고 메서드의 동작과 호출 결과가 어떨지 추론해야 한다는 것입니다.

 

그러니 메서드 호출의 출력 결과는 반환값을 통해 드러나는 것이 좋습니다.

 

즉, 숨겨진 출력이란 위의 코드처럼 메서드 호출 결과반환값이 아닌 경우를 말합니다.

 

이를 적용해 볼까요?

@Getter
public class User {

	private String email;
	private long lastLoginTimeStamp;
    
    	public String login(long currentTimestamp) {
    		// ...
        	this.lastLoginTimeStamp = currentTimestamp;
            	return "User(" + email + ")login!";
    	}
}

 

이런 방식으로 숨겨진 출력을 외부로 전달할 수 있게 됐습니다.


테스트가 보내는 신호

테스트를 작성하는 방법을 고민하다 보면 개발자가 느낄 수 있는 테스트가 보내는 신호가 몇 가지 있습니다.

  • 테스트의 입출력을 확인할 수 없는데? 이런 경우에는 어떻게 하지?
  • private 메서드는 어떻게 테스트해야 하지?
  • 서비스 컴포넌트의 간단한 메서드를 테스트하고 싶을 뿐인데, 이를 위해 필요도 없는 객체를 너무 많이 주입해야 하네?
  • 메서드의 코드 커버리지를 100% 달성하려면 테스트해야 할 케이스가 너무 많아지는데?

1, 2번에 대한 책에서 주는 해결법

 

이러한 고민과 생각들이 모두 테스트가 보내는 신호이며 개발자는 이를 포착한 것입니다.

이 신호들은 항상 같은 말을 하고 있습니다.

 

설계가 잘못됐을 확률이 높으니 좋은 설계로 변경해 봐

 

하지만 많은 개발자들이 이러한 신호를 포착하더라도 무시하게 됩니다.

테스트하기 어렵고 불편하다는 것을 느끼면서도 그냥 불편을 감수하고 테스트를 작성하게 됩니다.

 

이는 '테스트를 위해 구현 코드를 변경해야겠다'라는 생각을 하지 않는 것이며 구현 코드를 변경하지 않으면서 테스트를 강제로 할 수 있는 강력한 해결책을 찾아다닙니다. (예를 들어 자바의 Mockito, PowerMock과 같은...)

 

하지만 이런 생각은 좋지 못한 것 같습니다. 저희는 Mockito, PowerMock의 고수가 되는 것이 아니라 개발의 고수가 되고 싶은 거 아니겠습니까?

 

스프링을 계속하게 될 거란 보장도 없습니다. Nest.js나 Django를 하게 될 수도 있습니다. 이럴 때는 Mockito를 사용할 수 없게 됩니다. 그러니 신호를 무시하지 말고 받아들여보며 좋은 설계를 계속 고민해 보는 시간을 가져봅시다.

 

이런 시간을 이 책과 함께 해보는 건 어떨까요?

https://product.kyobobook.co.kr/detail/S000213447953

 

 

이 책에서 중간중간 주는 팁들이 있는데 아주 도움이 많이 됩니다...👍

중간중간 있는 꿀팁들!!