[테스트 주도 개발 시작하기] CHAPTER 7 - 대역

myeonji·2023년 1월 5일
0
  • 대역 필요성
  • 대역을 이용한 테스트
  • 대역 종류
  • 대역과 개발 속도

대역의 필요성

테스트를 작성하다 보면 외부 요인이 필요한 시점이 있다.
외부 요인이 테스트에 관여하는 주요 예는 아래와 같다.

  • 테스트 대상에서 파일 시스템을 사용
  • 테스트 대상에서 DB로부터 데이터를 조회하거나 데이터를 추가
  • 테스트 대상에서 외부의 HTTP 서버와 통신

테스트 대상이 이런 외부 요인에 의존하면 테스트를 작성하고 실행하기 어려워진다.

예시) 자동이체 기능을 예로 들어보자.
이 기능은 외부 업체가 제공하는 API를 이용해서 카드번호가 유효한지 확인하고 그 결과에 따라 자동이체 정보를 등록한다. 이 기능을 테스트하려면 정상 카드번호, 도난 카드번호, 만료일이 지난 카드번호가 필요하기 때문에 외부 업체에서 상황별로 테스트할 수 있는 카드번호를 받아와야 한다.
TDD는 "테스트 작성 -> 통과시킬 만큼 구현 -> 리팩토링"의 과정을 짧은 흐름으로 반복해야 하는데 외부 업체에서 상황별 카드번호를 제공하지 않으면 테스트를 진행할 수 없다.

외부 요인은 테스트 작성을 어렵게 만들 뿐만 아니라 테스트 결과도 예측할 수 없게 만든다.
예를 들어 카드 정보 검사 대행업체에서 테스트할 때 사용하라고 제공한 카드번호의 유효기간이 한 달 뒤일 수도 있다. 이 카드번호를 사용해서 성공한 자동이체 정보 등록 기능 테스트는 한 달 뒤에 유효 기간 만료로 실패하게 된다.

카드번호 검사기는 외부의 API를 호출
AutoDebitRegister -> CardNumberValidator ---------> 외부 카드 정보 API

AutoDebitRegister

public class AutoDebitRegister {
    private CardNumberValidator validator;
    private AutoDebitInfoRepository repository;

    public AutoDebitRegister(CardNumberValidator validator, AutoDebitInfoRepository repository) {
        this.validator = validator;
        this.repository = repository;
    }

    public RegisterResult register(AutoDebitReq req) {
        CardValidity validity = validator.validate(req.getCardNumber());

        if (validity != CardValidity.VALID) {
            return RegisterResult.error(validity);
        }

        AutoDebitInfo info = repository.findOne(req.getUserId());

        if (info != null) {
            info.changeCardNumber(req.getCardNumber());
        } else {
            AutoDebitInfo newInfo = new AutoDebitInfo(req.getUserId(), req.getCardNumber(), LocalDateTime.now());
            repository.save(newInfo);
        }

        return RegisterResult.success();
    }
}

CardNumberValidator

public class CardNumberValidator {
    public CardValidity validate(String cardNumber) {
        HttpClient httpClient = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://some-external-pg.com/card"))
                .header("Content-Type", "text/plain")
                .POST(HttpRequest.BodyPublishers.ofString(cardNumber))
                .build();

        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            switch (response.body()) {
                case "ok":
                    return CardValidity.VALID;
                case "bad":
                    return CardValidity.INVALID;
                case "expired":
                    return CardValidity.EXPIRED;
                case "theft":
                    return CardValidity.THEFT;
                default:
                    return CardValidity.UNKNOWN;
            }
        } catch (IOException | InterruptedException exception) {
            return CardValidity.ERROR;
        }
    }
}

AutoDebitRegisterTest

public class AutoDebitRegisterTest {
    private AutoDebitRegister register;

    @BeforeEach
    void setUp() {
        CardNumberValidator validator = new CardNumberValidator();
        AutoDebitInfoRepository repository = new JpaAutoDebitInfoRepository();
        register = new AutoDebitRegister(validator, repository);
    }

    @Test
    void validCard() {
        // 업체에서 받은 테스트용 유효한 카드번호 사용
        AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
        RegisterResult result = this.register.register(req);
        assertEquals(VALID, result.getValidity());
    }
    
