Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - 모의 객체로 테스트 하기 (Mock) (1/2)
간단한 스텁과 모의 객체 리뷰
- Stub은 특정 상황에서 "결과만" 중요할 때 사용 → Stub은 메서드가 무엇을 반환하는지에만 집중
- Mock은 "행위"가 중요할 때 사용 → 특정 메서드가 호출되었는지, 호출 횟수는 몇 번인지 등을 검증할 때 사용
스텁을 활용하면 소스코드를 웹 서버, 파일 시스템, 데이터베이스 등의 환경에서 격리하여 단위 테스트를 수행할 수 있다. 그러나 메서드 호출을 다른 클래스로부터 격리하는 것과 같은 세밀한 격리를 위한 테스트는 모의객체(Mock)을 활용하여 효과적으로 수행할 수 있다. 즉, 각 메서드별로 개별적인 단위 테스트를 만들어 개발하는 것이 가능해진다.
모의 객체란?
- 메서드에 집중하는 테스트를 만들 수 있다 → 대상 메서드가 다른 객체를 호출해서 발생하는 부수 효과가 생길일이 없다!
- 모의 객체 초기화 → 기대 설정 → 테스트 실행 → 단언문 검증 순으로 수행
모의 객체를 통한 단위 테스트 수행
모의 객체를 작성할 때 가장 중요한 규칙은 모의 객체가 비즈니스 로직을 가져서는 안된다는 것이다. 모의 객체는 테스트가 시키는대로만 해야 한다. 이렇게 모의 객체에 비즈니스 로직을 넣지 않으면 좋은 점이 두 가지 있다. 모의 객체를 만들기가 쉬워지고, 모의 객체는 빈 껍데기이므로 모의 객체를 테스트할 필요가 없다.
보다 쉬운 이해를 위해 간단한 모의 객체 활용 예제를 가져왔다. 아래 예제는 AccountService 클래스에서 transfer 메서드를 단위 테스트 하기 위한 예제이다. 각 클래스에 대한 설명은 소스 코드의 주석을 참고 바란다.
- Account 클래스
package com.test.junit.ch08;
/**
* 데이터 접근을 위한 객체
*/
public class Account {
private String accountId;
private long balance;
public Account(String accountId, long balance) {
this.accountId = accountId;
this.balance = balance;
}
/***
* 출금
* @param amount
*/
public void debit(long amount) {
this.balance -= amount;
}
/***
* 입금
* @param amount
*/
public void credit(long amount) {
this.balance += amount;
}
/**
* 잔액 확인
* @return
*/
public long getBalance() {
return this.balance;
}
}
- AccountService 클래스
package com.test.junit.ch08;
public class AccountService {
private AccountManager accountManager;
public void setAccountManager(AccountManager accountManager) {
this.accountManager = accountManager;
}
/**
* 단위테스트 대상 메서드 -> 단위 테스트를 위해서는 accountManager의 호출이 필요한 문제가 발생
* @param senderId
* @param beneficiaryId
* @param amount
*/
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);
}
}
- AccountManager 인터페이스
package com.test.junit.ch08;
/**
* Account 객체의 생애 주기와 영속성을 관리함
*/
public interface AccountManager {
Account findAccountForUser(String userId);
void updateAccount(Account account);
}
- MockAccountManager 클래스
package com.test.junit.ch08;
import java.util.HashMap;
import java.util.Map;
public class MockAccountManager implements AccountManager {
private Map<String, Account> accounts = new HashMap<>();
/**
* accounts 에 userId 를 키로 갖고 Account객체를 값으로 갖는 쌍을 추가 함
* @param userId
* @param account
*/
public void addAccount(String userId, Account account) {
this.accounts.put(userId, account);
}
public Account findAccountForUser(String userId) {
return this.accounts.get(userId);
}
public void updateAccount(Account account) {
// do nothing ...
}
}
- transfer 메서드 테스트를 위한 코드
package com.test.junit.ch08;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class TestAccountService {
@Test
public void testTransferOK() {
// 굳이 데이터 접근 객체까지 모의로 선언할 필요는 없다. 이런 객체는 환경에 영향을 받지도 않고 매우 단순하다.
Account sendAccount = new Account("1", 200);
Account beneficiaryAccount = new Account("2", 100);
// mock 객체에 반환될 계좌 정보 추가
MockAccountManager mockAccountManager = new MockAccountManager();
mockAccountManager.addAccount("1", sendAccount);
mockAccountManager.addAccount("2", beneficiaryAccount);
AccountService accountService = new AccountService();
accountService.setAccountManager(mockAccountManager);
accountService.transfer("1", "2", 50);
assertEquals(150, sendAccount.getBalance());
assertEquals(150, beneficiaryAccount.getBalance());
}
}
위 코드에서 모의 객체인 MockAccountManager를 사용하여 실제로 호출이 필요한 AccountManager의 역할을 수행하는 것을 알 수 있다. 모의 객체를 활용할 때는 테스트 코드 작성시 위 코드 처럼 모의 객체를 이용하기전에 기대를 정의 한다는 특징이 있다.
훌륭한 설계 전략은 클래스 안에서 객체를 직접 생성하기보다 비즈니스 로직과 직접 관계없는 객체를 파라미터로 전달하는 것이다. 궁극적으로 로거나 구성 관련 컴포넌트는 여러 곳에서 사용할 수 있도록 최상위 수준으로 올라가야 하는 점을 명심하자.
모의 객체를 활용한 리팩터링 예제 1
단위 테스트는 런타임 코드의 가장 중요한 클라이언트이며 다른 클라이언트와 거의 비슷한 수준의 취급을 받아야한다. 만약 코드가 테스트하기에 충분히 유연하지 않다면 코드를 수정하는 것은 당연하다 !
cf. 런타임 코드
런타임 코드(Runtime Code)란 프로그램이 실행되는 동안 동적으로 실행되는 코드. 프로그램의 런타임(runtime)은 프로그램이 실제로 실행되고 있는 시간대를 의미하며, 런타임 코드란 이 시점에 실행되는 모든 코드를 포함하는 개념이다.
런타임 코드의 특징
- 동적 동작: 런타임 코드는 실행 중에 시스템의 상태, 사용자 입력, 외부 데이터 등에 따라 동적으로 동작할 수 있음
- 오류 처리:런타임 시 발생할 수 있는 예외나 오류를 처리. 예를 들어, 사용자가 입력한 값이 유효하지 않을 때 이를 처리하는 코드는 런타임에 실행 됨.
- 메모리 할당: 객체를 생성하거나 메모리를 할당하는 작업도 런타임에 발생함
- 동적 로딩: 일부 프로그램에서는 런타임에 필요한 모듈이나 라이브러리를 동적으로 로딩하여 경우도 있음
cf. 컴파일 코드
프로그램이 컴파일될 때 실행되는 코드. 컴파일러가 소스 코드를 기계어로 변환할 때 확인되는 코드로, 변수의 선언, 타입 검사, 구문 오류 등이 컴파일 타임에 처리 됨
이론을 이해하는 것에는 테스트 코드가 최고다. 간단한 예제를 보자.
- 리팩터링 전 소스
package com.test.junit.ch08;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
public class DefaultAccountManage1 implements AccountManager{
// 문제1) 로거 객체가 내부에서 선언 되어 있다
private static final Log logger =
LogFactory.getLog(DefaultAccountManage1.class);
public Account findAccountForUser(String userId) {
logger.debug("Getting account for user [" + userId + "]");
//
ResourceBundle bundle = PropertyResourceBundle.getBundle("technical"); // 문제2) 만약 technical 에서 변경 된다면?
String sql = bundle.getString("FIND_ACCOUNT_FOR_USER");
return null;
}
public void updateAccount(Account account) {
}
}
- 1차 리팩터링 후 - logger, configuration 에 대해 파라미터로 받을 수 있도록하여 클래스를 재사용할 수 있게 변경
package com.test.junit.ch08;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import java.lang.module.Configuration;
public class DefaultAccountManager2 implements AccountManager{
private Log logger;
private Configuration configuration;
/**
* 외부에서 제어할 수 있도록 logger, configuration 를 생성자로 받을 수 있게 변경
* @param logger
* @param configuration
*/
public DefaultAccountManager2(Log logger, Configuration configuration) {
this.logger = logger;
this.configuration = configuration;
}
public DefaultAccountManager2() {
this(LogFactory.getLog(DefaultAccountManager2.class), new DefaultConfiguration("technical"));
}
public Account findAccountForUser(String userId) {
this.logger.debug("Getting account for user [" + userId + "]");
this.configuration.getSQL("FIND_ACCOUNT_FOR_USER");
//비즈니스로직 ...
return null;
}
@Override
public void updateAccount(Account account) {
}
}
이런식으로 클래스가 직접 책임지지 않는 객체를 내부에서 생성하는 것이 아니라 외부에서 의존성을 통해 주입하는 것을 제어의 역전(Inversion of Control, IoC)이라고 표현한다. ( Spring은 제어의 역전 패턴을 구현하는 대표적인 프레임 워크다. 관련 내용 참고)
cf. 제어의 역전 ( 실용적인 디자인 패턴 )
클래스가 직접 책임지지 않는 객체를 내부에서 생성하는 것이 아니라 외부에서 의존성을 통해 주입하는 것을 의미한다. 의존성은 세터(Setter) 메서드로 전달할 수 있고 또 다른 메서드의 파라미터로 전달할 수 있다. 의존성을 올바르게 구성하는 것은 메서드를 호출한 곳의 책임이지 호출 받은 곳의 책임이 아니다.
모의 객체를 활용한 리팩터링 예제 2
이번에 작성하는 예제는 HTTP 애플리케이션 예시이다. 웹 서버가 제공하는 웹 리소스에 대해 HTTP로 통신하여 콘텐츠를 읽어들이는 WebClient.getContent 메서드를 리팩터링 해볼것이다.
아래 코드는 리팩터링 전 예시이다.
public String getContent(URL url) {
StringBuffer content = new StringBuffer();
try {
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
InputStream is = connection.getInputStream();
int count;
while(-1 != (count = is.read())) {
content.append(new String(Character.toChars(count)));
}
} catch (IOException e) {
return null;
}
return content.toString();
}
위 코드는 크게 URLConnection 생성과 외부 URL에서 리소스를 읽어오는 두가지 부분으로 구현되어 있다.
해당 코드를 보다 유연하게 만들기 위해 URL Connection을 세팅하는 부분을 분리하여 테스트시 가짜 객체를 삽입할 수 있게 변환했다.
이런 방식을 메서드 팩터리를 이용한 리팩터링이라고 한다.
/**
* WebClient 에서 HttpUrlConnection 을 가져오는 부분을 따로 분리함
*/
public class WebClient1 {
public String getContent(URL url) {
StringBuffer content = new StringBuffer();
HttpURLConnection connection = null;
try {
connection = createHttpURLConnetion(url);
InputStream is = connection.getInputStream();
int count;
while (-1 != (count = is.read())) {
content.append(new String(Character.toChars(count)));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return content.toString();
}
// URL 연결 부분 분리
protected HttpURLConnection createHttpURLConnetion(URL url) throws IOException {
return (HttpURLConnection) url.openConnection();
}
}
/**
* createHttpConnection 메서드 재정의를 위한 클래스
*/
public class TestableWebClient extends WebClient1{
private HttpURLConnection connection;
public void setHttpURLConnection(HttpURLConnection connection) {
this.connection = connection;
}
public HttpURLConnection createHttpURLConnection(URL url) throws IOException {
return this.connection;
}
}
- 메서드 팩터리를 이용한 리팩터링 후 테스트 코드
**
* 메서드 팩터리 패턴을 이용한 mock 주입
*/
public class TestWebClientMock {
@Test
public void testGetContentOK() throws Exception {
// 모의 객체 생성
MockHttpURLConnection mockHttpURLConnection = new MockHttpURLConnection();
mockHttpURLConnection.setExpectedInputStream( new ByteArrayInputStream("It works".getBytes()));
// createdHttpURLConnection 메서드가 모의로 만든 MockHttpURLConnection 를 반환할 수 있도록 set
TestableWebClient client = new TestableWebClient();
client.setHttpURLConnection(mockHttpURLConnection);
String result = "";
try {
result = client.getContent(new URL("http://localhost"));
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
Assertions.assertEquals("It works", result);
}
}
이번에는 인터페이스를 통한 리팩터링 예제이다. 이런 방식으로 리팩터링하는 것을 클래스 팩토리 리팩터링이라고한다
/**
* ConnectionFactory를 구현한 클래스의 연결 종류에 상관없이 적절한 InputStream 을 반환
*/
public interface ConnectionFactory {
InputStream getData() throws Exception;
}
/**
* http protocol 에 대한 ConnectionFactory 인터페이스 구현 모습
*/
public class HttpURlConnectionFactory implements ConnectionFactory {
private URL url;
public HttpURlConnectionFactory(URL url) {
this.url = url;
}
public InputStream getData() throws Exception {
HttpURLConnection connection = (HttpURLConnection) this.url.openConnection();
return connection.getInputStream();
}
}
**
* ConnectionFactory를 사용하여 리팩터링한 결과
*/
public class WebClient2 {
public String getContent(ConnectionFactory connectionFactory) {
String workingContent;
StringBuffer content = new StringBuffer();
try(InputStream is = connectionFactory.getData()) {
int count;
while(-1 != (count = is.read())) {
content.append(new String(Character.toChars(count)));
}
workingContent = content.toString();
} catch (Exception e) {
workingContent = null;
}
return workingContent;
}
}
- 클래스 팩터리를 이용한 리팩터링 후 테스트 코드
public class TestWebClient {
/**
* url 이 주어졌을 때 웹 콘텐츠를 반환하는 하는 단위 테스트 작성
*/
@Test
public void testGetContentOK() throws Exception {
MockConnectionFactory mockConnectionFactory = new MockConnectionFactory();
mockConnectionFactory.setData(new ByteArrayInputStream("It works".getBytes()));
// 해당 단위 테스트는 connection 이나 stream 을 읽어오는 것에 관계 없이 가져온 리소스에 대해서만 검증할 수 있게 바뀌었다
WebClient2 webClient2 = new WebClient2();
String workingContent = webClient2.getContent(mockConnectionFactory);
assertEquals("It works", workingContent);
}
}
모의 객체를 트로이 목마로 사용하는 방법
모의 객체는 모의 객체를 호출하는 클래스가 인식하지 못하는 상태에서 실제 객체를 대체한다. 그렇기 때문에 모의 객체를 관찰자로써 사용하면 테스트 대상 객체가 정상적으로 메서드를 호출하고 있는지 감시할 수 있다. 즉 모니터링이 된다는 것이다.
이런식으로 특정 객체가 어떠한 메서드를 호출하는지에 대한 검증을하는 것을 기대라고 표현한다.
- 기대(Expectation)
모의 객체를 외부에서 호출하는 클래스가 정확하게 행동했는지 검증하기 위해 모의 객체에 내장된 기능이다. 예를 들어 데이터베이스 커넥션을 모의한 객체는 close 메서드가 모의 객체와 관련한 테스트 중에 정확히 한 번 호출되는지 검증할 수 있다.
verify() 메서드를 사용하면, 기대한 내용을 검증할 수 있다.