Post

[Book - JUnit IN ACTION 3판] 7. 스텁을 활용한 거친 테스트

아래 그림과 같이 다른 클래스에 의존하는 애플리케이션이 있고, 다른 클래스 역시 또 다른 클래스나 외부 환경에 의존한다.

Application dependency

JUnit 5를 사용해서 HTTP 서버, 데이터베이스, 물리 장비 등의 외부 리소스에 의존하는 애플리케이션을 테스트하는 방법을 알아보자.

특정한 런타임 환경에 의존하는 애플리케이션을 위한 단위 테스트를 작성하기는 매우 어렵다.
테스트는 안정적이어야 하며 반복적으로 수행해도 같은 결과가 나와야 하기 때문이다.
즉 테스트가 실행되는 환경을 제어할 필요가 있는데, 이를 위한 방법으로 실제로 필요한 환경을 테스트의 일부로 구성하여 테스트를 수행하는 것이다.

스텁을 사용한다면 스텁이 사전에 정의된 동작만을 수행한다는 점을 유의해야 한다.
스텁 코드는 테스트 외부에 만들어져 있으며, 사용하는 위치나 횟수와 관계없이 동일한 동작을 수행하는데, 일반적으로 하드 코딩한 값을 반환한다.
스텁으로 테스트할 때는 ‘스텁 초기화 > 테스트 실행 > 단언문 검증’ 순서로 진행한다.

반면 모의 객체사전에 정의된 동작을 수행하지는 않는다.
대신 테스트 실행 중에 모의 객체가 수행할 행동을 기대할 수 있다.
다른 테스트를 실행하고 모의 객체를 다시 초기화한 뒤 새로운 행동을 기대하는 것도 가능하다.
모의 객체로 테스트할 때는 ‘모의 객체 초기화 > 기대 설정 > 테스트 실행 > 단언문 검증’ 순서로 진행한다.

스텁 살펴보기

스텁은 실제 코드 혹은 아직 구현되지 않은 코드의 동작을 가장하기 위한 장치이다.
시스템의 일부를 사용할 수 없는 상황에서 테스트하기 위해 스텁을 활용한다.
일반적으로 스텁은 테스트 대상 코드를 변경하지는 않는다.
대신 애플리케이션에서 빠진 부분을 빈틈없이 채워주는 역할을 한다.

다시 말해, 스텁은 호출자를 실제 구현 코드에서부터 격리하기 위해 실제 코드 대신 런타임에 동작하는 코드를 말한다.
단순하게 만든 스텁으로 실제 코드의 복잡한 기능을 대체하면 애플리케이션에 독립적으로 테스트를 수행할 수 있다.


  • 스텁을 활용하기 좋은 경우
    • 기존 시스템이 너무 복잡하고 깨지기 쉬워 수정이 어려울 때
    • 소스 코드가 통제할 수 없는 외부 환경에 의존하고 있을 때
    • 파일 시스템, 서버, 데이터베이스 같은 외부 시스템을 완전히 교체해야 할 때
    • 하위 시스템 간 통합 테스트 같은 거친 테스트를 수행해야 할 때
  • 스텁을 활용하기 어려운 경우
    • 실패의 원인을 밝힐 수 있는 정확한 에러 메시지를 확인하기 위해 세밀한 테스트가 필요할 때
    • 코드 전체가 아니라 일부분만 격리해 테스트를 수행해야 할 때

      이런 상황에서는 스텁보다 모의 객체를 사용하는 것이 좋다.

일반적으로 스텁은 테스트가 많이 되어 있는 시스템에서 신뢰도가 높은 편이다.
스텁을 활용하면 테스트 대상 객체를 수정하지 않으면서도 실제 운영에서 실행되는 것과 동일한 소스를 테스트할 수 있다는 장점이 있다.

실제 실행 중인 환경에서 자동화된 빌드를 통해서 혹은 개발자가 수동으로 테스트를 수행했을 때 스텁을 활용해 테스트를 실행하면 개발에 확신을 얻을 수 있다.

스텁의 단점은 작성하기가 까다롭다는 것이다.
가장해야 하는 시스템이 복잡할 때는 더욱 그렇다.
스텁은 모사해야 하는 코드를 간단하고 단순한 방식으로 구현해야 하므로 비즈니스 로직이 복잡하다면 스텁이 적합하지 않다.

  • 스텁 활용의 단점
    • 작성하기가 까다로워서 스텁 자체를 디버깅해야 하는 일이 종종 생긴다.
    • 스텁이 복잡해져서 유지 보수하기가 어려울 수 있다.
    • 세밀한 단위 테스트에는 적합하지 않을 수 있다.
    • 테스트에 따라 다른 스텁을 만들어야 할 수도 있다.