    @Test
    void theftCard() {
        // 업체에서 받은 도난 테스트용 카드번호 사용
        AutoDebitReq req = new AutoDebitReq("user1", "1234567890123456");
        RegisterResult result = this.register.register(req);
        assertEquals(THEFT, result.getValidity());
    }
}

validCard() 테스트를 통과시키려면 외부 업체에서 테스트 목적의 유효한 카드번호를 받아야 한다. 만약 이 카드번호가 한 달 뒤에 만료되면 validCard() 테스트는 한 달 뒤부터 실패한다. 비슷하게 테스트 용도의 도난 카드 정보를 외부 업체에서 삭제하면 theftCard() 테스트는 통과하지 못하고 실패한다. 물론 외부 서비스가 개발 환경을 제공하지 않으면 테스트는 더욱 힘들어진다.

이렇게 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때는 대역을 써서 테스트를 진행할 수 있다.

영어로 된 테스트 관련 글을 읽으면 test double이란 표현이 자주 나오는데 여기서 double은 본 장에서 설명하는 대역에 해당한다. 즉, test double은 테스트에서 진짜 대신 사용할 대역을 의미한다. 대역 종류는 스텁, 가짜, 스파이, 모의 객체가 존재하는데 각 대역 종류마다 쓰임새가 다르다.

대역을 이용한 테스트

대역을 이용해서 AutoDebitRegister를 테스트하는 코드를 다시 작성해보자.
먼저 CardNumberValidator를 대신할 대역 클래스를 작성한다.

public class StubCardNumberValidator extends CardNumberValidator {
    private String invalidNo;

    public void setInvalidNo(String invalidNo) {
        this.invalidNo = invalidNo;
    }

    @Override
    public CardValidity validate(String cardNumber) {
        if (invalidNo != null && invalidNo.equals(cardNumber)) {
            return CardValidity.INVALID;
        }
        return CardValidity.VALID;
    }
}

StubCardNumberValidator는 실제 카드번호 검증 기능을 구현하지 않는다. 대신 단순한 구현으로 실제 구현을 대체한다.
validate() 메서드는 invalidNo 필드와 동일한 카드번호면 INVALD를 리턴하고 그렇지 않으면 VALID를 리턴한다.

StubCardNumberValidator를 이용해서 AutoDebitRegister를 테스트하는 코드를 작성한다.

public class AutoDebitRegister_Stub_Test {
    private AutoDebitRegister register;
    private StubCardNumberValidator stubValidator;
    private StubAutoDebitInfoRepository stubRepository;

    @BeforeEach
    void setUp() {
        stubValidator = new StubCardNumberValidator();
        stubRepository = new StubAutoDebitInfoRepository();
        register = new AutoDebitRegister(stubValidator, stubRepository);
    }

    @Test
    void invalidCard() {
        stubValidator.setInvalidNo("111122223333");

        AutoDebitReq req = new AutoDebitReq("user1", "111122223333");
        RegisterResult result = register.register(req);

        assertEquals(INVALID, result.getValidity());
    }
}

AutoDebitRegister 객체를 생성할 때 생성자로 StubCardNumberValidator 객체를 전달한다. 즉, AutoDebitRegister는 실제 객체 대신에 StubCardNumberValidator을 사용해서 카드번호가 유효한지 검사하게 된다.

invalidCard() 테스트 메서드는 stubValidator.setInvalidNo() 메서드를 이용해서 유효하지 않은 번호를 설정한다. StubCardNumberValidator#validate()setInvalidNo()로 지정한 번호에 대해 CardValidity.INVALID를 리턴하게 구현했으므로 결과적으로 유효하지 않은 카드번호에 대해 register() 메서드를 실행한다.

추가로, 도난 카드에 대한 테스트를 진행한다.
유효하지 않은 카드번호를 테스트 할 때와 동일하게 대역 클래스에 도난 카드번호를 지정하고 이를 비교하는 코드만 추가하면 된다.

StubCardNumberValidator에 추가

.
.
.

public void setTheftNo(String theftNo) {
        this.theftNo = theftNo;
}

@Override
public CardValidity validate(String cardNumber) {
    if (invalidNo != null && invalidNo.equals(cardNumber)) {
        return CardValidity.INVALID;
    }
    if (theftNo != null && theftNo.equals(cardNumber)) {
        return CardValidity.THEFT;
    }
    return CardValidity.VALID;
}

