Post

[Book - JUnit IN ACTION 3판] 9. 컨테이너를 활용한 테스트

일반적인 단위 테스트의 한계

사용자 인증이 되었는지를 검증하는 isAuthenticated 메서드를 구현하는 서블릿을 작성해 보자.

서블릿이란 웹 애플리케이션 서버에서 동작하는 자바 클래스를 말한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;

public class SampleServlet {

    private static final long serialVersionUID = 1L;

    public boolean isAuthenticated(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return false;
        }
        String authenticationAttribute = (String) session.getAttribute("authenticated");

        return Boolean.parseBoolean(authenticationAttribute);
    }
}

만약 isAuthenticated 메서드를 테스트하려면 사전에 유효한 HttpServletRequest 객체를 가지고 있어야 한다.
그런데 HttpServletRequest는 인터페이스이므로 new 예약어를 사용해서 HttpServletRequests를 생성할 수가 없다.
게다가 HttpServletRequest 객체의 생애 주기나 구현은 컨테이너에서 제공하는 것이지 개발자가 제공하는 것이 아니다.
HttpSession도 역시 마찬가지이다.

즉 JUnit만으로는 isAuthenticated 메서드가 일반적인 서플릿에 관한 테스트를 작성하기가 어렵다.

컴포넌트는 애플리케이션 또는 애플리케이션의 일부를 말한다.
컨테이너는 컴포넌트가 실행되고 있는 격리된 공간을 말하고, 생애 주기, 보안, 트랜잭션 등 컴포넌트를 위한 서비스를 제공한다.


서블릿이나 JSP에서 컨테이너는 Tomcat이나 Jetty 같은 서블릿 컨테이너를 말한다.
이 컨테이너가 런타임에 HttpServletRequest 같은 객체를 생성하고 관리하는 한 일반적인 JUnit만으로는 해당 객체를 테스트하기 어렵다.


모의 객체를 활용한 테스트

isAuthenticated 메서드를 단위 테스트하기 위해 고려할 만한 첫 번째 방법은 모의 객체 개념을 사용하여 HttpServletRequest 객체를 mock하는 것이다.

Mockito를 활용해서 서블릿을 테스트해 보자.

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
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.example.junitinaction3.chapter09.servlet.SampleServlet;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class TestSampleServletWithMockito {

    @Mock
    private HttpServletRequest request;

    @Mock
    private HttpSession session;

    private SampleServlet servlet;

    @BeforeEach
    public void setUp() {
        servlet = new SampleServlet();
    }

    @Test
    public void testIsAuthenticatedAuthenticated() {
        when(request.getSession(false)).thenReturn(session);
        when(session.getAttribute("authenticated")).thenReturn("true");
        assertTrue(servlet.isAuthenticated(request));
    }

    @Test
    public void testIsAuthenticatedNotAuthenticated() {
        when(request.getSession(false)).thenReturn(session);
        when(session.getAttribute("authenticated")).thenReturn("false");
        assertFalse(servlet.isAuthenticated(request));
    }

    @Test
    public void testIsAuthenticatedNoSession() {
        when(request.getSession(false)).thenReturn(null);
        assertFalse(servlet.isAuthenticated(request));
    }
}
  • 모의하려는 대상인 HttpServletRequest와 HttpSession에 대한 인스턴스 변수를 선언한다.
  • @BeforeEach 애노테이션이 달린 setUp() 메서드는 @Test 메서드가 실행되기 전에 실행된다.
    • setUp() 메서드에서 모의 객체를 초기화한다.
  • when().thenReturn()으로 모의 객체가 수행할 것으로 기대하는 동작을 선언한다.
  • assertTrue() 또는 assertFalse() 메서드로 예상 결과를 단언한다.

최소한의 컨테이너를 mock하는 것은 컴포넌트를 테스트하는 데 효과가 있다.
그러나 컨테이너를 모의하기 위해서는 복잡한 코드를 짜야 할 수도 있다.


컨테이너 활용하기

SampleServlet 클래스를 테스트하기 위해 HttpServletRequest와 HttpSession 객체를 갖고 있는 컨테이너를 직접 사용한다.
이렇게 하면 굳이 모의 객체를 사용할 필요 없이 실제 컨테이너에서 필요한 객체와 메서드에 직접 접근할 수 있다.

인증 메커니즘을 테스트하는 웹 요청과 세션이 각각 컨테이너에서 관리하는 실제 HttpServletRequest와 HttpSession 객체가 되어야 한다.
이렇게 컨테이너에서 테스트를 배포하고 실행하는 메커니즘컨테이너를 활용한 테스트(in-container testing)라고 한다.

컨테이너를 활용한 테스트 구현 전략

컨테이너를 활용한 테스트를 구현하는 방법은 서버 측과 클라이언트 측 두 가지 접근 방식으로 나뉜다.
첫 번째로 서버 측에서 컨테이너와 단위 테스트를 제어해서 테스트할 수 있다.
두 번째로 클라이언트 측에서 테스트를 실행할 수도 있다.

