본문 바로가기
독서/자바,스프링 개발자를 위한 실용주의 프로그래밍

15. 테스트 가능성

by Thinking 2024. 11. 18.

12장 '자동 테스트'에서는 테스트가 필요한 이유가 무엇인지 얘기했습니다. 테스트가 중요한 이유로 '회귀 버그 방지'였습니다. 하지만 실제로 테스트는 추가로 이점을 제공합니다. 예로, 테스트를 이용하면 전체 시스템의 품질을 향상할 수 있습니다.

 

테스트가 품질을 보증할 수 있어도, 품질을 개선해줄 수 없을 것 같다고 생각할 수 있죠. 테스트는 이미 만들어진 코드에 추가로 더 작성되는 요소이기 때문입니다. 테스트 자체는 수단에 불과합니다. 테스트를 '개발이 완료된 후 작성하는 것'이 아니라 '개발 전 미리 작성하는 것', '개발을 하면서 함께 작성하는 것'으로 보면 이야기가 달라지죠. 개발자는 어떻게 테스트를 작성하면 쉽게 작성할 수 있을지 고민함으로써 코드의 품질을 높일 수 있습니다.

 

테스트를 '좋은 설계를 얻기 위한 수단'으로 보고 좋은 설계를 얻기 위해 테스트를 사용합니다. 크게 2가지 목적으로 '회귀 버그 방지', '좋은 설계를 얻기 위함' 있고, 회귀 버그 방지가 테스트를 사용할 때 얻을 수 있는 기능적 장점에 집중한 것이라면, 좋은 설계를 얻고자 테스트를 작성하는 것은 테스트를 사용할 때 얻을 수 있는 부수적인 가치에 더 집중하는 것이라 볼 수 있습니다.

 

이를 위해 꼭 알아야 하는 개념으로 Testability(테스트 가능성)라는 개념입니다. 테스트 가능성이라는 뜻으로, 테스트하기 쉬운 코드일수록 Testability가 높습니다. 테스트하기 쉬운 코드일수록 좋은 설계일 확률이 높습니다.

 

 

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

어떤 코드가 테스트하기 쉬운 코드이고, 어려운 코드일까요? 테스트하려는 대상의 입력과 출력에 있습니다. 테스트는 테스트하려는 대상의 입력을 쉽게 변경할 수 있고, 출력을 쉽게 검증할 수 있을 때 작성하기 쉽습니다. 반면 테스트하려는 대상에 숨겨진 입력이 존재하거나 숨겨진 출력이 있을 때 테스트를 검증하기 어려워집니다.

 

 

15.1.1 숨겨진 입력

예로, 사용자가 로그인하면 현재 시각을 '사용자의 마지막 로그인 시각'으로 기록해야 하는 요구사항이 있다고 가정하죠. 유저 객체를 만들어 user.login 메서드를 실행, 마지막에 user의 마지막 로그인 시각이 변했는지 확인합니다. 어떤가요? 이 테스트는 비결정적으로 동작합니다. 처음 login 메서드를 실행하는 시점의 현재시각과 마지막 로그인 시간을 검증하기 위해 불러온 현재 시각이 다를 수 있기 때문이죠. 마지막 로그인 시각이 '어떻게 변경됐는지' 확인하는 것이 아니라 '변경됐는지' 정도만 확인한다면, isGreaterThan(0) 같은 메서드를 활용하는 것입니다.

 

이 방법도 별로라면 Clokc의 systemUTC를 Mockito 프레임워크를 이용해 강제로 Stub으로 만드는 방법이 있을 수 있겠죠. 하지만 이 모두 부정확해서 임시방편으로 사용할 뿐입니다. 다른 접근이 필요한데, '왜 이런 일이 발생했을까?'를 생각해보죠. 이유는 해당 코드가 Testability가 낮은 코드이기 때문입니다. 

 