.
.

AutoDebitRegister_Stub_Test에도 테스트 코드를 추가한다.

@Test
void theftCard() {
    stubValidator.setTheftNo("1234567890123456");

    AutoDebitReq req = new AutoDebitReq("user1", "1234567890123456");
    RegisterResult result = register.register(req);

    assertEquals(CardValidity.THEFT, result.getValidity());
}

즉, 도난 카드번호를 처리할 수 있게 대역을 구현하고 그 대역을 이용해서 도난 카드번호에 대한 자동이체 기능을 테스트 한 것이다.

DB 연동 코드도 대역을 사용하기에 적합하다. 자동이체 정보의 DB 연동을 처리하는 리포지토리 인터페이스를 만든다.

public class AutoDebitInfoRepository {
    void save(AutoDebitInfo info);
    AutoDebitInfo findOne(String userId);
}

위에서 AutoDebitRegisterAutoDebitInfoRepository를 사용해서 자동이체 정보를 저장했다.
대역을 사용하면 DB 없이 AutoDebitRegister를 테스트할 수 있다.
AutoDebitInfoRepository의 대역 구현을 아래와 같이 한다.

public class MemoryAutoDebitInfoRepository implements AutoDebitInfoRepository {
    private Map<String, AutoDebitInfo> infos = new HashMap<>();

    @Override
    public void save(AutoDebitInfo info) {
        infos.put(info.getUserId(), info);
    }

    @Override
    public AutoDebitInfo findOne(String userId) {
        return infos.get(userId);
    }
}

위 코드는 DB 대신 맵을 이용해서 자동이체 정보를 저장한다. 메모리에만 데이터가 저장되므로 DB와 같은 영속성을 제공하지는 않지만, 테스트에 사용할 수 있을 만큼의 기능은 제공한다.

MemoryAutoDebitInfoRepository를 이용해서 테스트 코드를 작성해보자.

public class AutoDebitRegister_Fake_Test {
    private AutoDebitRegister register;
    private StubCardNumberValidator cardNumberValidator;
    private MemoryAutoDebitInfoRepository repository;

    @BeforeEach
    void setUp() {
        cardNumberValidator = new StubCardNumberValidator();
        repository = new MemoryAutoDebitInfoRepository();
        register = new AutoDebitRegister(cardNumberValidator, repository);
    }

    @Test
    void alreadyRegistered_InfoUpdated() {
        repository.save(new AutoDebitInfo("user1", "111222333444", LocalDateTime.now()));

        AutoDebitReq req = new AutoDebitReq("user1", "123456789012");
        RegisterResult result = this.register.register(req);

        AutoDebitInfo saved = repository.findOne("user1");
        assertEquals("123456789012", saved.getCardNumber());
    }

    @Test
    void notYetRegistered_newInfoRegistered() {
        AutoDebitReq req = new AutoDebitReq("user1", "1234123412341234");
        RegisterResult result = this.register.register(req);

        AutoDebitInfo saved = repository.findOne("user1");
        assertEquals("1234123412341234", saved.getCardNumber());
    }
}

첫 번째 테스트 메서드는 이미 자동이체 정보가 등록되어 있을 때 기존 정보가 올바르게 바뀌는지 검사하는 테스트 메서드이다.

첫 번째 테스트 메서드의 코드를 분석해보면,

  • 메모리를 이용한 대역 객체인 MemoryAutoDebitInfoRepository를 생성한다.
  • 테스트 대상인 AutoDebitRegister 객체를 생성할 때 대역을 전달한다.
  • 이미 자동이체 정보가 등록되어 있는 상황을 만들기 위해 대역을 사용한다. 사용자 아이디 "user1"에 대한 자동이체 정보를 저장한다. (repository.save() 부분이다.)
  • "user1" 아이디에 대해 다른 카드번호를 사용해서 자동이체 등록 기능을 실행한다.
  • 자동이체 등록 기능 실행 후 대역에 보관된 자동이체 정보를 구해서 값이 올바르게 변경되었는지 확인한다.

