본문 바로가기

Spring/Test

TDD, 대역을 이용한 테스트

1. 대역

test double는 대역을 뜻함

실제 기능을 구현하지 않고 단순한 구현으로 실제 구현을 대체

 

 

2. 대역의 종류

대역 설명
스텁(Stub) 구현을 단순한 것으로 대체
테스트에 맞게 단순 동작 기능 수행
가짜(Fake) 제품에 적합하지 않지만 실제 동작하는 구현 제공
스파이(Spy) 호출된 내역 기록
기록한 내용은 테스트 결과 검증 시 사용
스텁이기도 함
모의(Mock) 기대한대로 상호작용하는지 행위를 검증
기대한대로 동작하지 않을 시 Exception 발생할 수 있음
스텁이자 스파이

상위 타입 인터페이스를 만들고, 그 인터페이스를 상속받아서 구현

 

1) 스텁(Stub)

 

- 실제 기능 구현을 하지 않고 단순 동작 기능을 수행하는 Stub 대역

/**
 * CardNumberValidator(실제 기능 구현하는 클래스)의 대역
 * StubCardNumberValidator 실제 카드번호 검증 기능 구현하지 않음, 단순한 구현으로 실제 구현 대체
 */
public class StubCardNumberValidator extends CardNumberValidator {
    private String invalidNo;
    private String theftNo;

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

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

    /**
     * Validate() 는 invalidNo 필드와 동일 카드 번호: INVALID, 비동일 카드 번호면 VALID 리턴
     * @param cardNumber
     * @return
     */
    @Override
    public CardValidity validate(String cardNumber) {
        // invalidNo(무효한 NO) 필드와 동일 카드 번호 => 무효한 카드 번호
        if (invalidNo != null && invalidNo.equals(cardNumber)) {
            return CardValidity.INVALID;
        }

        // 도난 카드 번호
        if (theftNo != null && theftNo.equals(cardNumber)) {
            return CardValidity.THEFT;
        }

        // invalidNo(무효한 NO) 필드와 비동일 카드 번호 => 유효한 카드 번호
        return CardValidity.VALID;
    }
}

 

 

2) 가짜(Fake): DB 대신 맵을 이용해서 테스트(DB 대신 메모리를 이용해서 구현한 Repository)

/**
 * DB 없이 테스트하기 위해 맵을 이용해서 자동이체 정보를 저장
 * 메모리에만 데이터가 저장되므로 DB 와 같이 데이터 영속성을 제공하지는 않지만 테스트에 사용할 수 있을 만큼의 기능은 제공됨
 */
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);
    }
}

 

 

3) 스파이(Spy): 호출된 내역을 저장하고 저장한 값으로 테스트 검증

/**
 * DB 없이 테스트하기 위해 맵을 이용해서 자동이체 정보를 저장
 * 메모리에만 데이터가 저장되므로 DB 와 같이 데이터 영속성을 제공하지는 않지만 테스트에 사용할 수 있을 만큼의 기능은 제공됨
 */
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);
    }
}

 

4) Mock(모의) 

Mockito: 모의 객체 생성, 검증, 스텁을 지원하는 프레임워크

 

의존 설정

 

모의 객체 생성

Mockito.mock() 메소드 이용하여 특정 타입의 모의 객체 생성

 

 

Mockito.mock() 메소드는 클래스, 인터페이스, 추상 클래스에 대해 모의 객체 생성할 수 있음

 

스텁 설정

모의 객체 생성 후 BDDMockto 클래스를 이용하여 모의 객체에 스텁을 구성할 수 있음

 

BBDMockito.given() 메소드를 이용하면 모의 객체 메소드가 특정 값을 리턴하도록 설정할 수 있음

 

 

제공 메소드

anyInt(), anytShort(), anyLong(), anyByte(), anyChar(), anyDouble(), anyFloat(), abyBoolean(): 기본 데이터 타입에 대한 임의 값 일치

anyString(): 문자열에 대한 임의 값 일치

any(): 임의 타입에 대한 일치

anyList(), anySet(), anyMap(), anyCollectio(): 임의 콜렉션에 대한 일치

matches(String), matches(Pattern): 정규표현식을 이용한 String 값 일치 여부

eq(값): 특정 값과 일치 여부

 

행위 검증

모의 객체의 역할 중 하나는 실제로 모의 객체가 불렸는 지 검증 하는 것