먼저 login 메서드에서, 메서드의 매개변수는 이 메서드를 실행하는 데 필요한 입력이 무엇인지 알려주는 수단이자 필요한 의존성이 무엇인지 알려주는 수단입니다. 앞의 Login 메서드에는 아무런 매개변수도 요구하지 않죠. 그래서 이를 사용하는 사용자 입장에서 이 메서드는 필요한 의존성이 없는 것처럼 보입니다. 내부 구현은 어떨까요?

 

사실 그렇지 않습니다. login 메서드는 마지막 로그인 시각을 기록하기 위해 현재 시각을 알 수 있어야 합니다. 그러한 까닭에 Clock 클래스의 전역 메서드에 의존하죠. 사실 Clock이라는 또 다른 입력이 있는 것입니다. 메서드를 실행하는 데 필요하지만 외부에서는 이를 알 수 없는 감춰진 입력, 이를 가리켜 숨겨진 입력이라고 합니다.

 

숨겨진 입력외부 사용자가 코드를 사용할 때 코드가 어떤 식으로 동작할지 예상할 수 없게 만듭니다. 명시된 입력에 같은 값을 넣어 같은 코드를 실행해도 다른 결과가 나오기 때문입니다. 코드 사용자가 코드를 제어할 수 없게 된다는 말이기도 합니다. 그래서 의존하는 코드와 입력이 있다면 이를 외부로 드러내는 것이 좋습니다. 이렇게 함으로써 아래와 같이 변하게 됩니다.

1) 앞서 말한 코드는 Clock 클래스에 고정적으로 의존합니다.

2) 뒤의 코드는 현재 시각을 알기 위해 꼭 Clock 클래스를 사용하지 않아도 됩니다.

 

1) 앞서 말한 코드의 login 메서드가 Clock 클래스에 의존하고 있다는 사실을 외부에서 알 수 없습니다.

2) 뒤의 코드 login 메서드가 모종의 이유로 메서드 실행을 위해 현재 시각이 필요하다는 사실을 외부에 알릴 수 있습니다.

 

주목할 만한 점은 '어떻게 해야 테스트를 쉽게 작성할 수 있을까?'를 고민했는데, '숨겨진 입력이나 숨겨진 의존성 때문에 테스트하기 힘들다'라는 사실을 발견했습니다. 그래서 숨겨진 입력을 외부로 꺼냈고, 코드는 테스트하기 쉬워졌고 유연해졌고 명료해졌습니다.

 

즉, 테스트를 쉽게 하는 법을 고민했더니 '숨겨진 입력을 제거하라'라는 설계 원칙을 자연스럽게 따르게 되었죠. 좋은 설계로 바꾸기 위해 어떤 설계 원칙을 사용하면 좋을지 고민하지 않았고, '어떻게 하면 테스트하기 쉬울까?'를 고민했습니다. 그랬더니 오히려 설계 원칙이 알아서 따라왔죠.

 

하지만 누군가는 이러한 코드 변경을 보고 이렇게 생각할 수 있습니다. 모든 문제가 해결되었나요? Clock 클래스 사용을 외부로 미뤘을 뿐이지 근본적인 문제가 해결된건가요? 맞는 말입니다. 결국 UserService 컴포넌트에서 똑같은 문제가 발생합니다. 즉, 숨겨진 의존성을 드러내는 것만으로 모든 문제를 해결하지 못합니다. 이때 바로 의존성 주입과 의존성 역전을 동시에 사용하는 것입니다.

 

ClockHolder라는 인테퍼이스를 만들고, 현재 시각을 알 수 있는 메서드를 만듭니다. UserService 컴포넌트는 ClockHolder라는 타입의 변수를 의존성 주입을 통해 받습니다. 다음으로, 이 인터페이스를 구현하는 스프링 컴포넌트를 만듭니다. 이렇게 작성된 UserSerivce 컴포넌트의 멤버 변수 clockHolder에는, 구현체인 SystemClockHolder 클래스를 스프링이 알아서 인스턴스화하고 의존성을 주입해 줄 것입니다.

 