두 번째 테스트 메서드는 아직 자동이체 정보가 등록되어 있지 않을 때 새로운 정보가 올바르게 등록되는지 검사하는 메서드이다.
테스트 메서드를 실행할 때마다 (@BeforeEach를 붙인) setUp 메서드를 매번 실행하므로 repository 대역에는 아직 어떤 자동이체 정보도 등록되어 있지 않다. 이 상태에서 두 번째 메서드의 자동이체 등록 기능을 실행한다. 새로운 자동이체 정보를 등록했는데 대역에 보관된 자동이체 정보를 구해서 새로운 값이 올바르게 등록되었는지 확인한다.

대역을 사용한 외부 상황 흉내와 결과 검증

앞서 대역을 사용한 테스트에서는 다음 두 가지 없이 AutoDebitRegister에 대한 테스트를 수행했다.

  • 외부 카드 정보 API 연동
  • 자동이체 정보를 저장한 DB

StubCardNumberValidator를 사용해서 유효하지 않은 카드번호에 대한 테스트를 수행했다. 외부 업체에서 제공하는 카드 정보 API 연동 없이 AutoDebitRegister가 유효하지 않은 카드번호에 대해 올바르게 동작하는지 확인할 수 있었다.
비슷하게 도난 카드번호에 대한 테스트도 대역인 StubCardNumberValidator을 이용해서 진행할 수 있었다.

또한, 메모리를 사용한 MemoryAutoDebitInfoRepository를 이용해서 데이터가 올바르게 바뀌고 저장되는지 확인했다.
실제 DB를 연동하지 않고 AutoDebitRegister가 데이터 저장소에 데이터를 올바르게 반영하는지 확인할 수 있었다. DB를 연동하지 않고 메모리를 사용했기에 테스트 속도도 매우 빠르다.

  • StubCardNumberValidator : 카드 정보 API를 대신해서 유효한 카드번호, 도난 카드번호와 같은 상황을 흉내 낸다.
  • MemoryAutoDebitInfoRepository : 특정 사용자에 대한 자동이체 정보가 이미 등록되어 있거나 등록되어 있지 않은 상황을 흉내 낸다.

대역을 이용하면 외부에 대한 결과를 검증할 수 있다.
AutoDebitRegister는 자동이체 정보를 AutoDebitInfoRepository에 저장하는데 테스트 코드는 메모리를 이용한 대역을 사용해서 저장 결과를 확인한다.

대역의 종류

대역 종류설명
스텁(Stub)구현을 단순한 것으로 대체한다. 테스트에 맞게 단순히 원하는 동작을 수행한다. StubCardNumberValidator가 스텁 대역에 해당한다.
가짜(Fake)제품에는 적합하지 않지만, 실제 동작하는 구현을 제공한다. DB 대신에 메모리를 이용해서 구현한 MemoryAutoDebitInfoRepository가 가짜 대역에 해당한다.
스파이(Spy)호출된 내역을 기록한다. 기록한 내용은 테스트 결과를 검증할 때 사용한다. 스텁이기도 하다.
모의(Mock)기대한 대로 상호작용하는지 행위를 검증한다. 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있다. 모의 객체는 스텁이자 스파이도 된다.

예를 이용해서 대역을 살펴보자.
사용할 예는 회원 가입 기능이다. 회원 가입 기능을 구현할 UserRegister 및 관련 타입이 아래와 같다고 한다.

<<interface>>            <--------- UserRegister --------->        <<interface>>
WeakPasswordChecker                       |                                EmailNotifier
                                                    |
                                                    |
                                                    v
                                           <<interface>>
                                           UserRepository

각 타입은 다음 역할을 수행한다.

  • UserRegister : 회원 가입에 대한 핵심 로직을 수행한다.
  • WeakPasswordChecker : 암호가 약한지 검사한다.
  • UserRepository : 회원 정보를 저장하고 조회하는 기능을 제공한다.
  • EmailNotifier : 이메일 발송 기능을 제공한다.

이렇게 UserRegister를 위한 테스트를 만들어 나가는 과정에서 나머지 타입을 위한 대역으로 스텁, 가짜, 스파이, 모의 객체를 차례대로 사용할 것이다.

구현하기 전에 모든 기능을 설계하는 것은 불가능하다. 개발을 진행하는 동안에도 요구사항이 계속해서 바뀌기 때문이다. 그럼에도 불구하고 단위 기능을 구현하기에 앞서 어떤 구성 요소가 필요할지 고민하는 것은 의존 대상을 도출할 때 도움이 된다.