메소드 호출 검증 Mockito 클래스가 제공하는 메소드

only(): 한 번만 호출

time(int): 지정한 횟수만큼 호출

never(): 호출하지 않음

atLeast(int): 적어도 지정한 횟수만큼 호출

atLeastOnce(): atLeast(1)과 동일 

atMost(int): 최대 지정한 횟수만큼 호출

 

인자 캡처

모의 객체를 호출할 때 사용한 인자를 검증해야 할 때가 있음

많은 속성을 가진 객체는 쉽게 검증하기 어려움, 이럴 때 인자 캡처를 사용 함

 

Mockito의 ArgumentCaptor를 사용하면 메소드 호출 여부를 검증하는 과정에서 실제 호출할 때 전달할 인자를 보관할 수 있음

 

 

 

제어하기 힘든 외부 상황 존재 시 다음과 같은 방법으로 의존을 도출하고 이를 대역으로 대신함

제어하기 힘든 외부 상황을 별도 타입으로 분리

테스트 코드는 별도로 분리한 타입의 대역을 생성

생성한 대역을 테스트 대상의 생성자 등을 이용해서 전달

대역을 이용해서 상황 구성

 

대역을 상황하면 실제 구현 없이 실행 결과를 확인할 수 있음

 

모의 객체는 스텁과 스파이를 지원하므로 대역으로 모의 객체를 많이 사용함

하지만 모의 객체의 과도한 사용은 오히려 테스트 코드가 복잡해지는 경우도 발생

 

/**
     * 모의 객체 사용 케이스
     * 리포지토리의 save()메소드 호출, 전달한 값 저장, 저장한 값으로 검증
     * 대역 클래스를 만들지 않아서 처음엔 편할 수 있지만 결과값을 확인하는 수단으로 모의 객체를 사용하면 결과 검증 코드가 길어지고 복잡해짐
     * 하나의 테스트를 위해 여러 모의 객체 사용 시 결과 검증 코드의 복잡도 증가
     * 모의 객체는기본적으로 메소드 호출 여부를 검증하는 수단이기 때문에 테스트 대상과 모의 객체 간 상호 작용이 조금만 바뀌어도 테스트가 깨질 수 있음
     */
@DisplayName("같은 ID가 없으면 가입 성공함")
    @Test
    void noDupId_RegisterSuccess() {
        userRegister.register("id", "pw", "email");

        ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
        BDDMockito.then(mockRepository).should().save(captor.capture()); // 성공 여부 확인을 위해 메소드 호출 여부 검증 & sqve 메소드 호출 시 전달한 인자 저장

        User savedUser = captor.getValue(); // 저장한 객체를 이용해서 값이 유효한 지 검증
        assertEquals("id", savedUser.getId());
        assertEquals("email", savedUser.getEmail());
    }
    
    
    /**
     * 메모리를 이용한 가짜 구현 시 코드가 단순해 짐 UserRegisterMockOvercaseTest 와 비교
     * 가짜 구현 사용 케이스
     * 리포지토리에 저장된 객체의 값은 이것이다로 검증 -> 실제 검증할 내용과 가까움
     * DAO 나 리포지토리 같이 저장소에 대한 대역은 모의 객체 사용보다 메모리르 이용한 가짜 구현을 사용하는 것이 테스트 코드 관리에 유연
     * 처음에는 가짜 대역 구현이 번거로울 수 있으나 가짜 대역 구현하면 모의 객체를 사용할 때 보다 테스트 코드가 간결하고 관리가 쉬워짐
     */
    @DisplayName("같은 ID가 없으면 가입 성공함")
    @Test
    void noDupId_RegisterSuccess() {
        userRegister.register("id", "pw", "email");

        User savedUser = fakeRepository.findById("id");
        assertEquals("id", savedUser.getId());
        assertEquals("email", savedUser.getEmail());
    }

 

 

 

 

 

참고: 최범균의 테스트 주도 개발(TDD) 시작하기

 

'Spring > Test' 카테고리의 다른 글

[Springboot] SpringBoot 테스트 @SpringBootTest  (0) 2022.12.19
AssertJ  (0) 2022.12.01
TDD, 테스트 코드의 구성  (0) 2022.11.30
TDD 테스트 주도 개발, 기능 명세와 설계  (0) 2022.11.27
TDD 테스트 코드 작성 방법  (0) 2022.11.27