Engineering

[테스트 주도 개발 시작하기] 7 ~ 8장

unknownomad 2023. 6. 15. 23:42

<7장> 대역

 

대역의 필요성

외부 요인이 테스트에 관여하는 예시

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

 

Double

대역

  • test double = 테스트에서 진짜 대신 사용할 대역
  • 테스트 대상에서 의존하는 요인 때문에 테스트가 어려울 때 대역 사용
  • = 스턴트들처럼 외부 요인으로 인해 테스트가 어려울 때 외부 요인을 대신하는 대역 사용

 

대역을 이용한 테스트

단순 구현으로 실제 구현 대체함

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 = this.register.register(req);

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

 

DB 연동 없이 메모리 사용하기에 테스트 빠름

  • 아래 예시에서는 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);
    }
}
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() {
        // repo.save()
        repository.save(
                new AutoDebitInfo("user1", "111222333444", LocalDateTime.now()));

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

        // repo.findOne()
        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);

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

 

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

  • 외부의 상황을 흉내냄
  • 외부에 대한 결과 검증 가능
@Test
void invalidCard() {
    // 유효하지 않은 카드 번호 상황 흉내
    stubValidator.setInvalidNo("111122223333");

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

    assertEquals(INVALID, result.getValidity());
}
@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());
}

 

대역의 종류

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

 

테스트 코드 작성 순서

  1. 상황(실행) ➡️ 검증(결과) 시나리오용 테스트 코드 작성
  2. 컴파일 에러 발생
  3. 컴파일 에러 제거
  4. 스텁 구현 알맞게 추가 및 일반화
  5. 테스트 재실행
  6. 최종 테스트
  7. 상수를 이용해 통과시킨 테스트 코드 개선

 

package user;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

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 weakPassword() {
        stubPasswordChecker.setWeak(true);

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

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

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

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

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

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

        // 결과 확인 위해 대역 이용
        assertTrue(spyEmailNotifier.isCalled());
        assertEquals(
                "email@email.com",
                spyEmailNotifier.getEmail());
    }
}

 

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

public class UserRegisterMockTest {

    private UserRegister userRegister;
    // Mockito.mock(): 인자로 전달받은 타입의 모의 객체 생성
    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
            // BDDMockito.given()
            // pw 인자를 사용해 모의 객체의 checkPasswordWeak 메서드 호출하면(상황)
            .given(mockPasswordChecker.checkPasswordWeak("pw"))
            // .willReturn(): 결과로 true 리턴(검증)
            .willReturn(true);

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

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

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

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

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

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

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

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

        // ArgumentCaptor: 메서드 호출 시 모의 객체를 전달받아 담는 기능
        ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
        // ArgumentCaptor#capture()
        // 테스트할 메서드 호출 시 전달한 인자가 ArgumentCaptor 에 담김
        BDDMockito.then(mockEmailNotifier).should().sendRegisterEmail(captor.capture());

        // ArgumentCaptor#getValue(): 보관한 인자 구하기
        String realEmail = captor.getValue();
        assertEquals("email@email.com", realEmail);
    }
}

 

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

  • 제어하기 힘든 외부 상황을 별도 타입으로 분리
  • 테스트 코드는 별도로 분리한 타입의 대역을 생성
  • 생성한 대역을 테스트 대상의 생서자 등을 이용해 전달
  • 대역을 이용해 상황 구성
@DisplayName("가입하면 메일을 전송함")
@Test
void whenRegisterThenSendMail() {
    userRegister.register("id", "pw", "email@email.com");

    // 결과 확인 위해 대역 이용
    assertTrue(spyEmailNotifier.isCalled());
    assertEquals(
            "email@email.com",
            spyEmailNotifier.getEmail());
}

 

점진적으로 테스트 코드 작성

  • 구현에 시간이 오래 걸리는 로직도 별도 타입으로 분리 시, 지금 당장은 미구현했어도 관련 테스트 통과시킬 수 있음