약한 암호 확인 기능에 스텁 사용

암호가 약한 경우 회원 가입에 실패하는 테스트부터 시작한다.
암호가 약한지를 UserRegister가 직접 구현하지 않고 WeakPasswordChecker를 사용하게 한다. 이렇게 하면 각 타입의 역할을 적절하게 분리할 수 있다.

테스트 대상이 UserRegister이므로 WeakPasswordChecker는 대역을 사용한다. 실제 동작하는 구현은 필요하지 않고 약한 암호인지 여부를 알려주기만 하면 되므로 스텁 대역이면 충분하다.

public class UserRegisterTest {
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();

    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(stubPasswordChecker);
    }

    @DisplayName("약한 암호면 가입 실패")
    @Test
    void weakPassword() {
        stubPasswordChecker.setWeak(true); // 암호가 약하다고 응답하도록 설정
        
        assertThrows(WeakPasswordException.class, () -> {
            userRegister.register("id", "pw", "email");
        });
    }
}

stubPasswordChecker.setWeak(true); 는 암호 확인 요청이 오면 암호가 약하다고 응답하라고 설정한다. 아직 구현 전이니까 그러한 목적으로 작성한 코드이다. 아직 WeakPasswordChecker 인터페이스가 제공할 메서드를 정하지 않았지만 이를 대신할 스텁은 setWeak() 메서드로 true를 전달받으면 암호가 약하다고 응답하게 구현할 것이다.

다음으로 WeakPasswordException, WeakPasswordChecker, StubWeakPasswordChecker, UserRegister 메서드를 만든다.

테스트를 실행하고 통과시키기 위해 UserRegister을 구현한다.

public class UserRegister {
    private WeakPasswordChecker passwordChecker;

    public UserRegister(WeakPasswordChecker passwordChecker) {
        this.passwordChecker = passwordChecker;
    }

    public void register(String id, String pw, String email) {
        if (passwordChecker.checkPasswordWeak(pw)) {
            throw new WeakPasswordException();
        }
    }
}

WeakPasswordChecker#checkPasswordWeak() 메서드를 이용해서 암호가 약한지 검사하고 있다. 여기서 checkPasswordWeak() 메서드는 UserRegister 입장에서 필요한 코드를 작성한 것이다.

public interface WeakPasswordChecker {
    boolean checkPasswordWeak(String pw);
}
public class StubWeakPasswordChecker implements WeakPasswordChecker {
    private boolean weak;

    public void setWeak(boolean weak) {
        this.weak = weak;
    }

    @Override
    public boolean checkPasswordWeak(String pw) {
        return weak;
    }
}

StubWeakPasswordChecker의 checkPasswordWeak() 메서드는 단순히 weak 필드 값을 리턴한다. 이 정도만 구현해도 UserRegister가 약한 암호인 경우와 그렇지 않은 경우에 대해 올바르게 동작하는지 확인할 수 있다.

단순히 익셉션을 발생하는 코드에서 암호 검사 뒤에 익셉션을 발생하도록 구현을 변경했으니 다시 테스트를 실행해서 통과되는 것을 확인한다.

앞서 공부했던 StubCardNumberValidator에 비하면 여기서 사용한 스텁은 더 구현이 단순하다. 두 경우 모두 대역은 테스트를 위해 필요한 값만 제공하면 되었고 이르 ㄹ위해 스텁 대역을 사용했다.

리포지토리를 가짜 구현으로 사용

다음 테스트로 동일 ID를 가진 회원이 존재할 경우 익셉션을 발생하는 테스트를 작성하자.

// 상황 : 동일 ID를 가진 회원 존재
상황을 만들기 위한 코드

// 실행 및 결과 검증
assertThrows(DupIdException.class, () -> {
	userRegister.register("id", "pw", "email");
});

UserRegisterTest에 추가하기

public class UserRegisterTest {
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();

    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(stubPasswordChecker, fakeRepository);
    }

... 생략

    @DisplayName("이미 같은 ID가 존재하면 가입 실패")
    @Test
    void dupIdExists() {
        // 이미 같은 ID 존재하는 상황 만들기
        fakeRepository.save(new User("id", "pw1", "email@email.com"));

        assertThrows(DupIdException.class, () -> {
            userRegister.register("id", "pw2", "email");
        });
    }
}