스텁으로 HTTP 연결 테스트하기

예를 들어, 웹 클라이언트에서 웹 서버에 있는 웹 리소스에 HTTP 연결을 시도한다.
여기에 스텁을 활용한다면,
테스트 케이스를 작성해서 테스트 대상인 웹 클라이언트는 수정하지 않고, 웹서버에 있는 웹 리소스를 스텁으로 만들어서 HTTP 연결을 시도한다.

즉, 테스트 대상 애플리케이션을 수정하지 않아야 한다.
만약 스텁으로 대체하는 것이 가능하려면 스텁으로 대체할 코드에 잘 정의된 인터페이스가 있는 것이 좋고,
스텁을 구현할 수 있도록 다양한 구현체를 생성하는 것이 혀용되어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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();
            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();
    }
}

위 코드는 HTTP 연결을 맺는 메서드의 예시다.

무엇을 스텁으로 대체할 것인가

어떤 경우가 되더라도 WebClient 클래스를 단위 테스트할 수 있도록 개발 플랫폼에 구동 가능한 서버가 있어야 한다.

상대적으로 쉬운 방법은 스텁으로 사용할 Apache 테스트 서버를 설치하고 테스트 서버에서 기본으로 보여 줄 간단한 웹 페이지를 만드는 것이다.
전형적이고 일반벅으로 자주 사용되지만 아래와 같은 단점이 존재한다.

  • 환경에 의존한다.
  • 테스트 로직이 분리되어 있다.
  • 테스트를 자동화하기 어렵다.

다행히도 쉬운 해결책이 있다.
바로 내장 웹 서버를 사용하는 것이다.
테스트가 자바로 작성되었으므로 테스트에 사용할 수 있는 자바 기반의 내장 웹 서버를 사용하면 간단히 해결할 수 있다.
자바 기반의 오픈 소스 웹 서버이자 서블릿 컨테이너인 Jetty를 사용해 보자.

Jetty를 내장 웹 서버로 활용하기

Jetty를 사용하면 위에서 언급한 단점을 일부 보완할 수 있다.
JUnit 5 테스트 코드에서 서버를 구동하고 자바로 테스트를 작성한 다음 테스트 묶음을 자동화할 수 있기 때문이다.
Jetty는 모듈화된 아키텍처를 갖고 있으므로 개발자는 전체 서버를 만들 필요 없이 Jetty 핸들러만 스텁으로 만들면 된다.

maven이 아닌 gradle로 Jetty를 사용해본다.