@DisplayName("약한 암호면 가입 실패")
@Test
void weakPassword() {
    assertThrows(WeakPasswordException.class, () -> {
        userRegister.register("id", "pw", "email");
    });

    assertThrows(WeakPasswordException.class, () -> {
        userRegister.register("id", "pw3", "email");
    });
    ...
    약한 암호 예시 추가하며 기능 구현
}

 

대역과 개발 속도

  • 대역 사용 시 의존하는 대상을 구현하지 않아도 테스트 대상 완성할 수 있음
  • 대기 시간 단축 및 개발 속도 향상에 기여
  • e.g.
    • DB 없어도 데이터가 올바르게 저장되는지 확인
    • 메일 서버가 없어도 이메일 발송 요청 확인

 

모의 객체 과하게 사용하지 않기

Mock overuse case

  • "리포지토리의 save() 메서드를 호출해야 하고, 이때 전달한 객체의 값이 어때야 한다" 식으로 결과 검증
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
    userRegister.register("id", "pw", "email");

    ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
    // save() 호출 여부 확인
    BDDMockito.then(mockRepository).should().save(captor.capture());

    // ArgumentCaptor 이용해 호출 시 전달한 인자 저장해야 함
    User savedUser = captor.getValue();
    assertEquals("id", savedUser.getId());
    assertEquals("email", savedUser.getEmail());
}

Better case

  • 메모리 이용한 가짜 구현 사용한 예시
  • "리포지토리에 저장된 객체의 값이 어때야 한다" 식으로 실제 검증 내용에 더 가까워짐
  • 결과 확인 코드가 더 단순해짐
  • 테스트 코드의 의미도 더 명확해짐