이후에 UserRepository, MemoryUserRepository, User 클래스를 생성하고 테스트에 통과할 수 있도록 컴파일 에러를 없애는 코드를 추가한다.
그 다음 이미 동일한 사용자가 존재하는 경우를 일반화하여 구현한다.

public class UserRegister {
    private WeakPasswordChecker passwordChecker;
    private UserRepository userRepository;

    public UserRegister(WeakPasswordChecker passwordChecker, UserRepository userRepository) {
        this.passwordChecker = passwordChecker;
        this.userRepository = userRepository;
    }

    public void register(String id, String pw, String email) {
        if (passwordChecker.checkPasswordWeak(pw)) {
            throw new WeakPasswordException();
        }
        User user = userRepository.findById(id);
        if (user != null) {
            throw new DupIdException();
        }
    }
}

다음은 중복 아이디가 존재하지 않을 경우 회원 가입에 성공하는 경우도 테스트 한다.
이 테스트도 가짜 대역을 사용한다.

public class UserRegisterTest {
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();

    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(stubPasswordChecker, fakeRepository);
    }

... 생략

    @DisplayName("같은 ID가 없으면 가입 성공함")
    @Test
    void noDupId_RegisterSuccess() {
        userRegister.register("id", "pw", "email");

        User savedUser = fakeRepository.findById("id"); // 가입 결과 확인
        assertEquals("id", savedUser.getId());
        assertEquals("email", savedUser.getEmail());
    }
}

회원 가입에 성공했는지 여부를 확인하려면 리포지토리에 새로운 사용자 정보가 올바르게 저장되었는지 확인하면 된다. 따라서 가짜 대역을 이용해서 저장된 User 객체를 구하고 이 객체가 원하는 값을 갖는지 검증한다.

TDD에서 속도 조절은 중요하다. 상수를 이용해서 테스트를 통과시킨 다음에 구현을 일반화할 방법이 떠오르지 않으면 예를 추가하면서 점진적으로 구현을 완성해 나가면 된다.

이메일 발송 여부를 확인하기 위해 스파이를 사용

회원 가입에 성공하면 이메일로 회원 가입 안내 메일을 발송한다고 하자.
이를 검증하기 위한 테스트 골격은 아래와 같다.

// 실행
userRegister.register("id", "pw", "email@somedomain.com");

// 결과
email@somedomain.com으로 이메일 발송을 요청했는지 확인

이메일 발송 여부를 확인할 수 있는 방법 중 하나는 UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 "email@somedomain.com"을 사용했는지 확인하는 것이다.
이런 용도로 사용할 수 있는 것이 스파이 대역이다. EmailNotifier의 스파이 대역을 이용한 테스트 코드를 만들 것이다.

회원 가입 시 이메일을 올바르게 발송했는지 확인하려면 EmailNotifier의 스파이 대역이 이메일 발송 여부와 발송을 요청할 때 사용한 이메일 주소를 제공할 수 있어야 한다.

public class SpyEmailNotifier implements EmailNotifier {
    private boolean called;
    private String email;

    public boolean isCalled() {
        return called;
    }

    public String getEmail() {
        return email;
    }
}
public class UserRegisterTest {
    private UserRegister userRegister;
    private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker();
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();
    private SpyEmailNotifier spyEmailNotifier = new SpyEmailNotifier();

    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(stubPasswordChecker, fakeRepository, spyEmailNotifier);
    }

... 생략

    @DisplayName("가입하면 메일을 전송함")
    @Test
    void whenRegisterThenSendMail() {
        userRegister.register("id", "pw", "email@email.com");

        assertTrue(spyEmailNotifier.isCalled());
        assertEquals("email@email.com", spyEmailNotifier.getEmail());
    }
}

컴파일 에러를 없앤 후 테스트를 실행하면,
org.opentest4j.AssertionFailedError: expected: <true> but was: <false> 라는 이유와 함께 assertTrue(spyEmailNotifier.isCalled()); 해당 코드에서 테스트에 실패한다.

이 단언을 통과하려면 다음 두 가지를 해야 한다.

  • UserRegister가 EmailNotifier의 이메일 발송 기능을 호출
  • 스파이의 이메일 발송 기능 구현에서 호출 여부 기록

