스텁(Stub)과 모의 객체(Mock)의 필요성
애플리케이션을 개발하다 보면 몇몇 코드 혹은 클래스들은 필수불가결하게 다른 클래스 혹은 특정환경에 의존한다. 특정 런타임 환경에 의존하는 애플리케이션을 위한 단위 테스트를 작성하는 것은 매우 어렵다. 테스트는 안정적이어야 하며 반복적으로 수행하더라도 같은 결과가 나와야 하기 때문이다.
예를 들어, 작성중인 애플리케이션이 타사에서 제공하는 웹 서버에 HTTP통신을 해야 하는 경우, 개발 환경에서는 그런 서버 통신을 하는 것이 사실상 불가능하다. ( 이것은 생각보다 실무에서 빈번하게 발생하는 문제이기도 하다. 개발 환경에 필요한 필수적인 인프라 스트럭처가 구체화되지 않았는데 개발자는 기능 구현을 할 수 있어야 한다. 😳 ) 그러므로 실제 장비 지원 없이도 소스 코드에 대한 테스트를 지속적으로 작성하고 실행할 수 있도록 통신해야 하는 웹 서버를 모사할 필요가 있다.
혹은 개발하고 있는 애플리케이션의 전반적인 기능이 구현되지 않았음에도 한 부분을 테스트하기 위해 다른 기능의 완성이 필요할 수 있다. 이럴 때는 아직 개발전인 부분에 대해 비슷하게 동작하는 가짜를 만들어 메꿀 수 있어야 한다.
이렇게 가짜 객체를 활용하는 방법에 스텁(Stub)과 모의객체(Mock)가 있다. 이번 글에서는 스텁(Stub)에 대해 보다 자세히 서술한다.
스텁(Stub) | 모의 객체(Mock) |
사전에 정의 된 동작만을 수행한다 | 사전에 정의된 동작을 수행하지 않는다 |
테스트 외부에 만들어져 있으므로 사용하는 위치나 횟수와 관계없이 동일한 동작을 수행하는데, 일반적으로 하드코딩한 값을 반환한다 | 테스트 실행 중에 모의 객체가 수행할 행동을 기대할 수 있고, 다른 테스트를 실행하고 모의 객체를 다시 초기화한 뒤 새로운 행동을 기대하는 것도 가능하다 |
스텁초기화 → 테스트 실행 → 단언문 검증 순으로 진행 | 모의 객체 초기화 → 기대 설정 → 테스트 실행 → 단언문 검증 순으로 진행 |
스텁(Stub)이란?
스텁은 실제 코드 혹은 아직 구현되지 않은 코드의 동작을 가장하기 위한 장치이다. 즉, 호출자를 실제 구현 코드에서부터 격리하기 위해 실제 코드 대신 런타임에 동작하는 코드를 말한다. 단순하게 만든 스텁으로 실제 코드의 복잡한 기능을 대체하면 애플리케이션에 독립적으로 테스트를 수행할 수 있다.
- 스텁을 활용하기 좋은 경우
- 기존 시스템이 너무 복잡하고 깨지기 쉬워 수정이 어려울때
- 소스 코드가 통제할 수 없는 외부 환경에 의존하고 있을 때
- 파일 시스템, 서버, 데이터베이스 같은 외부 시스템을 완전히 교체해야 할 때
- 하위 시스템 간 통합 테스트 같은 거친 테스트(반대되는 의미는 세밀한 테스트라고 표현된다. 애플리케이션의 개별적인 단위보다는 여러 컴포넌트 혹은 시스템의 상당 부분을 테스트할 경우를 의미한다)를 수행해야 할 때
- 스텁을 활용하기 힘든 경우 → 모의 객체를 사용하는 게 좋을 경우와 연결된다
- 실패의 원인을 밝힐 수 있는 정확한 에러 메시지를 확인하기 위해 세밀한 테스트가 필요할 때
- 코드 전체가 아니라 일부분만 격리해 테스트를 수행해야 할 때
스텁을 사용하면 테스트 대상 객체를 수정하지 않으면서도 실제 운영에서 실행되는 것과 동일한 소스를 테스트할 수 있다는 장점이 있다.
그러나 명확한 단점도 가진다.
- 단점
- 스텁은 작성하기 까다로워서 스텁 자체를 디버깅해야 하는 일이 종종 생긴다
- 스텁이 복잡해져서 유지 보수하기가 어려울 수 있다
- 스텁은 세밀한 단위 테스트에는 적합하지 않을 수 있다
- 테스트에 따라 다른 스텁을 만들어야 할 수도 있다
1. 스텁을 통한 HTTP 연결 테스트 - 웹 서버 리소스를 스텁으로 생성해 보자
스텁을 보다 쉽게 이해하기 위해 특정 URL에 대한 HTTP연결을 맺은 다음 웹 콘텐츠(B)를 읽어오는 애플리케이션을 가정(A)하고, 테스트 코드를 작성해 보자.
위 그림에서 스텁이 모사할 부분에 해당하는 것이 애플리케이션 B이다. A에서 B에 대해 접근하여 필요한 리소스를 긁어와야 하는데, B에 접근이 불가능하거나 B가 아직 구현되지 않았을 경우 B에 해당하는 가짜 객체를 생성해서 테스트 코드를 작성하는 것이다.
이렇게 스텁을 활용하면 A에서 getContent 메서드를 테스트하는 것과 애플리케이션 B가 실제로 반환하는 웹 리소스의 구현을 서로 독립적으로 만들 수 있다.
스텁으로 교체할 때 중요한 점은 A에서 getContent의 메서드는 수정되지 않아야 한다는 것이다. 단순하게 생각해 봐도 스텁이 교체될 때마다 A의 getContent의 내용이 변경된다면, 굳이 스텁을 생성하여 테스트 코드를 작성할 필요가 없기 때문이다. 스텁은 보다 독립적인 테스트 수행을 위해 존재한다.
아래 코드는 위 그림에서 A가 하는 역할을 구체화한 것이다.
package com.test.junit.ch07;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
/**
* A sample web-client class that opens an HTTP connection to a web-server and reads the response from it.
*
* @version $Id$
HttpURLConnection 타입의 HTTP 연결을 맺고, 스트림을 통해 웹 리소스를 읽어온다.
*/
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();
byte[] buffer = new byte[2048];
int count;
while (-1 != (count = is.read(buffer))) {
content.append(new String(buffer, 0, count));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return content.toString();
}
}
해당 코드를 실행해 보려면 반드시 개발 플랫폼에 구동가능한 서버가 있어야 한다. 상대적으로 쉬운 해결책으로 스텁으로 사용할 apache 서버를 설치하고 테스트 서버에서 기본적으로 보여 줄 간단한 웹페이지를 만드는 방법이 있다.
이런 방법은 전형적이고 자주 사용되지만, 아래와 같은 단점을 가진다.
- 스텁 대신 직접 설치해서 구현할 때의 단점
단점 | 설명 |
환경에 의존한다 | 테스트가 시작되기 전에 전체 환경이 구성되어 있고 실행중인지 확인해야 한다. 만약 아파치 서버를 구축했다면 테스트 코드를 실행할 때마다 아파치 서버가 정상적으로 동작하고 있어야 하는지 확인해야 한다는 뜻이다. 단위 테스트를 실행할 때 중요한 점은 테스트 결과를 차후에 똑같이 재현할 수 있도록 테스트를 실행하는 환경을 최대한 똑같이 유지해야 한다는 것인데, 이런 중요성을 지키기 어렵다. |
테스트 로직이 분리되어 있다 | 테스트를 성공적으로 수행하려면 흩어져 있는 리소스 모두를 항상 최신 상태로 유지해야한다. 가령, 테스트 대상 어플리케이션의 내용이 변경되어야 한다면 변경되는 내용이 요구하는 대로 apache 에서 요청하는 웹페이지를 새로 만들어야 한다. |
테스트를 자동화하기 어렵다 | 웹 페이지를 apache 웹 서버에 배포하고 웹 서버를 기동한 다음 단위 테스트를 실행하는 과정을 거쳐야한다. 즉, 테스트 자동화가 어려워 진다. |
이런 문제를 해결하기 위해 개발자는 내장 웹서버를 사용할 수 있다.
위 상황에서는 Jetty를 사용하면 Junit5 테스트 코드에서 서버를 구동하고 자바로 테스트를 작성한 다음 테스트 묶음을 자동화할 수 있다.
* Jetty
자바 기반의 오픈 소스 웹 서버이자 서블릿 컨테이너. 모듈화 된 아키텍처를 가지고 있으므로 개발자는 전체 서버를 만들 필요 없이 Jetty 핸들러만 스텁으로 생성해서 사용할 수 있다.
아래 코드는 Jetty를 통해 8081 포트로 HTTP요청을 수신할 수 있는 서버 객체를 생성하고, 그에 대한 응답을 테스트하는 예제이다.
/**
* A sample test case that demonstrates how to stub an HTTP server using Jetty as an embedded server.
*
* @version $Id$
*/
public class TestWebClient {
private WebClient client = new WebClient();
@BeforeAll
public static void setUp() throws Exception {
Server server = new Server(8081); // 8081포트를 사용하는 서버 생성
Context contentOkContext = new Context(server, "/testGetContentOk");
contentOkContext.setHandler(new TestGetContentOkHandler());
Context contentErrorContext = new Context(server, "/testGetContentError");
contentErrorContext.setHandler(new TestGetContentServerErrorHandler());
Context contentNotFoundContext = new Context(server, "/testGetContentNotFound");
contentNotFoundContext.setHandler(new TestGetContentNotFoundHandler());
server.setStopAtShutdown(true);
server.start();
}
@AfterAll
public static void tearDown() {
// Empty
}
@Test
public void testGetContentOk() throws MalformedURLException {
String workingContent = client.getContent(new URL("http://localhost:8081/testGetContentOk"));
assertEquals("It works", workingContent);
}
/**
* Handler to handle the good requests to the server.
*/
private static class TestGetContentOkHandler extends AbstractHandler {
public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException {
OutputStream out = response.getOutputStream();
ByteArrayISO8859Writer writer = new ByteArrayISO8859Writer();
writer.write("It works");
writer.flush();
response.setIntHeader(HttpHeaders.CONTENT_LENGTH, writer.size());
writer.writeTo(out);
out.flush();
}
}
/**
* Handler to handle bad requests to the server
*/
private static class TestGetContentServerErrorHandler extends AbstractHandler {
public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
}
}
/**
* Handler to handle requests that request unavailable content.
*/
private static class TestGetContentNotFoundHandler extends AbstractHandler {
public void handle(String target, HttpServletRequest request, HttpServletResponse response, int dispatch) throws IOException {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
}
이렇게 스텁을 통한 테스트를 진행하면 메서드를 단위 테스트하는 동시에 통합테스트를 수행하는 결과를 얻을 수 있다.
이러한 스텁 테스트를 구현할 때 명심해야 할 점은 스텁을 단순하게 만들어야 하며 애플리케이션 개발에 들이는 것만큼의 노력을 스텁을 테스트하고 유지 보수하는 데 들여서는 안 된다는 것이다. 스텁을 디버깅하는 데 너무 많은 시간을 쓰고 있다면 다른 방법을 찾는 게 좋다.
2. 스텁을 통한 HTTP 연결 테스트 - HTTP 연결을 스텁으로 생성하기
이전 예제에서는 스텁으로 웹 서버 리소스 자체를 만들었다. 이러한 방법은 단위 테스트와 통합 테스트를 동시에 수행하므로 복잡하고 무겁다는 단점을 가진다. 따라서 전체 웹 서버를 스텁으로 생성하지 않으면서 보다 단순하게 비슷한 기능을 테스트하는 방법을 소개하고자 한다.
이것은 다음 게시글에서 다룰 모의 객체(Mock) 내용과 유사하다.
보다 적은 범위인 단위 테스트에 초점을 맞췄기 때문에, HTTP 연결은 테스트 범위에서 배제된다.
다음 코드를 보자.
package com.test.junit.ch07;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
/**
* A stub class to stub the HttpUrl connection. We override the getInputStream method to return the "It works" string.
* HttpConnnection을 모사한 스텁 객체
* @version $Id$
*/
public class StubHttpURLConnection extends HttpURLConnection {
private boolean isInput = true;
protected StubHttpURLConnection(URL url) {
super(url);
}
@Override
public InputStream getInputStream() throws IOException {
if (!isInput) {
throw new ProtocolException("Cannot read from URLConnection" + " if doInput=false (call setDoInput(true))");
}
ByteArrayInputStream readStream = new ByteArrayInputStream(new String("It works").getBytes());
return readStream;
}
@Override
public void connect() throws IOException {
}
@Override
public void disconnect() {
}
@Override
public boolean usingProxy() {
return false;
}
}
package com.test.junit.ch07;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class TestWebClient1 {
@BeforeAll
public static void setUp() {
URL.setURLStreamHandlerFactory(new StubStreamHandlerFactory());
}
private static class StubStreamHandlerFactory implements URLStreamHandlerFactory {
@Override
public URLStreamHandler createURLStreamHandler(String protocol) {
return new StubHttpURLStreamHandler();
}
}
private static class StubHttpURLStreamHandler extends URLStreamHandler {
@Override
protected URLConnection openConnection(URL u) throws IOException {
return new StubHttpURLConnection(u); // HttpConnection을 모사한 스텁 객체 반환
}
}
@Test
public void testGetContentOk() throws MalformedURLException {
WebClient client = new WebClient();
String workingContent = client.getContent(new URL("http://localhost/"));
assertEquals("It works", workingContent);
}
}
웹 리소스 자체를 스텁으로 생성한 이전과 다르게 원격 웹 리소스에 대한 HTTP 연결을 스텁으로 생성했다. 이를 통해 WebClient의 비즈니스 로직에 대한 단위 테스트를 보다 쉽게 생성할 수 있다.