ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 엘레강트 오브젝트 : Mock 대신 Fake 사용
    책책책 책을 읽읍시다/프로그래밍 2023. 8. 4. 00:09

     단위 테스트에서 Mock은 객체 의존 관계를 대신해주기 때문에 편리하다. 특히 repository에 의존하는 service나 domain을 테스트할 때 repository 의존성을 대신해주어 속도도 빠르고 복잡한 데이터를 조사할 필요도 없어진다. 하지만 과도하게 사용할 경우 테스트가 장황해지고 리팩터링 내성이 저하된다. Fake는 이런 문제를 해결해준다. 다음 자기 자신을 새로운 환율로 변환할 수 있는 Cash 클래스를 보자.

    public class Cash {
        private final Exchange exchange;
        private final int amount;
    
        public Cash(Exchange exchange, int amount) {
            this.exchange = exchange;
            this.amount = amount;
        }
        
        public Cash in(String currency) {
            return new Cash(
                    this.exchange,
                    (int) (this.amount * this.exchange.rate("USD", currency))
            );
        }
        
        @Override
        public String toString() {
            return String.valueOf(amount);
        }
    }

     Cash 클래스는 Exchange 클래스에 의존하고, Exchange 클래스는 USD를 EUR로 변환하는데 필요한 비율을 알고 있다. Cash 클래스를 사용하기 위해서는 Exchange의 인스턴스를 Cash의 생성자에 전달해야 한다.

    Cash dollar = new Cash(new NYSE("secret"), 100);
    Cash euro = dollar.in("EUR");

     여기에서 NYSE 클래스는 USD에서 EUR로 변환하기 위한 환율을 찾는 방법을 알고 있으며, 아마 뉴욕 증권 거래소(New York Stock Exchange)에 위치한 서버에 HTTP 요청을 전송해서 이 정보를 알아낼 것이다. NYSE 프로덕션 서버에 접속하기 위해 필요한 패스워드로 'secret'을 사용하고 있는데, 단위 테스트를 실행할 때마다 매번 NYSE 서버에 요청을 전송하고 싶지는 않다. 'secret'이라는 패스워드가 모든 프로그래머에게 노출되는 상황 역시 원하지 않는데, NYSE 서버가 개입하지 않은 상황에서도 Cash 클래스를 테스트할 수 있는 방법이 필요하다.

     전통적인 접근방식은 '모킹(mocking)'이다. NYSE를 사용하는 대신, Exchange 인터페이스에 대한 '모의 객체(mock)'을 생성한 후 Cash 생성자의 인자로 사용한다.

    class CashTest {
    
        @Test
        void toEuro() {
            Exchange exchange = Mockito.mock(Exchange.class);
            Mockito.doReturn(1.15f)
                    .when(exchange)
                    .rate("USD", "EUR");
            Cash dollar = new Cash(exchange, 500);
            Cash euro = dollar.in("EUR");
            assert "575".equals(euro.toString());
        }
    }

     모킹은 나쁜 프랙티스이며, 최후의 수단으로만 사용해야 한다. 모킹 대신 '페이크 객체(fake object)'를 사용하자. 다음은 사용자에게 전달할 Exchange 인터페이스의 최종 코드이다.

    public interface Exchange {
        float rate(String origin, String target);
    
        final class Fake implements Exchange {
            @Override
            public float rate(String origin, String target) {
                return 1.2345f;
            }
        }
    }

     중첩된 '페이크(fake)' 클래스는 인터페이스의 일부이며 인터페이스와 함께 제공된다. 이 페이크 클래스는 단위 테스트 안에서 Exchange를 쉽게 사용할 수 있도록 지원하기 때문에 가치있는 Exchange의 구성요소이다. 이제 모킹 대신 '페이크(fake)' 클래스를 사용한 단위 테스트를 살펴보자.

    @Test
    void toEuroWithFake() {
        Exchange exchange = new Exchange.Fake();
        Cash dollar = new Cash(exchange, 500);
        Cash euro = dollar.in("EUR");
        assert "617".equals(euro.toString());
    }

     '페이크' 클래스를 사용하면 테스트를 더 짧게 만들 수 있기 때문에 유지보수성이 눈에 띄게 향상된다. 반면에 모킹의 경우 테스트가 매우 장황해지고, 이해하거나 리팩토링하기 어려워진다. 실무 코드에서 모킹이 2~3개만 되는 경우도 많은데 가독성이 현저히 떨어지게 된다.

     단순히 장황함이 문제가 아니라 더 큰 문제는 따로 있다. 모킹은 가정 (assumption)을 사실(facts)로 전환시키기 때문에 단위 테스트를 유지보수하기 어렵게 만든다. 다음 코드를 다시 살펴보자.

    Mockito.doReturn(1.15f)
    .when(exchange)
    .rate("USD", "EUR");

     이 코드는 글자 그대로 "Cash가 Exchange.rate()를 호출하리라고 가정한다"라고 이야기한다. 전체 단위 테스트는 이 가정에 기반한다. 단위 테스트 안에서 Cash 클래스는 '블랙박스(black box)'이기 때문에 Cash 클래스 내부에서 실제로 Exchange.rate()가 호출되는 지는 정확하게 알 수 없다. 우리는 Cash.in() 메서드가 정확하게 어떤 방식으로 구현되어 있는지, 그리고 이 메서드가 Exchange 인스턴스를 정확하게 어떤 방식으로 사용하고 있는 지를 알 수 없다. Cash 클래스가 Exchange 인스턴스를 전혀 사용하지 않을 수도 있다. 우리는 불확실한 가정을 세우고 이 가정을 중심으로 전체 테스트를 구축하고 있다. 그리고는 이 가정을 사실로 바꿔버린다. 코드를 통해 "이것이 Cash의 작동 방식에 대해 우리가 알고 있는 내용이다"라고 이야기한다.

     

     리팩토링의 안전망이라는 단위 테스트의 전체적인 목적에 어긋나기 때문에 이런 방식은 매우 좋지 않다. 클래스의 행동이 변경되면 단위 테스트가 실패하기 때문에, 단위 테스트는 코드 리팩토링에 큰 도움이 된다(참 양성, true positive). 하지만 동시에 행동이 변경되지 않을 경우에는 실패해서는 안된다(거짓 양성, false positive). 바로 이것이 단위 테스트라는 전체 아이디어에서 매우 중요한 '나머지 절반'이다. 클래스의 공개된(public) 행동을 변경하지 않을 경우 단위 테스트는 실패해서는 안된다. 단위 테스트는 거짓 양성 지표를 제공해서는 안된다.

     어떤 상황에서 이런 문제가 발생하는지 Exchange 인터페이스를 다음과 같이 수정해보자.

    public interface Exchange {
        float rate(String target);
        float rate(String origin, String target);
    }

     한 개의 인자를 받는 첫 번째 메서드는 USD를 target으로 환전하는데 필요한 환율은 반환하고, 두 개의 인자를 받는 메서드는 origin과 target 통화를 둘 다 지정할 수 있도록 허용한다.

     이제 origin 통화가 USD일 경우 하나의 인자를 받는 새로운 rate 메서드를 사용하도록 Cash 클래스를 수정한다고 가정하자.

    public Cash in(String currency) {
    return new Cash(
    this.exchange,
    (int) (this.amount * this.exchange.rate(currency))
    );
    }

    이때 단위 테스트는 실패한다. 아무 것도 실패하지 않았지만, 테스트는 실패했다는 잘못된 신호를 보낸다. Cash 클래스는 여전히 잘 동작하고 통화를 올바르게 변환하며 모든 것이 완벽하게 정상인데도 테스트는 실패한다.

    모킹 테스트의 리팩터링 취약성

     이런 종류의 실패는 단위 테스트에 대한 신뢰를 완전히 무너뜨린다. 단위 테스트가 너무 쉽게 깨지고 불안정하다. 대부분으 ㅣ실패는 모킹 때문에 일어난다. 이제 동일한 상황에서 모킹 대신 '페이크' 클래스인 Exchange.Fake를 사용해보자.

    public interface Exchange {
        float rate(String target);
        float rate(String origin, String target);
    
        final class Fake implements Exchange {
            @Override
            public float rate(String target) {
                return this.rate("USD", target);
            }
            @Override
            public float rate(String origin, String target) {
                return 1.2345f;
            }
        }
    }

     '페이크' 클래스가 존재하는 상황에서 Exchange 인터페이스를 변경하기 위해서는 자연스럽게 Exchange.Fake 클래스의 구현도 함께 변경해야 한다. 하지만 단위 테스트는 변경하지 않아도 되고 깨지지도 않는다. 이 테스트는 훌륭한 단위 테스트이고 신뢰할 수 있다.

    리팩터링에 강한 Fake 테스트

     모킹은 클래스 구현과 관련된 내부의 세부사항을 테스트와 결합시킨다. 우리는 가정하고, 이 가정을 모의 객체 안에 하드코딩한 채, 작업을 끝내버린다. 시간이 흐르고 리팩토링을 할 시간이 됐을 때 테스트가 더 이상 유효하지 않은 내부 구현에 결합되어 있기 때문에 할 수 있는 일이라곤 테스트를 폐기처분하는 것밖에 없다.

     반대로 '페이크' 클래스를 사용하면 테스트를 충분히 유지보수 가능하게 만들 수 있다. Cash 클래스와 Exchange 클래스 사이의 의사소통 방식에 대해서는 신경 쓸 필요가 없기 대문이다. Cash 클래스를 단위 테스트할 때 두 클래스의 상호작용은 우리의 관심사가 아니다. 이 상호작용은 Cash의 사적인 관심사일 뿐이다. Cash 클래스가 Exchange 클래스와 의사소통할 수도 있고 하지 않을 수도 있다. 우리에게는 Cash의 내부 결정에 대해 어떤 것도 가정할 권리가 없다. 우리의 관심사는 Cash와 우리의 상호작용 방법이지 Cash가 다른 클래스와 상호작용하는 방법이 아니다.

     테스트가 객체 내부의 구현 세부사항을 알면 테스트가 취약해지고 유지보수하기도 어려워진다.

     게다가, 대부분의 모킹 프레임워크는 특정한 상호작용이 실제로 발생했는지 여부와 상호작용 횟수를 검증할 수 있는 긴능을 제공한다. 유용해 보일 수도 있지만, 단위 테스트를 상호작용에 의존하도록 만듦으로써, 리팩토링을 고통스럽게하기 때문에 나쁜 아이디어다. 객체와 의존 대상 사이의 상호작용 방식을 확인하거나 테스트해서는 안된다. 이것은 객체가 캡슐화해야 하는 정보이다. 다시 말해서 객체가 숨겨야 하는 비밀이다.

     

     

     

     

     

     

    댓글

Designed by Tistory.