1
2
3
4
5
6
7
8
dependencies {
    ...
    implementation 'org.eclipse.jetty:jetty-server:11.0.15'
    implementation 'org.eclipse.jetty:jetty-servlet:11.0.15'
    implementation 'org.eclipse.jetty:jetty-io:11.0.15'
    implementation 'org.eclipse.jetty:jetty-util:11.0.15'
    implementation 'org.eclipse.jetty:jetty-http:11.0.15'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ResourceHandler;
import org.eclipse.jetty.server.handler.ContextHandler;

public class JettySample {
    public static void main(String[] args) throws Exception {
        Server server = new Server(8081);

        ResourceHandler resourceHandler = new ResourceHandler();
        resourceHandler.setResourceBase("./build.gradle");
        resourceHandler.setDirectoriesListed(true);

        ContextHandler contextHandler = new ContextHandler("/");
        contextHandler.setHandler(resourceHandler);

        server.setHandler(contextHandler);

        server.setStopAtShutdown(true);

        server.start();
    }
}
  • Server 객체를 생성한다.
    • 생성자를 보면 8081 포트로 HTTP 요청을 수신한다.
  • ResourceHandler를 사용하여 정적 리소스를 제공한다.
    • 제공할 리소스의 기본 경로 설정한다.
    • 디렉터리 목록 표시 여부를 설정한다.
  • ContextHandler를 사용하여 특정 컨텍스트 경로에 대한 핸들러를 설정한다.
  • 서버에 핸들러를 설정한다.
  • 서버를 구동한다.

위 코드를 실행해서 Jetty 내장 웹 서버로 127.0.0.1:8081에 접근하면 build.gradle의 내용을 볼 수 있다.


스텁으로 웹 서버 리소스 만들기

개발자는 유효한 URL을 호출한 다음 해당 URL에서 웹 콘텐츠를 가져올 수 있는지 확인하는 테스트를 작성한다.
이러한 테스트는 외부 클라이언트와 상호작용하는 웹 애플리케이션의 기능을 검증하는 첫 번째 단계로 볼 수 있다.

첫 번째 스텁 설정하기

WebClient 클래스가 잘 작동하는지 확인하고 싶다면 사전에 Jetty 서버를 구동해야 한다.

아래 코드는 WebClient 클래스가 유효한 경로로 작동하는지 검증하기 위한 테스트 스켈레톤이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class TestWebClientSkeleton {

    @BeforeAll
    public static void setUp() {
        // Jetty 서버를 시작하고
        // http://127.0.0.1:8081/testGetContentOk 경로로
        // 접근했을 때 "It works"를 반환하도록 설정한다.
    }

    @AfterAll
    public static void tearDown() {
        // Jetty 서버를 중지한다.
    }

    @Test
    @Disabled(value = "단순한 테스트 스켈레톤이므로 현재 이 테스트를 실행하면 실패한다.")
    public void testGetContentOk() throws MalformedURLException, URISyntaxException {
        WebClient client = new WebClient();
        String workingContent = client.getContent(new URI("http://127.0.0.1:8081/testGetContentOk").toURL());
        assertEquals("It works", workingContent);
    }
}

@BeforeAll, @AfterAll 메서드를 구현하는 방법은 두 가지가 있다.

  1. 문서 루트 경로에 “It works” 문자열을 포함한 정적 페이지를 만들어 놓는 것
    • resourceHandler.setResourceBase("./build.gradle");같은 코드를 호출
  2. 파일에서 가져오지 않고 “It works” 문자열을 반환하는 사용자 정의 핸들러를 사용하도록 Jetty를 구성하는 것
    • 이 방법이 선호된다.
    • HTTP 서버가 WebClient 객체로 만든 애플리케이션에 에러 코드를 반환하는 경우도 테스트할 수 있다.

URL(String)은 Java 20부터 deprecated 되었기 때문에
new URI(“http://127.0.0.1:8081/testGetContentOk”).toURL()와 같이 사용해야 한다.

사용자 정의 핸들러를 사용하도록 Jetty 구성하기

아래 코드는 “It works” 문자열을 반환하는 Jetty Handler 객체를 생성한다.

gradle에 추가한 Jetty 버전에 맞게 코드를 변경했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.server.handler.ContextHandlerCollection;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class TestWebClient {

    private WebClient client = new WebClient();

    @BeforeAll
    public static void setUp() throws Exception {
        Server server = new Server(8081);

        ContextHandler contentOkContextHandler = new ContextHandler("/testGetContentOk");
        contentOkContextHandler.setHandler(new TestGetContentOkHandler());

        ContextHandler contentErrorContextHandler = new ContextHandler("/testGetContentError");
        contentErrorContextHandler.setHandler(new TestGetContentServerErrorHandler());

        ContextHandler contentNotFoundContextHandler = new ContextHandler("/testGetContentNotFound");
        contentNotFoundContextHandler.setHandler(new TestGetContentNotFoundHandler());

        ContextHandlerCollection contexts = new ContextHandlerCollection();
        contexts.setHandlers(new Handler[]{contentOkContextHandler, contentErrorContextHandler, contentNotFoundContextHandler});

        server.setHandler(contexts);
        server.setStopAtShutdown(true);
        server.start();
    }

    @Test
    public void testGetContentOk() throws MalformedURLException, URISyntaxException {
        String workingContent = client.getContent(new URI("http://localhost:8081/testGetContentOk").toURL());
        assertEquals("It works", workingContent);
    }

    private static class TestGetContentOkHandler extends AbstractHandler {
        @Override
        public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
            response.setContentType("text/plain; charset=UTF-8");
            response.setStatus(HttpServletResponse.SC_OK);
            baseRequest.setHandled(true);

            try (OutputStream out = response.getOutputStream()) {
                String message = "It works";
                byte[] messageBytes = message.getBytes(StandardCharsets.ISO_8859_1);
                response.setContentLength(messageBytes.length);
                out.write(messageBytes);
                out.flush();
            }
        }
    }

    private static class TestGetContentServerErrorHandler extends AbstractHandler {
        @Override
        public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
            response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
            baseRequest.setHandled(true);
        }
    }

    private static class TestGetContentNotFoundHandler extends AbstractHandler {
        @Override
        public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            baseRequest.setHandled(true);
        }
    }
}
  • @BeforeAll 애노테이션이 달린 setUp 메서드는 Server 객체를 생성한다.
  • @Test 애노테이션이 달린 testGetContentOk 메서드는 이전과 크게 달라지지 않았다.
  • @AfterAll 애노테이션이 붙은 tearDown 메서드는 의도적으로 비운다.
    • testGetContentOk 메서드에서는 테스트가 종료될 때 서버가 중지되도록 설정했기 때문이다.
  • 서버 인스턴스는 JVM이 종료될 때 중지된다.

