레시피 16-2 단위/통합 테스트 작성하기

umtuk·2022년 1월 27일
0

단위/통합 테스트 작성하기

과제

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

해결책

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

스텁stub은 테스트에 필요한 최소한의 메서드만으로 의존 객체를 시뮬레이션한 객체
보통 메서드는 사전에 정해진 로직으로 하드 코딩한 데이터를 이용해 구현
스텁은 테스트 코드가 그 내부 상태를 확인할 수 있도록 어떤 메서드를 표출
목 객체mock object는 자신의 메서드를 테스트 코드가 어떤 식으로 호출할 거라 기대
실제로 호출된 메서드와 호출되리라 기대했던 메서드를 비교 검증 가능
모키토Mockito, 이지목EasyMock, 제이목jMock 등 목 객체를 간편하게 생성하게 해주는 자바 라이브러리 여렷 존재
스텁은 대개 상태를 검증, 목 객체는 수행 로직을 검증

단위를 몇 개 통째로 묶어 테스트하는 통합 테스트를 수행하면 단위 사이의 연계 및 상호 작용이 올바른지 확인
일반적으로 통합 테스트는 각 단위를 단위 테스트로 검증한 후 실시

구현 코드와 인터페이스를 분리해야 한다는 원칙과 의존체 주입 패턴에 따라 개발하면 애플리케이션의 여러 단위 간의 결합도가 줄어서 단위 테스트와 통합 테스트 모두 용이

풀이

단일 클래스에 대한 단위 테스트 작성하기

public class Account {

    private final String accountNo;
    private double balance;

    // Constructor
    // Getter, Setter

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Account account = (Account) o;
        return Objects.equals(accountNo, account.accountNo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(accountNo);
    }
}
public interface AccountDao {

    void createAccount(Account account);
    void updateAccount(Account account);
    void removeAccount(Account account);
    Account findAccount(String accountNo);
}
public class InMemoryAccountDao implements AccountDao {

    private Map<String, Account> accounts;

    public InMemoryAccountDao() {
        accounts = Collections.synchronizedMap(new HashMap<String, Account>());
    }

    public boolean accountExists(String accountNo) {
        return accounts.containsKey(accountNo);
    }

    public void createAccount(Account account) {
        if (accountExists(account.getAccountNo())) {
            throw new DuplicateAccountException();
        }
        accounts.put(account.getAccountNo(), account);
    }

    public void updateAccount(Account account) {
        if (!accountExists(account.getAccountNo())) {
            throw new AccountNotFoundException();
        }
        accounts.put(account.getAccountNo(), account);
    }

    public void removeAccount(Account account) {
        if (!accountExists(account.getAccountNo())) {
            throw new AccountNotFoundException();
        }
        accounts.remove(account.getAccountNo());
    }

    public Account findAccount(String accountNo) {
        Account account = accounts.get(accountNo);
        if (account == null) {
            throw new AccountNotFoundException();
        }
        return account;
    }
}

JUnit으로 InMemoryAccountDao 클래스 단위 테스트 작성

package com.apress.springrecipes.bank;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import org.junit.Before;
import org.junit.Test;

public class InMemoryAccountDaoTests {

    private static final String EXISTING_ACCOUNT_NO = "1234";
    private static final String NEW_ACCOUNT_NO = "5678";

    private Account existingAccount;
    private Account newAccount;
    private InMemoryAccountDao accountDao;

    @Before
    public void init() {
        existingAccount = new Account(EXISTING_ACCOUNT_NO, 100);
        newAccount = new Account(NEW_ACCOUNT_NO, 200);
        accountDao = new InMemoryAccountDao();
        accountDao.createAccount(existingAccount);
    }

    @Test
    public void accountExists() {
        assertTrue(accountDao.accountExists(EXISTING_ACCOUNT_NO));
        assertFalse(accountDao.accountExists(NEW_ACCOUNT_NO));
    }

    @Test
    public void createNewAccount() {
        accountDao.createAccount(newAccount);
        assertEquals(accountDao.findAccount(NEW_ACCOUNT_NO), newAccount);
    }

    @Test(expected = DuplicateAccountException.class)
    public void createDuplicateAccount() {
        accountDao.createAccount(existingAccount);
    }

    @Test
    public void updateExistedAccount() {
        existingAccount.setBalance(150);
        accountDao.updateAccount(existingAccount);
        assertEquals(accountDao.findAccount(EXISTING_ACCOUNT_NO), existingAccount);
    }

    @Test(expected = AccountNotFoundException.class)
    public void updateNotExistedAccount() {
        accountDao.updateAccount(newAccount);
    }

    @Test
    public void removeExistedAccount() {
        accountDao.removeAccount(existingAccount);
        assertFalse(accountDao.accountExists(EXISTING_ACCOUNT_NO));
    }