@DisplayName("같은 ID가 없으면 가입 성공함")
@Test
void noDupId_RegisterSuccess() {
    userRegister.register("id", "pw", "email");

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

 

모의 객체

  • 결과 값을 확인하는 수단으로 모의 객체 사용 시, 결과 검증 코드 길어지고 복잡해짐
  • 기본적으로 메서드 호출 여부를 검증하는 수단이기에, 테스트 대상과 모의 객체 간의 상호 작용이 조금만 바뀌어도 테스트가 깨지기 쉬움
  • 모의 객체의 메서드 호출 여부를 결과 검증 수단으로 사용하는 것 주의하기
  • 특히 DAO 나 리포지토리 같은 저장소에 대한 대역은 모의 객체 사용보단, 메모리를 이용한 가짜 구현이 테스트 코드 관리에 더 유리함

 


 

<8장> 테스트 가능한 설계

 

테스트가 어려운 코드

하드 코딩된 경로

  • 의존 객체를 직접 생성
  • 생성된 객체가 올바르게 동작하는데 필요한 모든 환경을 구성해야 함
  • DB 준비 및 필요 테이블 구축 필요
  • 테스트 시행 시 데이터가 DB 에 추가되기에, 같은 테스트 다시 실행 전, 기존 데이터 삭제 필요
public class PaySync {
    private PayInfoDao payInfoDao = new PayInfoDao();
    ...
}

 

정적 메서드 사용

  • 아래 예시 기준
  • AuthUtil 클래스가 인증 서버와 통신하는 경우, 이 코드를 테스트하려면 동작하고 있는 인증 서버 필요
  • AuthUtil 클래스가 통신할 인증 서보 정보를 시스템 프로퍼티에서 가져오면, 이도 테스트 환경에 맞게 설정해야
  • + 다양한 상황 테스트 위해 인증 서버에 저장된 유효한 ID/PW 사용해야 ...
public LoginResult login(String id, String pw) {
    int resp = 0;
    boolean authorized = AuthUtil.authorize(authKey);
    if (authorized) {
        resp = AuthUtil.authenticate(id, pw);
    } else {
        resp = -1;
    }
    ...
}

 

실행 시점에 따라 달라지는 결과

  • LocalDate 나 Random 이용한 값 사용 시, 생성된 값에 따라 실행 결과가 달라질 수 있음
LocalDate now = LocalDate.now();
int point = 0;
if (s.isFinished(now)) {
    point += p.getDefaultPoint();
} else {
    point += p.getDefaultPoint() + 10;
}

 

역할이 섞여 있는 코드

  • 테스트할 메서드 실행에 필요한 DAO 등

 

기타 테스트하기 힘든 상황

  • 메서드 중간에 소켓 통신 코드가 포함됨
  • 콘솔에서 입력을 받거나 결과를 콘솔에 출력함
  • 테스트 대상이 사용하는 의존 대상 클래스나 메서드가 final 임, 이 경우 대역으로 대체 어려움
  • 테스트 대상의 소스를 소유하고 있지 않아 수정 어려움

 

소켓 통신이나 HTTP 통신

  • 실제를 대체할 서버를 로컬에 띄워 처리 가능
  • 서버 수준에서 대역 사용한다 생각하면 됨

 

테스트 가능한 설계

하드 코딩된 상수를 생성자나 메서드 파라미터로 받기

  • 테스트 환경에 따라 경로를 다르게 줄 수 있는 수단이 없으면 테스트가 어려움
  • 그러므로 해당 상수를 교체할 수 있는 기능 추가하면 됨(생성자, 세터 등 통해 전달받기)
  • e.g.1. 세터 사용해 파일 경로를 필드에 보관
public class PaySync {
    
    private String filePath = "D:\\data\\pay\\cp0001.csv";

    public void setFilePath(String filePath) {
        this.filePath = filePath;
    }

    public void sync() throws IOException {
        Path path = Paths.get(filePath);
        ...
    }
}
@Test
void allDataSaved() throws IOException {

    PaySync paySync = new PaySync();
    paySync.setFilePath("src/test/resources/c0111.csv");

    paySync.sync();
    ...
}
  • e.g.2. 메서드 실행 시 인자로 전달받기
public void sync(String filePath) throws IOException {
    Path path = Paths.get(filePath);
    ....
}
@Test
void someTest() throws IOException {
    PaySync paySync = new PaySync();
    paySync.sync("/src/test/resources/c0111.csv");
    ...
}

 

의존 대상 주입 받기

  • 의존 대상은 주입 받을 수 있는 수단을 제공해 교체할 수 있도록
  • 생성자나 세터를 주입 수단으로 이용
  • e.g.1. 생성자 이용해 의존 대상 주입
public class PaySync {
    private PayInfoDao payInfoDao = new PayInfoDao();
    private String filePath = "D:\\data\\pay\\cp0001.csv";

    public PaySync(PayInfoDao payInfoDao) {
        this.payInfoDao = payInfoDao;
    }
    ...
}
  • e.g.2. 만약 레거시에서 생성자 없는 버전 사용 시, 기존 코드 그대로 유지 및 세터 이용해 의존 대상 교체
public class PaySync {
    private PayInfoDao payInfoDao = new PayInfoDao();
    private String filePath = "D:\\data\\pay\\cp0001.csv";

    public void setPayInfoDao(PayInfoDao payInfoDao) {
        this.payInfoDao = payInfoDao;
    }
    ...
}
  • 이후 테스트
public class PaySyncTest {
    // 대역 생성
    private MemoryPayInfoDao memoryDao = new MemoryPayInfoDao();

    @Test
    void allDataSaved() throws IOException {
        PaySync paySync = new PaySync();
        paySync.setPayInfoDao(memoryDao); // 대역으로 교체
        paySync.setFilePath("src/test/resources/c0111.csv");

        paySync.sync();

        // 대역 이용한 결과 검증
        List<PayInfo> savedInfos = memoryDao.getAll();
        assertEquals(2, savedInfos.size());
    }
}

 

테스트하고 싶은 코드 분리

  • Before
public int calculatePoint(User u) {
    Subscription s = subscriptionDao.selectByUser(u.getId());
    if (s == null) throw new NoSubscriptionException();
    Product p = productDao.selectById(s.getProductId());
    LocalDate now = LocalDate.now();
    int point = 0;
    if (s.isFinished(now)) {
        point += p.getDefaultPoint();
    } else {
        point += p.getDefaultPoint() + 10;
    }
    if (s.getGrade() == GOLD) {
        point += 100;
    }
    return point;
}
  • After
public class PointRule {

    public int calculate(Subscription s, Product p, LocalDate now) {
        int point = 0;
        if (s.isFinished(now)) {
            point += p.getDefaultPoint();
        } else {
            point += p.getDefaultPoint() + 10;
        }
        if (s.getGrade() == GOLD) {
            point += 100;
        }
        return point;
    }
}

 

시간이나 임의 값 생성 기능 분리

  • 테스트 대상이 사용하는 시간이나 임의 값을 제공하는 기능을 별도 분리해 테스트 가능성 높이기
  • 분리한 대상을 주입할 수 있게 변경
public class Times {

    public LocalDate today() {
        return LocalDate.now();
    }
}
public class DailyBatchLoader {

    private Times times = new Times(); // Times 의 대역 이용해

    public int load() {
        LocalDate date = times.today(); // DailyBatchLoader 가 사용할 일자 지정
        ...
    }
}
public class DailyBatchLoaderTest {

    private Times mockTimes = Mockito.mock(Times.class);
    private final DailyBatchLoader loader = new DailyBatchLoader();

    @BeforeEach
    void setUp() {
        loader.setBasePath("src/test/resources");
        loader.setTimes(mockTimes);
    }

    @Test
    void loadCount() {
        given(mockTimes.today()).willReturn(LocalDate.of(2019, 1, 1));
        int ret = loader.load();
        assertEquals(3, ret);
    }
}

 

외부 라이브러리는 직접 사용하지 말고 감싸서 사용

  • 외부 라이브러리가 정적 메서드 제공 시 대체 불가함
  • ➡️ 외부 라이브러리와 연동하기 쉬운 타입을 따로 만들기
  • ➡️ 테스트 대상은 분리한 타입 사용함으로써 외부 연동 필요한 기능을 쉽게 대역으로 대체 가능
  • 의존하는 대상이 Final 클래스이거나 의존 대상의 호출 메서드가 final 이어서 대역으로 재정의할 수 없는 경우에도 동일 기법 적용

Before

  • 아래 예시 : AuthUtil 클래스의 정적 메서드는 대역으로 대체하기 어려움
public LoginResult login(String id, String pw) {
    int resp = 0;
    boolean authorized = AuthUtil.authorize(authKey);
    if (authorized) {
        resp = AuthUtil.authenticate(id, pw);
    } else {
        resp = -1;
    }
    if (resp == -1) return LoginResult.badAuthKey();

    if (resp == 1) {
        Customer c = customerRepo.findOne(id);
        return LoginResult.authenticated(c);
    } else {
        return LoginResult.fail(resp);
    }
}

After

  • 대역으로 대체 어려운 외부 라이브러리는 직접 사용하지 말고, 그와 연동하기 위한 타입을 따로 만들기
public class AuthService {
    private String authKey = "somekey";

    public int authenticate(String id, String pw) {
        boolean authorized = AuthUtil.authorize(authKey);
        if (authorized) {
            return AuthUtil.authenticate(id, pw);
        } else {
            return -1;
        }
    }
}
public class LoginService {

    // AuthService 를 대역으로 대체
    private AuthService authService = new AuthService();

    public void setAuthService(AuthService authService) {
        this.authService = authService;
    }

    public LoginResult login(String id, String pw) {
        int resp = authService.authenticate(id, pw);
        ...
    }
}