의존성 역전이 중요한 또 다른 이유가 나옵니다. 의존성 주입과 의존성 역전을 사용한 코드는 선택적으로 컴포넌트를 사용할 수 있습니다. 의존성 주입과 역전을 사용하는 코드가 인터페이스와 책임에 집중하기 때문이죠. 구현체에 의존하지 않고, 책임에 따른 인터페이스에 의존하기 때문에 환경에 따라 다른 구현체가 실행되게 할 수 있습니다. 즉, 책임에 의존하면 같은 코드를 실행하면서도 배포 환경에서는 실제 구현체를 사용하게 만들고, 테스트 환경에서는 테스트 대역을 사용하도록 구성할 수 있습니다. 환경에 따라 교묘하게 컴포넌트를 갈아 끼울 수 있게 되는 것입니다.

 

테스트하기 쉬운 코드는 좋은 설계일 확률이 높다.

1) 어떤 코드가 더 나은 방식인지 고민된다면 테스트하기 쉬운 쪽을 선택해라.

2) 테스트하기 쉬운 코드라면 어떤 코드여도 괜찮다.

 

 

15.1.2 숨겨진 출력

코드에 숨겨진 출력이 있다는 것은 어떤 의미일까요? 만약 감사(audit)를 위해 로그인한 사용자가 있다면 이를 로그로 기록해야 한다고 가정하죠. 사용자가 로그인하고 로그인한 내용을 콘솔에 출력합니다. 만약 User(foobar@localhost.com) login! 출력 메시지를 확인하고 싶다면, 테스트 검증 단계에서 이런 부수적인 출력을 확인할 길이 없습니다. 로그 출력 결과는 표준 출력(System.out)을 확인해야 하기 때문인데, 시스템 출력은 테스트 환경 밖에서 벌어지는 일이기에 이러한 출력은 자연스럽지 않습니다.

 

인터페이스를 정의하며 입력(매개변수), 출력(반환값), 시그니처(메서드 이름)만을 사용해 메서드를 정의합니다. 이 3가지를 이용해 인터페이스 계약이 이뤄지고, 개발자가 메서드를 보고 알 수 있는 것도 이 3가지가 전부입니다. 개발자는 이 3가지만 보고 메서드의 동작과 호출 결과가 어떨지 추론해야 한다는 것입니다. 때문에 메서드 호출의 출력 결과는 반환값을 통해 드러나는 것이 좋습니다. 최대한 많은 정보를 메서드 사용자에게 힌트를 제공해야 하기 때문이죠. 그래야 메서드 사용자 입장에서 메서드를 호출한 후 어떤 출력을 받을 수 있을지 예상이 가능하고, 실제 출력 결과를 바로바로 비교해 예상과 맞는지 확인할 수 있습니다.

 

숨겨진 출력이란 메서드 호출 결과가 반환값이 아닌 경우를 가리킵니다. 즉, 반환값 외에 존재하는 모든 부수적인 출력을 숨겨진 출력이라고 합니다. 전역 변수를 변경해 숨겨진 출력을 만들거나, 메서드 호출 결과로 시스템이나 로그 출력이 발생하는 경우가 예이죠. 숨겨진 출력 메서드는 개발자가 메서드를 호출할 때 어떤 결과가 나올지 깊게 고민하도록 만듭니다. 메서드 호출 결과를 예측할 수 없게 되죠.

 

위의 사용자 로그인의 출력을 예로 보죠. @Before 애너테이션을 이용해 클래스에 정의된 모든 테스트를 실행하기 전에 실행되는 메서드를 정의합니다. System.out 출력을 다루기 쉬운 outStream으로 변경합니다. 결과적으로 System.out에 출력되는 값을 outStream을 통해 들여다볼 수 있게 됨으로써 검증 단계에서도 확인할 수 있습니다.

 