먼저 UserRegister가 EmailNotifier의 이메일 발송 기능을 호출하는 코드를 추가한다.

아래 코드를 UserRegister에 추가한다.

emailNotifier.sendRegisterEmail(email);
public class SpyEmailNotifier implements EmailNotifier {
    private boolean called;
    private String email;

... 생략

    @Override
    public void sendRegisterEmail(String email) {
        this.called = true;
    }
}

called 필드를 true로 설정한 이유는 앞서 실패한 assertTrue를 통과시키기 위함이다.

테스트를 실행해본다. 이번에도 테스트에 실패하는데 그 이유는 아래와 같다.

expected: <email@email.com> but was: <null>
Expected :email@email.com
Actual   :null

따라서 테스트를 통과시키기 위한 코드를 스파이에 추가한다.

public class SpyEmailNotifier implements EmailNotifier {

... 생략

    @Override
    public void sendRegisterEmail(String email) {
        this.called = true;
        this.email = email;
    }
}

모의 객체로 스텁과 스파이 대체

앞서 작성한 테스트 코드를 모의 객체를 이용해서 다시 작성한다.
모의 객체를 위한 몇 가지 도구가 존재하는데 Mockito를 사용한다.

public class UserRegisterMockTest {
    private UserRegister userRegister;
    private WeakPasswordChecker mockPasswordChecker = Mockito.mock(WeakPasswordChecker.class);
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();
    private EmailNotifier mockEmailNotifier = Mockito.mock(EmailNotifier.class);

    @BeforeEach
    void setUp() {
        userRegister = new UserRegister(mockPasswordChecker, fakeRepository, mockEmailNotifier);
    }

    @DisplayName("약한 암호면 가입 실패")
    @Test
    void weakPassword() {
        BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true);
        
        assertThrows(WeakPasswordException.class, () -> {
            userRegister.register("id", "pw", "email");
        });
    }
}

Mockito.mock() 메서드는 인자로 전달 받은 타입의 모의 객체를 생성한다.
BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")).willReturn(true); 해당 코드는 모의 객체를 이용해서 스텁을 대신한다. 이 코드는 모의 객체가 다음과 같이 행동하도록 설정한다.

BDDMockito
 	// "pw" 인자를 사용해서 모의 객체의 checkPasswordWeak 메서드를 호출하면
    .given(mockPasswordChecker.checkPasswordWeak("pw"))
    // 결과로 true를 리턴하라
    .willReturn(true);

테스트 코드에서 UserRegister#register() 메서드는 모의 객체인 mockPasswordChecker를 사용하므로 결과적으로 약한 암호에 대한 테스트를 진행한다.

대역 객체가 기대하는 대로 상호작용했는지 확인하는 것이 모의 객체의 주요 기능이다. Mockito를 사용하면 모의 객체가 기대한대로 불렸는지 검증할 수 있다.

@DisplayName("회원 가입시 암호 검사 수행함")
@Test
void checkPassword() {
    userRegister.register("id", "pw", "email");

    BDDMockito.then(mockPasswordChecker)
            .should()
            .checkPasswordWeak(BDDMockito.anyString());
}
  • BDDMockito.then() : 인자로 전달한 mockPasswordChecker 모의 객체의
  • should() : 특정 메서드가 호출됐는지 검증하는데
  • 임의의 String 타입 인자를 이용해서 checkPasswordWeak() 메서드 호출 여부를 확인한다.

모의 객체를 사용하면 스파이도 가능하다.

@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
    userRegister.register("id", "pw", "email");

    ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
    BDDMockito.then(mockEmailNotifier)
            .should().sendRegisterEmail(captor.capture());
    
    String realEmail = captor.getValue();
    assertEquals("email@email.com", realEmail);
}

Mockito의 ArgumentCaptor는 모의 객체를 메서드를 호출할 때 전달한 객체를 담는 기능을 제공한다. BDDMockito.then().should()로 모의 객체의 메서드가 호출됐는지 확인할 때 ArgumentCaptor#capture() 메서드를 사용하면 메서드를 호출할 때 전달한 인자가 ArgumentCaptor에 담긴다.

상황과 결과 확인을 위한 협업 대상(의존) 도출과 대역 사용

0개의 댓글