클라이언트 측에서 테스트를 실행하는 방식을 살펴 보자.

  1. 테스트가 패키징되어 컨테이너와 클라이언트에 배포되면 JUnit runner가 클라이언트에서 테스트를 실행한다.
  2. 클라이언트는 HTTP(S)와 같은 프로토콜을 통해 연결을 맺고 서버 측으로 동일한 테스트를 호출한다.
  3. 서버 측의 테스트는 HttpServletRequest, HttpServletResponse, HttpSession, BundleContext 같이 일반적으로 많이 사용하는 객체로 도메인 객체를 테스트한다.
  4. 테스트 결과는 서버 측에서 클라이언트 측으로 반환한다.

컨테이너를 활용한 테스트 프레임워크

컨테이너를 활용한 테스트는 컨테이너와 상호작용해야 하는 코드를 테스트하거나 HttpServletRequest와 같은 컨테이너 객체를 테스트가 직접 만들지 못할 때 유용하다.


스텁, 모의 객체, 컨테이너 테스트 비교하기

스텁 평가

  • 장점
    • 만들기 쉽고 이해하기 쉽다.
    • 그 자체로 강력하다.
    • 거친 테스트에 적합하다.
  • 단점
    • 상태를 확인하기 위해 특별한 방법이 필요하다.
    • 가짜로 만들어 낸 객체의 동작까지는 테스트하지 않는다.
    • 복잡한 상호작용을 따라가기 위해 너무 많은 시간이 든다.
    • 코드를 변경할 때마다 추가적인 유지 보수가 필요하다.

모의 객체 평가

  • 장점
    • 테스트를 실행하기 위해 굳이 컨테이너를 구동할 필요가 없다.
    • 테스트를 빠르게 설정하고 실행할 수 있다.
    • 세밀한 단위 테스트가 가능하다.
  • 단점
    • 컨테이너와 컴포넌트, 컴포넌트 간의 상호작용을 테스트할 수는 없다.
    • 컴포넌트의 배포는 테스트하지 못한다.
    • 모의할 API에 대한 충분한 도메인 지식이 필요한데, 특히 외부 라이브러리에 관해서는 지식을 습득하기 쉽지 않을 수 있다.
    • 대상 컨테이너에서 코드가 실행되리라는 확신을 주지 못한다.
    • 지나치게 세밀한 테스트만 작성되어 테스트 코드가 인터페이스로 가득 차 버릴 수도 있다.
    • 스텁과 마찬가지로 소스 코드가 변경되면 유지 보수가 필요하다.

컨테이너를 활용한 테스트 평가

  • 장점
    • 실제 환경과 유사한 테스트
    • 전체 시스템 테스트
    • 의존성 관리 및 격리
    • 일관된 테스트 환경 제공
  • 단점
    • 특별한 도구가 필요하다
    • IDE 지원이 나쁘다
    • 실행 시간이 너무 길다
    • 구성하기가 복잡하다.

Arquillian으로 테스트하기

Arquillian은 자바로 컨테이너를 활용한 테스트를 실행하기 위해 사용하는 프레임워크다.
크게 아래 세 가지 요소로 구성된다.

  • JUnit 같은 테스트 runner
  • WildFly, Tomcat, GlassFish, Jetty 등의 컨테이너
  • 컨테이너 리소스나 빈을 테스트 클래스에 주입하는 test enricher

Arquillian을 사용하면 컨테이너, 배포, 프레임워크 초기화 등을 관리하는 부담이 최소화된다.
한편 Arquillian은 Java EE 애플리케이션을 테스트하기 위한 프레임워크이므로, 사용하려면 제어의 역전에 대한 자바 EE 표준인 CDI에 관한 기본적인 지식이 필요하다.

항공편에 승객이 올바르게 추가되고 삭제되는지 테스트 해보고, 승객 수가 좌석 수를 초과하지 않는지도 검증해 보자.
이를 위해 Passenger와 Flight 클래스를 생성하고,
CSV 파일로 저장된 승객 정보를 애플리케이션의 메모리로 가져오는 FlightBuilderUtil 클래스를 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class Passenger {

    private final String identifier;
    private final String name;

    @Override
    public String toString() {
        return "Passenger " + getName() + " with identifier: " + getIdentifier();
    }
}
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
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.HashSet;
import java.util.Set;

@Getter
public class Flight {

    private String flightNumber;
    private int seats;
    Set<Passenger> passengers = new HashSet<>();

    public Flight(String flightNumber, int seats) {
        this.flightNumber = flightNumber;
        this.seats = seats;
    }

    public void setSeats(int seats) {
        if (passengers.size() > seats) {
            throw new RuntimeException("현재 승객 수보다 적은 좌석을 설정할 수 없습니다!");
        }
        this.seats = seats;
    }

    public int getNumberOfPassengers() {
        return passengers.size();
    }

    public boolean addPassenger(Passenger passenger) {
        if (passengers.size() >= seats) {
            throw new RuntimeException("좌석 수보다 더 많은 승객을 추가할 수 없습니다!");
        }
        return passengers.add(passenger);
    }

