스프링 테스트(3) - JUnit 의존 관계가 있는 클래스의 단위 테스트

2024. 2. 24. 16:12spring/TEST

과제

애플리케이션 모듈을 따로 분리해 테스트한 후 다시 조합해서 테스트하는 것이 가장 흔한 테스트 방식이다. 이 방식으로 애플리케이션을 테스트 해보자

 

해결책

단위 테스트의 쓰임새는 하나의 프로그램 단위를 테스트하는 것이다. 객체 지향 언어에서 단위란 보통 클래스나 메서드를 가리킨다. 단위 테스트의 범위는 하나의 단위 하나로 국한되지만 실제로 단위가 홀로 움직이는 일은 거의 없고 다른 단위와 함께 작동되는 경우가 대부분이다. 다른 단위와 의존 관계를 지닌 단위를 테스트할 때에는 보통 스텁이나 목 객체로 단위 간 의존 관계를 모방해서 테스트의 복잡도를 낮춘다.

 

스텁은 테스트에 필요한 최소한의 메서드만으로 의존 객체를 시뮬레이션한 객체로, 보통 메서드는 사전에 정해진 로직으로 하드 코딩한 데이터를 이용해 구현한다. 스텁은 테스트 코드가 그 내부 상태를 확인할 수 있도록 어떤 메서드를 표출한다. 스텀과 대조적으로, 

 

목 객체는 자신의 메서드를 테스트 코드가 어떤 식으로 호출할 거라 기대한다.따라서 실제로 호출된 메서드와 호출되리라 기대했던 메서드를 비교 검증할 수 있다.

 

스텁은 대개 상태를 검증할 때 쓰이며 목 객체는 수행 로직을 검증할 때 쓰인다는 점에서 차이가 있다.

 

의존 관계가 있는클래스 대한 단위 테스트 작성하기

혼자 떨어진 클래스는 의존체의 작동 로직, 설정 방법 등을 신경 쓸 필요가 없어 테스트하기 쉽지만 다른 클래스나 서비스의 결과에 의존하는 서비스(DB, 네트워크)의 결과에 의존하는 클래스는 조금 까다롭다.

 

예를 들어 서비스 레이어에 AccountService 인터페이스가 있다고 해보자

public interface AccountService {
    void createAccount(String accountNo);
    void removeAccount(String accountNo);
    void deposit(String accountNo, double amount);
    void withdraw(String accountNo, double amount);
    double getBalance(String accountNo);
}

 

이 인터페이스의 구현 클래스는 퍼시스턴스 레이어에서 계정 객체를 저장하는 AccountDao 객체에 의존할 수밖에 없다.

 

@RequiredArgsConstructor
@Service
public class AccountServiceImpl implements AccountService{

    private final AccountRepository accountRepository;

    @Override
    public void createAccount(String accountNo) {
        accountRepository.createAccount(new Account(accountNo, 0.2));
    }

    @Override
    public void removeAccount(String accountNo) {
        Account account = accountRepository.findAccount(accountNo);
        accountRepository.removeAccount(account);
    }

    @Override
    public void deposit(String accountNo, double amount) {
        Account account = accountRepository.findAccount(accountNo);
        account.setBalance(account.getBalance() + amount);
        accountRepository.updateAccount(account);
    }

    @Override
    public void withdraw(String accountNo, double amount) {
        Account account = accountRepository.findAccount(accountNo);
        
        if (account.getBalance() < amount)
            throw new IllegalArgumentException("예금 금액이 부족합니다.");
        
        account.setBalance(account.getBalance() - amount);
        accountRepository.updateAccount(account);
    }

    @Override
    public double getBalance(String accountNo) {
        return accountRepository.findAccount(accountNo).getBalance();
    }
}

 

스텁은 단위 테스트에서 의존 관계로 빚어진 복잡도를 줄이는 가장 일반적인 기법이다. 여기서 스텁은 반드시 대상 객체와 같은 인터페이스를 구현해야 한다.(스텁 객체가 인터페이스를 구현하기기 때문)

예를 들어 단일 고객 계정을 보관하는 AccountDao 스텁을 만든다면 deposit(), withdraw() 메서드에서 곡 필요한 findAccount(), updateAccount() 메서드만 구현하면 된다.

 

class AccountServiceImplStubTest {

    private static final String TEST_ACCOUNT_NO = "1234";

    private AccountDaoStub accountDaoStub;
    private AccountService accountService;

    private static class AccountDaoStub implements AccountRepository {

        private String accountNo;
        private double balance;

        @Override
        public void createAccount(Account account) {}

        @Override
        public void updateAccount(Account account) {
            this.accountNo = account.getAccountNo();
            this.balance   = account.getBalance();
        }

        @Override
        public void removeAccount(Account account) {}

        @Override
        public Account findAccount(String accountNo) {
            return new Account(this.accountNo, this.balance);
        }
    }

    @BeforeEach
    public void init() {
        accountDaoStub = new AccountDaoStub();
        accountDaoStub.accountNo = TEST_ACCOUNT_NO;
        accountDaoStub.balance = 100;
        accountService = new AccountServiceImpl(accountDaoStub);
    }