    @Test(expected = AccountNotFoundException.class)
    public void removeNotExistedAccount() {
        accountDao.removeAccount(newAccount);
    }

    @Test
    public void findExistedAccount() {
        Account account = accountDao.findAccount(EXISTING_ACCOUNT_NO);
        assertEquals(account, existingAccount);
    }

    @Test(expected = AccountNotFoundException.class)
    public void findNotExistedAccount() {
        accountDao.findAccount(NEW_ACCOUNT_NO);
    }
}
``


### **스텁, 목 객체를 써서 의존 관계가 있는 클래스에 대한 단위 테스트 작성하기**

혼자 떨어진 클래스는 의존체의 작동 로직, 설정 방법 등을 신경 안쓰고 테스트하기 쉽지만  
다른 클래스나 서비스의 결과에 의존하는 클래스는 조금 까다로움  
```java
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);
}
public class AccountServiceImpl implements AccountService {

    private final AccountDao accountDao;

    public AccountServiceImpl(AccountDao accountDao) {
        this.accountDao = accountDao;
    }

    public void createAccount(String accountNo) {
        accountDao.createAccount(new Account(accountNo, 0));
    }

    public void removeAccount(String accountNo) {
        Account account = accountDao.findAccount(accountNo);
        accountDao.removeAccount(account);
    }

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

    public void withdraw(String accountNo, double amount) {
        Account account = accountDao.findAccount(accountNo);
        if (account.getBalance() < amount) {
            throw new InsufficientBalanceException();
        }
        account.setBalance(account.getBalance() - amount);
        accountDao.updateAccount(account);
    }

    public double getBalance(String accountNo) {
        return accountDao.findAccount(accountNo).getBalance();
    }
}

스텁은 단위 테스트에서 의존 관계로 빚어진 복잡도를 줄이는 가장 일반적인 기법
스텁은 반드시 대상 객체와 같은 인터페이스를 구현(대상 객체의 대리자 역할)
AccountDao 스텁을 만들면 deposit(), withdraw() 메서드에 필요한 findAccount(), updateAccount() 메서드만 구현

public class AccountServiceImplStubTests {

    private static final String TEST_ACCOUNT_NO = "1234";
    private AccountDaoStub accountDaoStub;
    private AccountService accountService;


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

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

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

    @Test(expected = InsufficientBalanceException.class)
    public void withdrawWithInsufficientBalance() {
        accountService.withdraw(TEST_ACCOUNT_NO, 150);
    }

    private static class AccountDaoStub implements AccountDao {

        private String accountNo;
        private double balance;

        public void createAccount(Account account) {}
        public void removeAccount(Account account) {}

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

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

스텁을 직접 작성하면 코딩양이 많음
목 객체를 이용하는 게 효율적
모키토 라이브러리를 쓰면 녹음record/재생playback 메커니즘으로 작동되는 목 객체를 동적으로 생성 가능

public class AccountServiceImplMockTests {

    private static final String TEST_ACCOUNT_NO = "1234";

    private AccountDao accountDao;
    private AccountService accountService;

    @Before
    public void init() {
        accountDao = mock(AccountDao.class);
        accountService = new AccountServiceImpl(accountDao);
    }

    @Test
    public void deposit() {
        // Setup
        Account account = new Account(TEST_ACCOUNT_NO, 100);
        when(accountDao.findAccount(TEST_ACCOUNT_NO)).thenReturn(account);

        // Execute
        accountService.deposit(TEST_ACCOUNT_NO, 50);

        // Verify
        verify(accountDao, times(1)).findAccount(any(String.class));
        verify(accountDao, times(1)).updateAccount(account);

    }

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

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

        // Verify
        verify(accountDao, times(1)).findAccount(any(String.class));
        verify(accountDao, times(1)).updateAccount(account);

    }

    @Test(expected = InsufficientBalanceException.class)
    public void testWithdrawWithInsufficientBalance() {
        // Setup
        Account account = new Account(TEST_ACCOUNT_NO, 100);
        when(accountDao.findAccount(TEST_ACCOUNT_NO)).thenReturn(account);

        // Execute
        accountService.withdraw(TEST_ACCOUNT_NO, 150);
    }
}

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

통합 테스트 작성하기

여러 단위 테스트를 한데 묶어 각 단위가 서로 잘 연계되는지, 상호 작용이 정확하게 이루어졌는지 확인하는 용도로 수행

public class AccountServiceTests {

    private static final String TEST_ACCOUNT_NO = "1234";
    private AccountService accountService;

    @Before
    public void init() {
        accountService = new AccountServiceImpl(new InMemoryAccountDao());
        accountService.createAccount(TEST_ACCOUNT_NO);
        accountService.deposit(TEST_ACCOUNT_NO, 100);
    }

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

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

    @After
    public void cleanup() {
        accountService.removeAccount(TEST_ACCOUNT_NO);
    }
}
profile
https://github.com/umtuk

0개의 댓글