하지만 더 나은 해결책으로, 숨겨진 입력을 드러낸 것과 마찬가지로 '숨겨진 출력'을 드러낼 수 있습니다. 테스트하기 어려운 상태를 유지하면서, 위처럼 할 필요 없죠. 바로 반환값을 이용하는 것입니다. login 메서드의 실행 결과로 로그로 출력할 메시지를 반환합니다. user.login 메서드를 호출하는 곳에서 System.out 메서드나 로거를 이용해 로그를 출력하기만 하면 됩니다. 그런데 이 해결책은 미묘합니다. login 메서드의 반환값이 String 타입이기 때문이죠. 고작 로그 때문에 반환 타입을 이렇게 바꾸는 것은 일반적이지 않습니다.

 

그래서 또 다른 해결책으로 반환값을 위한 DTO를 만드는 것입니다. 반환용 DTO에는 감사 로그를 남기기 위한 auditMessage 필드가 있습니다. 이렇게 만들어진 DTO를 login 메서드의 반환값이 되게 만듭니다. 이제 테스트하기 쉬운데, 반환값에서 auditMessage 변수가 어떻게 만들어졌는지 확인만 하면 되기 때문입니다. 더불어 LoginSuccess DTO에는 어떤 값이든 추가로 더 담을 수 있습니다. 덕분에 원래 메서드의 반환값이 이미 존재했어도 이 구조에서는 해결이 가능합니다.

 

DTO 방안도 충분하지만, 또 다른 해결책을 제시해보죠. 이벤트라는 클래스를 만들고 메서드의 반환값으로 이벤트를 반환하는 것입니다. 즉, User 클래스의 login 메서드를 호출하면 이벤트 배열이 반환됩니다. 그리고 이벤트 배열에는 감사용 로그를 출력해야 한다는 메시지가 담겨있습니다. 이렇게 하면 외부에서 이 이벤트를 보고 로그를 출력할 수 있습니다. 아니면 아예 반환된 이벤트를 스프링의 ApplicationEventPublisher 같은 기능을 이용해 발행하면 더더욱 좋겠네요. 그리고 LogSystem 같은 컴포넌트를 만들고 이 컴포넌트에서 이벤트를 가져와 로그를 출력하도록 만드는 것입니다. 그렇게 개발하면 로그 출력을 한 군데에서 관리할 수 있게 됩니다.

 

유의해야 할 것은 숨겨진 출력을 드러내겠다고, 모든 메서드를 위처럼 사용하는 것은 배보다 배꼽이 큰 격입니다. 시스템에서 숨겨진 출력을 완전히 없애지 못합니다. 왜냐하면 결국 감사용 로그 메시지 값을 받아 System.out 출력하는 코드가 적혀있어야 하고, UserService 컴포넌트가 user 객체의 login 메서드를 호출한다면 감사 메시지를 출력하는 코드는 UserService 컴포넌트 쪽에 위치해야 합니다. 그러면 테스트하기 어려워지는 똑같은 문제가 발생합니다. System.out.println 코드가 UserService 컴포넌트에 위치하기 때문이죠.

 

또한 UserService가 user 객체의 login 메서드를 호출하고 반환값을 ApplicationEventPublisher로 발행하면 이는 반환값 이외의 출력에 해당하기 때문에 숨겨진 출력에 해당합니다. LogSystem이라는 컴포넌트도 반환값 이외의 출력인 System.out을 수행하는 컴포넌트가 되기 때문에 숨겨진 출력에 해당하는 컴포넌트가 되죠. 해당 문단에서는 UserService, 앞의 문단에서는 User 도메인에서의 위치기에 확실하게 이해하시기 바랍니다.

 

앞서 DTO, 이벤트 같은 코드를 작성해야 한다고 주장하는 것이 아니라, '숨겨진 출력이 어떻게 코드를 테스트하기 어렵게 만드는가?'입니다. 테스트하기 쉬운 방향을 고민했더니 숨겨진 출력을 없애는 방법을 고민하기 시작했다는 것이죠. 실제로 '숨겨진 출력을 줄여야 한다'라는 격언은 함수형 프로그래밍의 주요 목표 중 하나입니다.

 