첫 번째 스텁 테스트 되짚어 보기

메서드를 단위 테스트하는 동시에 통합 테스트를 수행할 수 있었기 때문에 큰 성과가 있었다.
비즈니스 로직뿐만 아니라 HttpURLConnection 클래스를 통해 코드 외부에 있는 HTTP 연결 부분도 테스트했다.

하지만 이런 접근 방법도 Jetty를 잘 다루지 못하거나, 스텁이 올바르게 동작하는지 확인하기 위해 스텁을 디버깅해야 할 수도 있어서 복잡하다는 문제가 있다.

명심해야 할 점은 스텁은 단순하게 만들어야 하며, 애플리케이션 개발에 들이는 것만큼의 노력을 스텁을 테스트하고 유지 보수하는 데 들여서는 안된다는 것이다.


스텁으로 HTTP 연결 만들기

사용자 정의 URL 프로토콜 핸들러 생성하기

아래 코드는 테스트를 위한 스트림 핸들러 클래스다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

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 url) {
            return new StubHttpURLConnection(url);
        }
    }

    @Test
    public void testGetContentOk() throws MalformedURLException, URISyntaxException {
        WebClient client = new WebClient();
        String workingContent = client.getContent(new URI("http://localhost/").toURL());
        assertEquals("It works", workingContent);
    }
}
  • URL.setURLStreamHandlerFactory 메서드는 스텁으로 사용할 StubStreamHandlerFactory 객체를 설정한다.
  • StubHttpURLConnection 클래스를 사용하기 위해 내부 클래스를 구현한다.
  • StubStreamHandlerFactory에서 createURLStreamHandler 메서드를 재정의한다.
    • 이 메서드에서 또 다른 스텁인 StubHttpURLStreamHandler 인스턴스를 반환한다.
  • StubHttpURLStreamHandler에서 openConnection 메서드를 재정의하고, StubHttpURLConnection 인스턴스를 반환한다.

HttpURLConnection 스텁 생성하기

아래 코드는 스텁으로 만든 HttpURLConnection 클래스다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;

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;
    }
}
  • HttpURLConnection을 상속하여 스텁으로 사용할 수 있게 메서드를 재정의한다.
  • getInputStream 메서드는 테스트 대상 메서드다.
  • 테스트할 코드가 HttpURLConnection에서 getInputStream 이외에 다른 API를 사용했다면 그 또한 스텁으로 만들어야 한다.
    • 이 부분에서 스텁이 복잡해진다.
    • 실제 HttpURLConnection을 대체하기 위해 계속 로직이 늘어나기 때문이다.

HTTP 연결을 스텁으로 대체한 테스트 수행하기

웹 리소스를 스텁으로 만드는 것보다 HTTP 연결을 스텁으로 만드는 것이 쉽다.

HTTP 연결을 스텁으로 만들면 통합 테스트를 할 수는 없다.
그러나 WebClient의 비즈니스 로직에 대한 단위 테스트를 더 쉽게 만들 수 있다.

모의 객체를 사용하면 개발자가 더 좋은 소스 코드를 개발할 수 있도록 세밀한 단위 테스트를 수행하는 데 도움이 된다.
모의 객체를 활용하는 새로운 테스트 전략은 코드 수정을 허용할 뿐만 아니라 장려하기도 한다.


정리

  • 스텁이 권장될 때
    • 복잡하거나 깨지기 쉬운 기존 시스템을 수정하기 어려울 때
    • 개발자가 제어할 수 없는 환경에 의존해야 할 때
    • 외부 시스템을 전면적으로 교체해야 할 때
    • 거친 테스트를 해야 할 때
  • 스텁을 사용하면 안될 때
    • 정확히 어떤 이유로 실패했는지 구체적인 에러 메시지르 받기 위해서 세밀한 테스트가 필요할 때
    • 코드의 일부분을 격리해 테스트를 구행하고자 할 때
  • 웹 서버에 대한 스텁은 Jetty로 구현해 보자.
  • 웹 리소스를 스텁으로 만드는 것보다 HTTP 연결을 스텁으로 만드는 것이 쉽다.
  • 만약 스텁으로 대체하는 것이 가능하려면 스텁으로 대체할 코드에 잘 정의된 인터페이스가 있는 것이 좋다.
This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.