    @Test
    void deposit() {
        accountService.deposit(TEST_ACCOUNT_NO, 50);
        assertEquals(TEST_ACCOUNT_NO, accountDaoStub.accountNo);
        assertEquals(150, accountDaoStub.balance, 0);
    }

    @Test
    void withdrawWithSufficientBalance() {
        accountService.withdraw(TEST_ACCOUNT_NO, 50);
        assertEquals(TEST_ACCOUNT_NO, accountDaoStub.accountNo);
        assertEquals(50, accountDaoStub.balance, 0);
    }

    @Test
    void withdrawWithInsufficientBalance() {
        assertThrows(IllegalArgumentException.class,
                () -> accountService.withdraw(TEST_ACCOUNT_NO, 150));
    }
}

 

그런데 스텁을 직접 작성하면 코딩양이 적지 않다. 목 객체를 이용하는 편이 확실히 효율적이다. 모키토 라이브러리를 쓰면 녹음/재생 메커니즘으로 작동되는 목 객체를 동적으로 만들어 쓸 수 있다.

 

class AccountServiceImplMockTest {

    private static final String TEST_ACCOUNT_NO = "1234";

    private AccountRepository accountRepository;
    private AccountService accountService;

    @BeforeEach
    public void init() {
        accountRepository = mock(AccountRepository.class);
        accountService = new AccountServiceImpl(accountRepository);
    }

    @Test
    void deposit() {

        //설정
        Account account = new Account(TEST_ACCOUNT_NO, 100);
        when(accountRepository.findAccount(TEST_ACCOUNT_NO)).thenReturn(account);

        //실행
        accountService.deposit(TEST_ACCOUNT_NO, 50);

        //확인
        verify(accountRepository, times(1)).findAccount(any(String.class));
        verify(accountRepository, times(1)).updateAccount(account);
    }

    @Test
    void withdrawWithSufficientBalance() {
        // Given
        Account account = new Account(TEST_ACCOUNT_NO, 100);
        when(accountRepository.findAccount(TEST_ACCOUNT_NO)).thenReturn(account);

        // When
        accountService.withdraw(TEST_ACCOUNT_NO, 50);

        // Then
        verify(accountRepository, times(1)).findAccount(any(String.class));
        verify(accountRepository, times(1)).updateAccount(account);
    }

    @Test
    void withdrawWithInsufficientBalance() {
        // Given
        Account account = new Account(TEST_ACCOUNT_NO, 100);
        when(accountRepository.findAccount(TEST_ACCOUNT_NO)).thenReturn(account);

        // When
        assertThrows(IllegalArgumentException.class,
                () -> accountService.withdraw(TEST_ACCOUNT_NO, 150));
    }
}

 

모키토는 어떤 인터페이스/클래스라도 목 객체를 동적으로 생성할 수 있다. 목 객체를 이용해 어떤 메서드를 어떻게 호출해야 할지 지시하고 어떤 일이 일어났는지 확인할 수 있다.

예제에서 Mockito.when() 메서드로 findAccount() 메서드가 특정 Account 객체를 반환하도록 설정한다. 이 메서드가 어떤 값을 반환하든지 예외를 던지게 하거나 다른 정교한 로직을 org.mockito.stubbing.Answer 객체에 코딩할 수 있다. 목의 기본 로직은 null을 반환한다. 어떤 일을 실제로 했는지는 Mockito.verify() 메서드로 확인한다. findAccount() 메서드가 정말 호출됐는지, 데이터가 수정됐는지 검증할 수 있다.

 

 

통합 테스트 작성하기

통합 테스트는 여러 단위 테스트를 한데 묶어 각 단위가 서로 잘 연계되는지, 상호 작용이 정확하게 이뤄졌는지 확인하는 용도로 수행한다. 예를 들어 AccountServiceImpl은 다음과 같이 통합 테스트할 수 있다.

 

class AccountServiceImplTest {

    private static final String TEST_ACCOUNT_NO = "1234";

    private EntityManagerFactory emf;
    private EntityManager em;
    private EntityTransaction tx;
    private AccountService accountService;


    @BeforeEach
    public void init() {
        emf = Persistence.createEntityManagerFactory("hello");
        em = emf.createEntityManager();
        tx = em.getTransaction();
        AccountRepository accountRepository = new AccountRepositoryImpl(em);

        accountService = new AccountServiceImpl(accountRepository);

        tx.begin();

        accountService.createAccount(TEST_ACCOUNT_NO);
        accountService.deposit(TEST_ACCOUNT_NO, 100);
    }

    @Test
    void deposit() {
        accountService.deposit(TEST_ACCOUNT_NO, 50);
        assertEquals(150, accountService.getBalance(TEST_ACCOUNT_NO), 0);
    }

    @Test
    void withDraw() {
        accountService.withdraw(TEST_ACCOUNT_NO, 50);
        assertEquals(50, accountService.getBalance(TEST_ACCOUNT_NO), 0);
    }

    @AfterEach
    void cleanup() {
        tx.commit();
        em.close();
        emf.close();
    }
}