-
[JUnit] 모의객체를 활용해 리팩터링하기DEV/JUnit 2024. 8. 27. 09:03
테스트를 단순하게 만들 목적으로 런타임 코드를 변경해서는 안된다는 통념이 있다. 그러나 이는 옳지않다.
단위 테스트는 런타임 코드의 가장 중요한 클라이언트이며, 코드가 테스트하기에 충분히 유연하지않다면 코드를 수정하는 것은 당연하다.
다음 예제에서 문제를 찾아보자.
import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import java.util.PropertyResourceBundle; import java.util.ResourceBundle; public class DefaultAccountManager1 implements AccountManager { private static final Log logger = LogFactory.getLog(DefaultAccountManager1.class); @Override public Account findAccountForUser(String userId) { logger.debug("Getting account for user [" + userId + "]"); ResourceBundle bundle = PropertyResourceBundle.getBundle("technical"); String sql = bundle.getString("FIND_ACCOUNT_FOR_USER"); // ... return null; } @Override public void updateAccount(Account account) { } }
해당 예제에서는 로거를 사용하기위해 Log객체를 생성하고, 적절한 SQL을 가져오고 있다.
여기서 두가지 문제점이 있는데, 둘다 코드가 충분히 유연하지 못하며 변화에 적응하기 어렵게 설계된 것과 관련이 있다.
첫번째 문제는 Log 객체를 클래스 내부에서 생성하여 Log 객체를 바꿔서 쓸 수 없다는 것
두번째 문제에서도 PropertyResourceBundle 클래스를 통해 생성한 config로 인해 다른 config로 바꿔서 쓸 수 없다는 것
어떤 구현체를 사용할지 결정하는 것이 이 클래스 설계의 목표가 되어서는 안된다.
훌륭한 설계 전략은 클래스 안에서 객체를 직접 생성하는 것이 아니라 비즈니스 로직과 직접 관계가 없는 객체를 파라미터로 전달하는 것이다. 궁극적으로 로거나 구성 관련 컴포넌트는 여러곳에서 사용할 수 있도록 최상위 수준으로 올라가야한다. 이런 전략은 코드를 유연하게 만들고 변화에 잘 적응할 수 있게 한다.
간단한 리팩터링 후
import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; public class DefaultAccountManager2 implements AccountManager { //(1) private Log logger; private Configuration configuration; public DefaultAccountManager2() { this(LogFactory.getLog(DefaultAccountManager2.class), new DefaultConfiguration("technical")); } //(2) public DefaultAccountManager2(Log logger, Configuration configuration) { this.logger = logger; this.configuration = configuration; } @Override public Account findAccountForUser(String userId) { logger.debug("Getting account for user [" + userId + "]"); this.configuration.getSQL("FIND_ACCOUNT_FOR_USER"); // ... JDBC를 사용하여 유저의 계좌정보를 가져오는 비즈니스 로직 // return null; } @Override public void updateAccount(Account account) { } }
(1) 이전 예제의 PropertyResourceBundle을 사용하지 않기 위해 새로운 Configuration 필드를 정의함. 이렇게 사용하면 상대적으로 모의하기 쉬운 인터페이스를 사용할 수 있으므로 코드를 더 유연하게 만들고, Configuration객체를 직접 구현하는 것으로 우리가 원하는 작업을 수행할 수 있다.
(2) Log, Configuration을 구현한 객체를 파라미터로 받는 생성자를 사용하면 DefaultAccountManager2클래스를 재사용 할 수 있으므로 설계가 더 좋아진다. 해당 클래스는 호출자가 외부에서 제어할 수 있게된 것이다.
리팩터링시 고려사항
실용적인 디자인 패턴 : 제어의 역전(IoC)
제어의 역전을 적용하는 것은 클래스가 직접 책임지지 않는 객체를 내부에서 생성하는 것이 아닌 외부에서 의존성을 통해 주입하는 것을 의미한다. 이때 의존성을 생성자나 세터메서드, 또 다른 메서드의 파라미터로 전달 할 수 있다.
의존성을 올바르게 구성하는 것은 메서드를 호출한 곳의 책임이지 호출을 받은 곳에 책임이 아니다.
=> 관련 글 : https://ivory-room.tistory.com/85
제어의 역전을 잘 활용하면 단위 테스트를 쉽게 작성할 수 있다.
public class TestDefaultAccountManager { public void testFindAccountByUser() { MockLog logger = new MockLog(); MockConfiguration configuration = new MockConfiguration(); configuration.setSQL("SELECT * FROM [...]"); DefaultAccountManager2 am = new DefaultAccountManager2(logger, configuration); Account account = am.findAccountForUser("1234"); //... } }
1) Log 인터페이스를 구현하지만 실제로는 아무 일도 하지 않는 logger 필드를 모의한다.
2) MockConfiguration 객체를 생성하고 configuration.getSQL 메서드를 호출할떼 SQL쿼리를 반환하도록 설정한다.
3) Log 객체와 Configuration 객체를 생성자에 전달하여 테스트할 DefaultAccountManager2객체를 생성한다.
이렇게함으로써 테스트코드에서 테스트 대상 코드의 로깅이나 설정과 관련한 동작을 제어할 수 있게 됐다.
결과적으로 코드가 유연해지고 다양한 로깅이나 설정을 사용할 수 있게 되었다.
이제 스텁에서 테스트했던 HTTP 연결을 모의객체로 테스트해보고, 순차적으로 리팩터링을 해보자.
WebClient.java
import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; public class WebClient { 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(); } }
이 예제를 보면 HTTP 연결을 맺고, HTTP연결에서 가져온 모든 콘텐츠를 읽어 들인다. 오류가 발생하면 null을 반환한다. 이 방식이 오류를 잡기위한 최선은 아니지만 앞으로 있을 리팩터링으로 남겨두기에 충분하다.
첫번째 테스트 (리팩터링 전)
import java.io.ByteArrayInputStream; import static org.junit.jupiter.api.Assertions.assertEquals; public class TestWebClientMock { @Test public void testGetContentOk() throws Exception { MockHttpURLConnection mockConnection = new MockHttpURLConnection(); mockConnection.setExpectedInputStream(new ByteArrayInputStream("It works".getBytes())); MockURL mockURL = new MockURL(); mockURL.setupOpenConnection(mockConnection); WebClient client = new WebClient(); String workingContent = client.getContent(mockURL); assertEquals("It works", workingContent); } }
웹서버에 대한 실제 HTTP연결과 독립적으로 getContent메서드를 테스트하기위해 URL 객체를 모의한다.
1. 모의 MockHttpURLConnection 객체를 만들고 반환할 스트림 객체를 설정한다.
2. 모의 MockURL 객체를 만들고 반환할 모의 연결을 설정한다.
3. getContent 메서드를 테스트한다.
4. 결과값이 It works가 맞는지 검증한다.
이 테스트는 성공할 수 없다. URL 클래스는 final 클래스이므로 상속받아 Mock객체로 만들 수 없기 때문이다.
따라서 다른 방법으로 모의객체를 활용해 getContent메서드를 리팩토링해보자.
import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; public class WebClient1 { public String getContent(URL url) { StringBuffer content = new StringBuffer(); try { HttpURLConnection connection = createHttpURLConnection(url); // connection 호출 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(); } protected HttpURLConnection createHttpURLConnection(URL url) throws IOException { return (HttpURLConnection) url.openConnection(); } }
우선 createHttpURLConnection 메서드를 호출하여 HTTP 연결을 생성하도록 수정한다.
그리고 다음과 같이 WebClient1 클래스를 상속하고 createHttpURLConnection 메서드를 재정의하는 테스트 헬퍼 클래스를 작성한다.
import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; 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; } }
이와 같이 메서드 팩터리라는 일반적인 리팩터링 방식은 모의할 클래스에 인터페이스가 없을때 특히 유용하다.
메서드 팩터리 기법은 먼저 대상 클래스를 상속하고, 이를 제어하기위한 Setter 메서드를 추가한다.
그리고 테스트를 위해 원하는 내용을 반환하는 Getter 메서드를 재정의한다.
첫번째 테스트(리팩터링 후)
import org.junit.Test; import java.io.ByteArrayInputStream; import java.net.URL; import static org.junit.jupiter.api.Assertions.assertEquals; public class TestWebClientMock { @Test public void testGetContentOk() throws Exception { MockHttpURLConnection mockConnection = new MockHttpURLConnection(); mockConnection.setExpectedInputStream(new ByteArrayInputStream("It works".getBytes())); TestableWebClient client = new TestableWebClient(); client.setHttpURLConnection(mockConnection); String result = client.getContent(new URL("http://localhost")); assertEquals("It works", result); } }
createHttpURLConnection 메서드가 모의로 만든 MockHttpURLConnection 객체를 반환하도록 TestableWebClient를 설정한 후 getContent메서드를 호출한다.
해당 기법은 테스트하기 쉬운 객체를 만드는 수단으로써는 유용하다.
그러나 테스트 대상 클래스를 서브클래싱하면 로직이 바뀌는 문제를 가지고 있다.
제어의 역전을 적용한 또 다른 리팩터링을 해보자.
필요한 리소스는 getContent 메서드나 WebClient 클래스로 전달되어야한다.
그리고 전달되어야하는 리소스는 HttpURLConnectionm 객체다.
따라서 getContent 메서드를 다음과 같이 변경할 수 있다.
public String getContent(URL url, HttpConnection connection)
이렇게 파라미터로 HttpURLConnection 객체생성을 WebClient를 호출한 쪽에 위임한다.
그런데 URL 객체는 HttpURLConnection 클래스에서 가져와야하므로 이 예제에서는 좋은 방법이 아니다.
대신에 ConnectionFactory 인터페이스를 만들어 이 인터페이스를 구현한 클래스는 HTTP,TCP/IP 등 연결의 종류가 무엇이든 적절한 InputStream 객체를 반환하는 것이다. 이 리팩터링 기법을 클래스 팩터리라고한다.
ConnectionFactory.java
import java.io.InputStream; public interface ConnectionFactory { InputStream getData() throws Exception; }
ConnectionFactory를 사용하여 WebClient 리팩터링
import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; 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; } }
HTTP 연결을 맺는 것과 독립적으로 콘텐츠를 읽고 있다.
이전 테스트에서는 HTTP 프로토콜을 사용하는 URL에서만 적용이 가능했지만,
이러한 방식은 어떠한 표준 프로토콜에서도 잘 작동한다.
ConnectionFactory를 구현한 HttpURLConnectionFactory 클래스
import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; 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 클래스에 대한 모의객체를 만들어 테스트할 수 있다.
import java.io.InputStream; public class McokConnectionFactory implements ConnectionFactory{ private InputStream inputStream; public void setData(InputStream stream) { this.inputStream = stream; } public InputStream getData() { return inputStream; } }
모의 객체에는 어떠한 비즈니스 로직도 들어있지 않고, setData를 통해 외부에서 제어가 가능하다.
두번째 테스트(클래스 팩터리 적용)
import org.junit.Test; import java.io.ByteArrayInputStream; import static org.junit.jupiter.api.Assertions.assertEquals; public class TestWebClient { @Test public void testGetContentOk() throws Exception { MockConnectionFactory mockConnectionFactory = new MockConnectionFactory(); mockConnectionFactory.setData(new ByteArrayInputStream("It works".getBytes())); WebClient2 client = new WebClient2(); String workingContent = client.getContent(mockConnectionFactory); assertEquals("It works", workingContent); } }
'DEV > JUnit' 카테고리의 다른 글
[JUnit] JUnit5 extension (확장 모델) (1) 2024.10.22 [JUnit] 모의 객체 프레임워크 EasyMock / JMock / Mockito (0) 2024.09.11 [JUnit] 모의 객체로 테스트하기 (0) 2024.08.25 [JUnit] 스텁을 활용한 테스트 (0) 2024.07.14 [JUnit] JUnit4에서 JUnit5로 마이그레이션 (0) 2024.06.30