    public boolean removePassenger(Passenger passenger) {
        return passengers.remove(passenger);
    }

    @Override
    public String toString() {
        return "Flight " + getFlightNumber();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1236789; John Smith
9006789; Jane Underwood
1236790; James Perkins
9006790; Mary Calderon
1236791; Noah Graves
9006791; Jake Chavez
1236792; Oliver Aguilar
9006792; Emma McCann
1236793; Margaret Knight
9006793; Amelia Curry
1236794; Jack Vaughn
9006794; Liam Lewis
1236795; Olivia Reyes
9006795; Samantha Poole
1236796; Patricia Jordan
9006796; Robert Sherman
1236797; Mason Burton
9006797; Harry Christensen
1236798; Jennifer Mills
9006798; Sophia Graham
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.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class FlightBuilderUtil {

    public static Flight buildFlightFromCsv() throws IOException {
        Flight flight = new Flight("AA1234", 20);
        try (BufferedReader reader = new BufferedReader(new FileReader("/flights_information.csv"))) {
            String line = null;
            do {
                line = reader.readLine();
                if (line != null) {
                    String[] passengerString = line.split(";");
                    Passenger passenger = new Passenger(passengerString[0].trim(), passengerString[1].trim());
                    flight.addPassenger(passenger);
                }
            } while (line != null);
        }

        return flight;
    }
}

이제 여기에 Arquillian을 사용해 보자.

Arquillian은 단위 테스트로부터 컨테이너나 애플리케이션 구동 로직을 추상화한다.
대신 애플리케이션을 런타임에 직접 배포하는 패러다임을 적용하여 Java EE 서버에 프로그램을 직접 배포할 수 있다.

Arquillian은 대상 런타임에 애플리케이션을 배포하여 테스트 케이스를 실행한다.
이때 대상 런타임은 내장된 애플리케이션 서버일 수도 있고, 관리형 애플리케이션 서버일 수도 있다.

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
import jakarta.inject.Inject;
import org.example.junitinaction3.chapter09.airport.producers.FlightProducer;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit.Arquillian;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.IOException;

import static org.junit.Assert.assertEquals;

@RunWith(Arquillian.class)
public class FlightWithPassengersTest {

    @Deployment
    public static JavaArchive createDeployment() {
        return ShrinkWrap.create(JavaArchive.class)
                .addClasses(Passenger.class, Flight.class, FlightProducer.class)
                .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml");
    }

    @Inject
    Flight flight;

    @Test(expected = RuntimeException.class)
    public void testNumberOfSeatsCannotBeExceeded() throws IOException {
        assertEquals(20, flight.getNumberOfPassengers());
        flight.addPassenger(new Passenger("1247890", "Michael Johnson"));
    }

    @Test
    public void testAddRemovePassengers() throws IOException {
        flight.setSeats(21);
        Passenger additionalPassenger = new Passenger("1247890", "Michael Johnson");
        flight.addPassenger(additionalPassenger);
        assertEquals(21, flight.getNumberOfPassengers());
        flight.removePassenger(additionalPassenger);
        assertEquals(20, flight.getNumberOfPassengers());
        assertEquals(21, flight.getSeats());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.example.junitinaction3.chapter09.airport.Flight;
import org.example.junitinaction3.chapter09.airport.FlightBuilderUtil;

import javax.enterprise.inject.Produces;
import java.io.IOException;

public class FlightProducer {

    @Produces
    public Flight createFlight() throws IOException {
        return FlightBuilderUtil.buildFlightFromCsv();
    }
}
1
2
3
4
5
6
7
8
9
10
...
dependencies {
    ...
    /* Arquillian */
    testImplementation 'org.jboss.arquillian.junit:arquillian-junit-core:1.8.0.Final'
    testImplementation 'org.jboss.arquillian.junit:arquillian-junit-container:1.8.0.Final'
    /* CDI */
    testImplementation 'org.jboss.weld.se:weld-se-core:3.1.7.Final'
}
...

Java 21을 사용해서 코드 상에서는 ‘javax.inject.Inject’가 ‘jakarta.inject.Inject’로 바뀐게 끝이고,
gradle에 JUnit5는 호환이 안되는거 같아서 JUnit4에 맞는 Arquillian과 CDI 의존성을 추가했는데 아래 오류 코드를 어떻게 해결해야 할지 모르겠다..(중간중간 해결해 보자..)


정리

  • 컨테이너에서 제공하는 객체는 mock하기 어려우며, 상대적으로 많은 개발이 필요하고, 코드가 변경될 때마다 테스트의 기대도 그에 맞추어 변경되어야 하며, 격리된 테스트 실행 환경을 제공하지는 않는다.
  • 스텁, 모의 객체, 컨테이너의 장단점을 다시 확인해 보자.
  • Arquillian은 단위 테스트로부터 컨테이너나 애플리케이션 구동 로직추상화한다.
    (Arquillian을 활용한 테스트가 왜 안될까.. 다시 해보자ㅠ)
This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.