함수형 프로그래밍에서는 이러한 숨겨진 입출력을 일컬어 '부수효과(side-effect)'라고 합니다. 함수에 부수효과가 있으면 '비순수 함수'라고 부르고, 없으면 '순수 함수'라고 부릅니다. 그래서 이 부수효과를 최대한 줄이는 방향으로 프로그래밍하는 것을 함수형 프로그래밍이라고 말합니다.

 

결국 앞서 반환값, DTO, 이벤트 모두 테스트하기 쉽기 때문에 '모두 좋다' 결과입니다.

 

 

15.2 테스트가 보내는 신호

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

1) 테스트의 입출력을 확인할 수 없는데? 어떻게 하지?

2) private 메서드는 어떻게 테스트하지?

3) 서비스 컴포넌트의 간단한 메서드를 테스트하고 싶은데, 이를 위해 필요 없는 객체를 너무 많이 주입해야하네?

4) 메서드의 코드 커버리지를 100% 달성하려면 할 케이스가 너무 많아지는데?

 

모든 생각들이 테스트가 보내는 신호입니다. 이는 그리고 한결같이 말하고 있습니다. 설계가 잘못됐을 확률이 높으니 좋은 설계로 변경해 봐

위 질문에 대한 답변을 생각해보죠.

[1-1] : 테스트의 입출력을 확인할 수 있는 구조로 코들를 변경해야 해요. 랜덤이나 시간 같은 숨겨진 입력이 존재한다면 외부로 드러내요. 숨겨진 출력이 존재한다면 반환값을 이용해서 출력되도록 변경하세요. 그렇게 해서 테스트 환경에서도 코드를 제어할 수 있게 변경하세요.

 

[2-1] : private 메서드는 테스트를 할 필요가 없습니다. private 메서드를 테스트하는 것은 내부 구현을 테스트하겠다는 것과 같은 말이죠. 우리는 책임을 기반으로 테스트를 작성해야 합니다. 인터페이스만 테스트해도 충분하죠. priavte 메서드를 테스트하고 싶은 생각이 든다면 이는 책임을 잘못 할당한 경우일 것입니다. 사실 public 메서드였어야 했다는 뜻이겠죠. 그러니 해당 메서드의 코드를 다른 객체에 할당하고, 그 메서드를 public으로 선언하세요. 기존 메서드는 해당 객체와 협력하도록 코드를 변경하세요.

 

[3-1] : 이는 '서비스 컴포넌트를 더 작은 단위로 나눠라'라는 의미일 수 있습니다. 서비스 컴포넌트를 기능 단위로 더 세분화해보세요.  예로, UserService 같은 서비스 컴포넌트를 만들기보다 UserRegister 같은 서비스 컴포넌트를 만드는 것입니다. 더 좁은 의미로 서비스 컴포넌트를 분할하다 보면 불필요한 의존성을 주입하기 위해 고생할 필요가 없어집니다.

 

[4-1] : 긴 코드로 테스트케이스가 많아진다면 해당 메서드에 책임이 너무 많이 할당된 것은 아닌지 고민해 보세요. 메서드를 분할하고 다른 객체에 일의 내용을 위임하세요. 메서드의 코드 커버리지를 100% 달성하는 것을 목표로 삼지 마세요. 이를 행하는 경우 다른 더 중요한 것들을 놓치게 되는 경우가 많습니다. 커버리지에 집착하기보다 테스트가 책임을 제대로 수행하고 검증하고 있는지 돌이켜보세요. 테스트 코드를 작성하는 목표는 시스템의 품질을 보장하고 개선하기 위함이지 코드 커버리지를 100% 만들기 위함이 아닙니다.

 

테스트를 작성하며 이러한 신호를 포착할 수 있는 이유는 '코드 작성자' 입장에서 코드를 바라보는 것이 아니라 '코드 사용자' 입장에서 바라볼 수 있게 되기 때문이죠. 시점의 변화로 알고리즘이 아닌 요구사항 위주로 바라볼 수 있게 됩니다. 또한 테스트 프레임워크를 사용하며 이 도구가 '어떤 작업을 대신하고 있는지 알아야 한다'를 짚고 넘어가면 되겠습니다.