ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JUnit] 모의 객체로 테스트하기
    DEV/JUnit 2024. 8. 25. 23:09

    모의객체를 사용하면 가능한 한 가장 세밀한 수준에서 테스트를 실행할 수 있다.

    메서드별로 개별적인 단위 테스트를 만들어 개발하는 것이 가능하다.

     

    모의 객체란?

    모의 객체는 비즈니스 로직의 일부만을 다른 부분과 격리해 테스트하는데 적합하다. 모의 객체는 테스트 대상인 메서드가 사용하는 객체를 대체하여 테스트 대상 메서드는 다른 객체와 격리되는 효과가 생긴다.

     

    다른 클래스나 메서드와 격리된 상태에서 테스트를 하면 큰 이점이 있다. 격리된 상태에서의 테스트는 다른 부분의 코드가 완성되는 것을 기다리지 않고 한 부분의 코드를 단위 테스트하는 데 큰 도움이 된다.

    모의 객체를 사용할 때의 가장 큰 장점은 메서드에 집중하는 테스트를 만들 수 있다는 것이다. 모의 객체를 사용하면 테스트 대상 메서드가 다른 객체를 호출해서 발생하는 부수 효과가 생길 일이 없다.

     

    스텁과 비슷하지만 모의 객체는 비즈니스 로직을 새로 구현할 필요가 없고, 스텁처럼 사전에 정의된 동작이 있는 것이 아니라 모의 객체가 수행할 행동을 기대할 수 있다.

     

    모의 객체를 활용한 단위 테스트

    모의 객체 초기화 => 기대 설정 => 테스트 실행 => 단언문 검증

     

    모의 객체를 활용한 테스트는 이와 같은 순서로 진행된다.

     

    한 계좌에서 다른 계좌로 이체하는 예시를 통해 테스트를 만들어보자.

     

    AccountService는 Account 객체를 처리하는 서비스 클래스이고, AccountManager클래스를 통해 데이터베이스에 데이터를 영속시킨다. 계좌 이체 서비스는 AccountService.transfer 메서드로 구현할수있다.

     

    그러나 모의 객체가 없다면 transfer 메서드를 테스트하기위해선 사전에 데이터베이스 준비부터 시작해서 데이터를 밀어넣어야하며 서버를 배포해야한다. 단순히 비즈니스 로직 테스트를 위해 이 과정은 너무 과하다. 따라서 우리는 모의객체를 사용해서 테스트해야한다.

     

    Account.java

    public class Account {
        private String accountId; // 계좌 id
        private Long balance; // 잔액
    
        public Account(String accountId, Long initialBalance) {
            this.accountId = accountId;
            this.balance = initialBalance;
        }
    
        public void debit(Long amount) {
            this.balance -= amount;
        }
    
        public void credit(Long amount) {
            this.balance += amount;
        }
    
        public Long getBalance() {
            return this.balance;
        }
    }

     

    AccountManager.java

    public interface AccountManager {
    
        Account findAccountForUser(String userId);
    
        void updateAccount(Account account);
    }

     

    Account객체의 생애주기와 영속성을 관리

     

    AccountService.java

    public class AccountService {
    
        private AccountManager accountManager;
    
        public void setAccountManager(AccountManager manager) {
            this.accountManager = manager;
        }
    
        public void transfer(String senderId, String beneficiaryId, Long amount) {
            Account sender = accountManager.findAccountForUser(senderId);
            Account beneficiary =  accountManager.findAccountForUser(beneficiaryId);
    
            sender.debit(amount);
            beneficiary.credit(amount);
            this.accountManager.updateAccount(sender);
            this.accountManager.updateAccount(beneficiary);
        }
    }

    계좌이체에 대한 transfer 메서드 구현

     

    transfer 메서드를 단위테스트하려면 AccountManager의 구현체가 준비될때까지

    AccountManager에 대한 모의 객체를 구현하여 테스트를 별도로 해야한다. 격리된 상태로 테스트를 해야하기때문이다.

     

    MockAccountMananger.java

    import java.util.HashMap;
    import java.util.Map;
    
    public class MockAccountManager implements AccountManager {
    
        private Map<String, Account> accounts = new HashMap<>();
    
        public void addAccount(String userId, Account account) {
            this.accounts.put(userId, account);
        }
    
        @Override
        public Account findAccountForUser(String userId) {
            return this.accounts.get(userId);
        }
    
        @Override
        public void updateAccount(Account account) {
            // 아무것도하지않음
        }
    }

     

    - addAccount : accounts Map에 userId를 Key로 Account를 Value로 갖는 쌍을 추가한다.

    이렇게 작성함으로써 여러 테스트에서 계좌 1개 혹은 그 이상으로 모의객체를 사용할수 있도록한다.

    - findAccountForUser : userId를 가지고 accounts에서 Account객체를 조회한다.

    - updateAccount : 현재 아무 작업도 수행하지 않는다. 즉 비즈니스로직이 없다.

     

    JUnit 모범사례 1

    더보기

     JUnit 모범사례 1. 모의 객체에 비즈니스 로직을 작성하지 않는다.

    모의객체를 작성할때 가장 중요한 규칙은 모의객체가 비즈니스로직을 가져서는 안된다는 것이다.

    모의객체는 테스트가 시키는대로만 해야하며 순전히 테스트에 의해서만 구동된다. 이러한 특성을 모든 로직을 가지고 있는 스텁과 반대된다. 모의객체에 비즈니스 로직을 넣지않으면 좋은점이 두가지 있다.

    첫째, 모의객체를 만들기 쉬워진다.

    둘째, 모의객체는 빈 껍데기이므로 모의객체를 테스트할 필요가없다.

     

    TestAccountService.java

    import org.junit.jupiter.api.Test;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    
    class TestAccountService {
    
        @Test
        public void testTransferOk() {
        
        	//given
            Account senderAccount = new Account("1", 200L);
            Account beneficiaryAccount = new Account("2", 100L);
    
            MockAccountManager mockAccountManager = new MockAccountManager();
            mockAccountManager.addAccount("1", senderAccount);
            mockAccountManager.addAccount("2", beneficiaryAccount);
    
            AccountService accountService = new AccountService();
            accountService.setAccountManager(mockAccountManager);
    		
            //when
            accountService.transfer("1","2", 50L);
    		
            //then
            assertEquals(150L, senderAccount.getBalance());
            assertEquals(150L, beneficiaryAccount.getBalance());
        }
    }

     

    일반적으로 테스트는 테스트 설정하기 (given), 테스트 실행하기 (when), 테스트 결과 검증하기 (then) 세 단계로 진행된다.

    테스트를 설정하는 단계에서 출금계좌와 입금계좌를 만들고 mockAccountManager에 두 계좌를 추가한다.

    이제 transfer를 통해 기대되는 결과를 assertEquals로 검증한다. 이렇게 AccountService 클래스를 다른 도메인 객체인 AccountManager클래스와 격리한 상태에서 테스트할 수 있었다. 만약 실제 테스트를 하려면 JDBC등을 활용해야만 할 것이다.

     

     JUnit 모범사례 2

    더보기

     JUnit 모범사례 2. 문제될 만한 것만 테스트한다.

    해당 테스트에서 Account클래스를 모의객체로 만들지 않았는데, 굳이 데이터 접근을 위한 객체까지 모의객체로 만들 필요가 없기때문이다. 이런 객체는 환경에 크게 영향을 받지도 않고, 기본적으로 매우단순하다.

    그리고 Account객체를 사용하는 다른 클래스에 대한 테스트가 있다면  Account객체를 간접적으로 테스트한것으로 간주할수있다. Account클래스가 올바르게 동작하지 않는다면 Account객체를 사용하는 다른 테스트가 실패하고 거기서 문제가 무엇인지 알려줄 것이다.

     

    댓글

Designed by Tistory.