레거시 통합 테스트 구축기 2 - IntegrationRule 구성

장성호·2024년 8월 24일
0

[Server]

목록 보기
6/6
post-thumbnail

복기

지난 게시글에서 Junit 4 Parameterized Test과 Spring Context를 엮으려고 할 때, @RunWith는 하나만 사용가능하기 때문에 둘 중 하나를 포기해야하는 문제점을 발견했다. 둘 중 하나라도 포기하면 스프링 기반 통합 테스트를 진행하기엔 너무나도 불편해지기 때문에 어떻게든 개선해야하는 문제점이다. 사실상 Mockito도 테스트 엔진을 활성화시키기 위해서 @RunWith를 보통 사용하기 때문에, Junit 4 Parameterized Test / Spring Test / Mockito 중에 1개만 사용가능한 상황이다. (직접 설정하면 되긴 하지만, 매 번 다 설정하라 그러면 누가 테스트 코드를 짤까...) 일단 개선을 위해서 지난 게시글에서 구성한 기초적인 테스트 환경을 다시 한 번 정리해보자.

성공

  • FixtureMonkey를 활용한 TestFixture 자동 생성
  • Junit4의 @RunWith(Paramterized.class)를 활용해, 복수의 TestCase에 대해 테스트 가능
  • AbstractJUnit4SpringContextTests를 사용해 MyBatis와 함께 통합 테스트 가능

개선해야할 점

  • @RunWith(Paramterized.class), AbstractJUnit4SpringContextTests(@RunWith(SpringRunner.class)), @RunWith(MockitoJUnitRunner.class) 중 1개만 선택 가능
  • Paramterized test는 @RunWith를 사용해야만 함.

일단 Junit 4에서는 복수 테스트 케이스를 테스트하고 싶다면 @RunWith(Paramterize.class)를 고정으로 사용해야한다. 그렇다면 Spring과 Mockito는 @RunWith를 사용하지 않고 우회할 수 있는 방법이 있는지 찾아보자.

Junit과 TestRule

public interface TestRule {
    Statement apply(Statement var1, Description var2);
}

public class SpringClassRule implements TestRule {
    private static final Log logger = LogFactory.getLog(SpringClassRule.class);
    private static final Map<Class<?>, TestContextManager> testContextManagerCache = new ConcurrentHashMap(64);

    public SpringClassRule() {
    }
    
	public Statement apply(Statement base, Description description) {
        Class<?> testClass = description.getTestClass();
        if (logger.isDebugEnabled()) {
            logger.debug("Applying SpringClassRule to test class [" + testClass.getName() + "]");
        }

        validateSpringMethodRuleConfiguration(testClass);
        TestContextManager testContextManager = getTestContextManager(testClass);
        Statement statement = this.withBeforeTestClassCallbacks(base, testContextManager);
        statement = this.withAfterTestClassCallbacks(statement, testContextManager);
        statement = this.withProfileValueCheck(statement, testClass);
        statement = this.withTestContextManagerCacheEviction(statement, testClass);
        return statement;
    }
    
	// ...
}

Junit4에서는 테스트 규칙을 정의하기 위한 기본 인터페이스로 TestRule을 제공한다. 각 테스트 메서드가 실행될 때마다 Junit에 의해 호출되는 녀석이라고 생각하면 된다. 따라서 TestRule을 구현해 다음과 같은 작업을 수행할 수 있다.

  • 테스트 메서드 실행 전후에 공통 작업을 수행
  • 테스트 메서드의 실행을 조건부로 변경 (예: 특정 조건을 만족할 때만 테스트 실행)
  • 테스트 메서드의 결과를 수정하거나, 예외를 처리

각 테스트 메서드에 대해서 굉장히 세밀하게 조절할 수 있기 때문에, TestRule 인터페이스를 구현한다면 @RunWith 없이도 여러 가지 설정을 직접할 수 있다.

ClassRule과 Rule

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface ClassRule {
    int order() default -1;
}

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface Rule {
    int DEFAULT_ORDER = -1;

    int order() default -1;
}
public abstract class AbstractSpringContextTests {

    @ClassRule
    public static final SpringClassRule SPRING_CLASS_RULE = new SpringClassRule();

    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();
}

TestRule을 구현했다면 Junit4가 인식할 수 있게 규칙을 등록해주는 과정이 필요하다. 이를 @Rule과 @ClassRule이라는 어노테이션을 사용해 진행한다. 이렇게 적용된 규칙은 다음과 같은 생명 주기를 가진다.

  1. 클래스 로딩
  2. @BeforeClass 메서드 실행
  3. @ClassRule의 apply() (초기화 작업)
  4. 각 테스트 메서드 실행:
    • @Rule의 apply() (초기화 작업)
    • @Before 메서드 실행
    • 테스트 메서드 실행 (test1(), test2() 등)
    • @After 메서드 실행
    • @Rule의 apply() (후처리 작업)
  5. @ClassRule의 apply() (후처리 작업)
  6. @AfterClass 메서드 실행

Rule과 Before, After 등이 분리되는 것을 확인할 수 있는데, 이건 다음 포스팅에서 Junit 실제 구현 코드와 함께 딥다이브하며 알아보자.

Spring

SpringClassRule 코드를 다시 가져왔다. @RunWith(SpringJUnit4ClassRunner.class)로 사용되던 SpringJUnit4ClassRunner 코드도 가져왔다. 구현을 비교해보면서 SpringClassRule이 문제점을 개선시켜줄 수 있는지 확인해보자.

public class SpringClassRule implements TestRule {
    private static final Log logger = LogFactory.getLog(SpringClassRule.class);
    private static final Map<Class<?>, TestContextManager> testContextManagerCache = new ConcurrentHashMap(64);

    public SpringClassRule() {
    }

    public Statement apply(Statement base, Description description) {
        Class<?> testClass = description.getTestClass();
        if (logger.isDebugEnabled()) {
            logger.debug("Applying SpringClassRule to test class [" + testClass.getName() + "]");
        }

        validateSpringMethodRuleConfiguration(testClass);
        
        // Spring test context 설정
        TestContextManager testContextManager = getTestContextManager(testClass);
        Statement statement = this.withBeforeTestClassCallbacks(base, testContextManager);
        statement = this.withAfterTestClassCallbacks(statement, testContextManager);
        statement = this.withProfileValueCheck(statement, testClass);
        statement = this.withTestContextManagerCacheEviction(statement, testClass);
        return statement;
    }
    
    /**
   	  * 테스트 클래스 초기화 콜백
      */
    private Statement withBeforeTestClassCallbacks(Statement statement, TestContextManager testContextManager) {
        return new RunBeforeTestClassCallbacks(statement, testContextManager);
    }
    // ...
}

/**
  * Spring에서 테스트 클래스 초기화 콜백을 처리하는 클래스 
  */
public class RunBeforeTestClassCallbacks extends Statement {
    private final Statement next;
    private final TestContextManager testContextManager;

    public RunBeforeTestClassCallbacks(Statement next, TestContextManager testContextManager) {
        this.next = next;
        this.testContextManager = testContextManager;
    }

    public void evaluate() throws Throwable {
        this.testContextManager.beforeTestClass();
        this.next.evaluate();
    }
}

public class TestContextManager {
    private static final Log logger = LogFactory.getLog(TestContextManager.class);
    private final TestContext testContext;
    private final List<TestExecutionListener> testExecutionListeners;

	/**
      * Test class가 설정되면 TestContextBootstrapper를 통해 Spring context 초기화, TestExecutionListener 리스트를 등록
      */ 
    public TestContextManager(Class<?> testClass) {
        this(BootstrapUtils.resolveTestContextBootstrapper(BootstrapUtils.createBootstrapContext(testClass)));
    }

    public TestContextManager(TestContextBootstrapper testContextBootstrapper) {
        this.testExecutionListeners = new ArrayList();
        this.testContext = testContextBootstrapper.buildTestContext();
        this.registerTestExecutionListeners(testContextBootstrapper.getTestExecutionListeners());
    }

    public final TestContext getTestContext() {
        return this.testContext;
    }
    
    /**
      * 테스트 클래스를 가져와서 해당 클래스와 연결된 Spring context를 준비한 뒤 상태 업데이트 
      */
    public void beforeTestClass() throws Exception {
        Class<?> testClass = this.getTestContext().getTestClass();
        if (logger.isTraceEnabled()) {
            logger.trace("beforeTestClass(): class [" + testClass.getName() + "]");
        }

        this.getTestContext().updateState((Object)null, (Method)null, (Throwable)null);
        Iterator var2 = this.getTestExecutionListeners().iterator();

        while(var2.hasNext()) {
            TestExecutionListener testExecutionListener = (TestExecutionListener)var2.next();

            try {
                testExecutionListener.beforeTestClass(this.getTestContext());
            } catch (Throwable var5) {
                if (logger.isWarnEnabled()) {
                    logger.warn("Caught exception while allowing TestExecutionListener [" + testExecutionListener + "] to process 'before class' callback for test class [" + testClass + "]", var5);
                }

                ReflectionUtils.rethrowException(var5);
            }
        }

    }
    
    // ...
public SpringJUnit4ClassRunner(Class<?> clazz) throws InitializationError {
        super(clazz);
        if (logger.isDebugEnabled()) {
            logger.debug("SpringJUnit4ClassRunner constructor called with [" + clazz + "]");
        }

        ensureSpringRulesAreNotPresent(clazz);
        
        /**
          * 위에서 봤던 TestContextManager처럼, Test class가 설정되면 TestContextBootstrapper를 통해 Spring context 초기화, TestExecutionListener 리스트를 등록
          */
        this.testContextManager = this.createTestContextManager(clazz);
    }

    protected TestContextManager createTestContextManager(Class<?> clazz) {
        return new TestContextManager(clazz);
    }
}

SpringClassRule와 SpringJUnit4ClassRunner를 비교해보니, 둘 모두 테스트 클래스가 설정되면 TestContextBootstrapper를 통해 Spring context를 초기화해준다는 것을 알 수 있다. 그럼 마지막으로 SpringMethodRule도 검토해보자.

public class SpringMethodRule implements MethodRule {
    private static final Log logger = LogFactory.getLog(SpringMethodRule.class);

    public SpringMethodRule() {
    }

    public Statement apply(Statement base, FrameworkMethod frameworkMethod, Object testInstance) {
        if (logger.isDebugEnabled()) {
            logger.debug("Applying SpringMethodRule to test method [" + frameworkMethod.getMethod() + "]");
        }

        Class<?> testClass = testInstance.getClass();
        validateSpringClassRuleConfiguration(testClass);
        
        /** 
          * SpringClassRule에서 설정된 TestContextManager를 가져와서 사용한다.
          */ 
        TestContextManager testContextManager = SpringClassRule.getTestContextManager(testClass);
        Statement statement = this.withBeforeTestMethodCallbacks(base, frameworkMethod, testInstance, testContextManager);
        statement = this.withAfterTestMethodCallbacks(statement, frameworkMethod, testInstance, testContextManager);
        statement = this.withTestInstancePreparation(statement, testInstance, testContextManager);
        statement = this.withPotentialRepeat(statement, frameworkMethod, testInstance);
        statement = this.withPotentialTimeout(statement, frameworkMethod, testInstance);
        statement = this.withProfileValueCheck(statement, frameworkMethod, testInstance);
        return statement;
    }

SpringMethodRule은 SpringClassRule에서 설정된 TestContextManager를 가져와서 사용하는 것을 볼 수 있다.

검증

SpringRule과 SpringJUnit4ClassRunner를 비교해보면서, SpringRule이 현재 개선점에 딱 들어맞는 구현체라는 것을 확인했다. @RunWith를 사용하지 않으면서 동시에 Spring Context를 활용할 수 있다는 것이다. 그렇다면 실제로도 되는지 확인이 필요하다. 확인해보자.

public abstract class AbstractSpringContextTests {

    @ClassRule
    public static final SpringClassRule SPRING_CLASS_RULE = new SpringClassRule();

    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();
}

@ContextConfiguration(classes = MyBatisConfig.class)
@Transactional
@Slf4j
public abstract class MyBatisSpringContextTests extends AbstractSpringContextTests {
    private static EmbeddedPostgres embeddedPostgres;

    @Autowired
    protected DataSource dataSource;
    
    // ...
}

@ContextConfiguration(classes = CarDaoTestConfiguration.class)
public class CarDaoImplTest extends MyBatisSpringContextTests {
 @Autowired
    private CarDao carDao;

    @Test
    public void saveTest() {
        // given
        final String schema = "test_schema";
        final Car expected = CarTestFixture.getNotSavedCar();

        // when
        final Car actual = carDao.save(schema, expected);

        // then
        assertThat(actual.getId()).isNotNull();
        assertThat(actual.getName()).isEqualTo(expected.getName());
        assertThat(actual.getCreatedAt()).isNotNull();
        assertThat(actual.getUpdatedAt()).isNull();
        assertThat(actual.getDeletedAt()).isNull();
    }
    
    // ...
}

AbstractSpringContextTests 클래스를 추가하고 기존에 있던 MyBatisIntegrationTest 코드를 그대로 복사해서, AbstractSpringContextTests를 상속하게끔만 변경했다. CarDaoImplTest도 MyBatisSpringContextTests를 상속하게끔만 변경했다. 테스트 해보니 아래처럼 문제 없이 작동하는 것을 확인했다.

Paramterized Test와 연결

이제 목적을 달성했는지 확인해보자. @RunWith(Paramterized.class)와 함께 사용해 Spring Context도 불러올 수 있는지를 검증해보자.

@RunWith(Parameterized.class)
@ContextConfiguration(classes = CarDaoTestConfiguration.class)
public class CarDaoImplTest extends MyBatisSpringContextTests {
    @Autowired
    private CarDao carDao;

    @Parameterized.Parameter(0)
    public Car entity;

    @Parameterized.Parameters(name = "{index}: testCase: {0}")
    public static List<Object[]> testCases() {
        return CarTestFixture.getNotSavedCars(100).stream()
                .map(car -> new Object[]{car})
                .collect(Collectors.toList());
    }
    @Test
    public void saveTest() {
        save();
    }

    private Car save() {
        // given
        final String schema = "test_schema";
        final Car expected = entity;

        // when
        final Car actual = carDao.save(schema, expected);

        // then
        assertThat(actual.getId()).isNotNull();
        assertThat(actual.getName()).isEqualTo(expected.getName());
        assertThat(actual.getCreatedAt()).isNotNull();
        assertThat(actual.getUpdatedAt()).isNull();
        assertThat(actual.getDeletedAt()).isNull();

        return actual;
    }

    @Test
    public void findByIdTest() {
        // given
        final String schema = "test_schema";
        final Car expected = save();

        // when
        final Optional<Car> actual = carDao.findById(schema, expected.getId());

        // then
        assertThat(actual.isPresent()).isTrue();
        assertThat(actual.get()).isEqualTo(expected);
    }
}

@Configuration
@ComponentScan(basePackageClasses = {
        CarDao.class,
        CarMapper.class
})
class CarDaoTestConfiguration {

}

Fixture Monkey를 사용해 테스트 케이스 100개를 랜덤으로 만들고 테스트를 진행한다. 아래처럼 200개의 테스트가 진행되었고 모두 통과한 모습을 확인할 수 있다.

WireMock

WireMock은 Spring Context가 없어도 된다. 그리고 WireMockClassRule이라는 구현체를 제공해준다. 이를 가지고 다음 목표를 달성해보자.

  • Spring 관련된 설정 없이 WireMockClassRule으로 Mock Server를 구성한다.
  • SpringClassRule, WireMockClassRule을 테스트 규칙으로 사용해, Spring Context 내에서 외부 API 통합 테스트를 진행한다.

WireMockClassRule

public class WireMockClassRule extends WireMockServer implements MethodRule, TestRule {
    public WireMockClassRule(Options options) {
        super(options);
    }

    public WireMockClassRule(int port, Integer httpsPort) {
        this(WireMockConfiguration.wireMockConfig().port(port).httpsPort(httpsPort));
    }

    public WireMockClassRule(int port) {
        this(WireMockConfiguration.wireMockConfig().port(port));
    }

    public WireMockClassRule() {
        this(WireMockConfiguration.wireMockConfig());
    }

    public Statement apply(Statement base, FrameworkMethod method, Object target) {
        return this.apply(base, (Description)null);
    }

    public Statement apply(final Statement base, Description description) {
        return new Statement() {
            public void evaluate() throws Throwable {
                if (WireMockClassRule.this.isRunning()) {
                    try {
                        WireMockClassRule.this.before();
                        base.evaluate();
                    } finally {
                        WireMockClassRule.this.after();
                        WireMockClassRule.this.client.resetMappings();
                    }
                } else {
                    WireMockClassRule.this.start();
                    if (WireMockClassRule.this.options.getHttpDisabled()) {
                        WireMock.configureFor("https", "localhost", WireMockClassRule.this.httpsPort());
                    } else {
                        WireMock.configureFor("http", "localhost", WireMockClassRule.this.port());
                    }

                    try {
                        WireMockClassRule.this.before();
                        base.evaluate();
                    } finally {
                        WireMockClassRule.this.after();
                        WireMockClassRule.this.stop();
                    }
                }

            }
        };
    }

    protected void before() {
    }

    protected void after() {
    }
}

SpringClassRule과 유사하게 TestRule, MethodRule 인터페이스를 구현하고 있다. 생각보다 별다른 로직이 없다. SubClass에서 before(), after()을 Override 해서 사용할 수 있게끔 Handler 방식으로 열어놓은 것으로 보인다. 이를 활용해서 PurgoMalumClientTest를 수정해보자.

public class PurgoMalumClientTest {
    @ClassRule
    public static WireMockClassRule wireMockClassRule = new WireMockClassRule(WireMockConfiguration.wireMockConfig().dynamicPort());

    private static PurgoMalumClient purgoMalumClient;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @BeforeClass
    public static void setUp() {
        final String apiUrl = "http://localhost:" + wireMockClassRule.port();

        purgoMalumClient = Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(PurgoMalumClient.class, apiUrl);
    }

    @AfterClass
    public static void teardown() {
        wireMockClassRule.stop();
    }

    @Test
    public void testFeignClient() throws JsonProcessingException {
        final String profanity = "this is some test fuck";
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result("this is some test ****")
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(profanity))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        final ProfanityJsonResponse actual = purgoMalumClient.profanityJson(profanity);

        assertThat(actual.getResult()).isNotEqualTo(profanity);
    }
}

랜덤 포트로 Mock Server를 띄우고 stub으로 API에 대한 Test double을 만드는 것 모두 그대로이다. WireMock 대신에 WireMockClassRule 을 쓰는 것만 차이점이 있다.

Spring과 통합

@Service
@RequiredArgsConstructor
@Slf4j
public class ProfanityFilterService implements ProfanityFilterClient {
    private final PurgoMalumClient purgoMalumClient;

    @Override
    public boolean isCleanText(String text) {
        final ProfanityJsonResponse response = purgoMalumClient.profanityJson(text);
        return text.equals(response.getResult());
    }

    @Override
    public boolean isNotCleanText(String text) {
        return !isCleanText(text);
    }
}

이전에 위와 같은 Service 클래스를 만들었다. SpringClassRule과 WireMockClassRule를 통합해서, 이 클래스를 Spring Context 내에 있는 PurgoMalumClient를 불러온 뒤 외부 API 통합 테스트를 진행해보자.

ExternalApiSpringClassRule

public abstract class ExternalApiSpringClassRule {
    @ClassRule
    public static final SpringClassRule springClassRule = new SpringClassRule();

    @ClassRule
    public static WireMockClassRule wireMockClassRule = new WireMockClassRule(WireMockConfiguration.wireMockConfig().dynamicPort());

    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();
}

위처럼 추상 클래스에 사용할 규칙들을 불러오면 된다. 상속을 사용하지 않고 복합 객체로 해보고 싶었는데 Test class에서 규칙을 찾지 못 한다는 예외가 발생하면서 테스트가 불가능했다. 아무래도 테스트 클래스에 직접 선언되어야 하는 것 같았다.

java.lang.IllegalStateException: Failed to find 'public SpringMethodRule' field in test class [com.example.car.client.ProfanityFilterServiceTest]. Consult the javadoc for SpringClassRule for details.

어떻게든 꼼수 부리고 싶어서 테스트 클래스의 clazz를 받아서 각 규칙을 초기화 해보려고 했으나, 아래처럼 생성자에는 테스트 클래스를 받는게 아예 없어서 결국 상속으로 구현했다.

public class SpringClassRule implements TestRule {
    private static final Log logger = LogFactory.getLog(SpringClassRule.class);
    private static final Map<Class<?>, TestContextManager> testContextManagerCache = new ConcurrentHashMap(64);

    public SpringClassRule() {
    }
    
    // ...
}

검증

일단 됐으니 실제 테스트 코드를 짜보자.

@ContextConfiguration(classes = {ProfanityFilterServiceTestConfig.class})
public class ProfanityFilterServiceTest extends ExternalApiSpringClassRule {
    @Autowired
    private ProfanityFilterService profanityFilterService;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Test
    public void testFeignClient() throws JsonProcessingException {
        final String profanity = "this is some test fuck";
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result("this is some test ****")
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(profanity))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        final boolean actual = profanityFilterService.isNotCleanText(profanity);

        assertThat(actual).isTrue();
    }
}

@Configuration
@ComponentScan(basePackageClasses = {
        ProfanityFilterService.class
})
class ProfanityFilterServiceTestConfig {
    @Bean
    public PurgoMalumClient purgoMalumClient() {
        final String apiUrl = "http://localhost:" + wireMockClassRule.port();
        return Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(PurgoMalumClient.class, apiUrl);
    }
}


문제없이 통과하는 것을 확인했다.

Paramterized Test와 연결

이렇게 만든 ExternalApiSpringClassRule도 Paramterized test와 함께 잘 동작하는지 확인해보자.

@RunWith(Parameterized.class)
@ContextConfiguration(classes = {ProfanityFilterServiceTestConfig.class})
public class ProfanityFilterServiceTest extends ExternalApiSpringClassRule {
    private static final List<String> badwords = Arrays.asList("fuck", "bitch", "asshole", "damn");

    @Autowired
    private ProfanityFilterService profanityFilterService;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Parameterized.Parameter(0)
    public String goodword;

    @Parameterized.Parameter(1)
    public String badword;

    @Parameterized.Parameters(name = "{index} - goodword: {0}, badword: {1}")
    public static List<Object[]> testCases() {
        final int size = 100;
        final List<String> goodwordTestcases = CarTestFixture.getNameStrings(size);
        final List<String> badwordTestcases = CarTestFixture.getBadWordNameStrings(badwords, size);

        return IntStream.range(0, size)
                .mapToObj(i -> new Object[]{goodwordTestcases.get(i), badwordTestcases.get(i)})
                .collect(Collectors.toList());
    }

    @Test
    public void isCleanText() throws JsonProcessingException {
        // given
        final String text = goodCarName;

        // given - client
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result(text)
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(text))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        final boolean actual = profanityFilterService.isCleanText(text);

        assertThat(actual).isTrue();
    }

    @Test
    public void isNotCleanText() throws JsonProcessingException {
        final String profanity = badCarName;
        final String filteredName = badwords.stream()
                .filter(profanity::contains)
                .findFirst()
                .map(badword -> profanity.replaceAll(badword, "*"))
                .orElseThrow(() -> new IllegalArgumentException("잘못된 테스트 케이스입니다."));
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result(filteredName)
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(profanity))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        final boolean actual = profanityFilterService.isNotCleanText(profanity);

        assertThat(actual).isTrue();
    }
}

일반 텍스트와 비속어에 대해 각각 100개의 테스트 케이스를 만들어 반복 테스트를 진행했다. 아래처럼 총 200개의 테스트를 모두 통과한 것을 확인할 수 있다.

통합 테스트 환경 최종 구성

마지막으로 Database와 외부 API를 모두 연결해서 테스트할 수 있는 환경을 구성하기 위해서, 목표를 어디까지 달성했는지 중간 점검을 해보자.

성공

  • FixtureMonkey를 활용한 TestFixture 자동 생성
  • Junit4의 @RunWith(Paramterized.class)를 활용해, 복수의 TestCase에 대해 테스트 가능
  • AbstractJUnit4SpringContextTests를 사용해 MyBatis와 함께 통합 테스트 가능

개선해야할 점

  • @RunWith(Paramterized.class), AbstractJUnit4SpringContextTests(@RunWith(SpringRunner.class)), @RunWith(MockitoJUnitRunner.class) 중 1개만 선택 가능 => Spring은 Junit Test Rule로 해결, Mockito는 검증 필요
  • Paramterized test는 @RunWith를 사용해야만 함. => Junit Test Rule로 해결

Spring은 검증이 끝났고 이제 Mockito를 검증해보자.

Mockito 초기화

Mockito는 사용하는 방법이 다음과 같다.

  • @RunWith(MockitoJUnitRunner.class)
@RunWith(MockitoJUnitRunner.class)
public class TestClass {
	// ...
}
  • MockitoAnnotations.openMocks(TestClass.clazz)

public class TestClass {
	private AutoCloseable mockitoCloseable;
    
    @Before
    public void setUp() throws SQLException {
        mockitoCloseable = MockitoAnnotations.openMocks(this);
    }

    @After
    public void tearDown() throws Exception {
        mockitoCloseable.close();
    }
	// ...
}
  • MockitoRule
public class TestClass {
	public MockitoRule mockitoRule = MockitoJUnit.rule();
}

지금 보니 테스트 프레임워크들은 전부 Rule을 구현하고 있는 것 같다. 다른 테스트 프레임워크를 쓰게 되면 한 번 알아봐야겠다. 지금은 상황상 MockitoAnnotations 또는 MockitoRule을 사용하면 될 것 같다. MockitoRule을 사용해서 IntegrationRule 기능을 명세하고, 코드가 굉장히 더러웠던 CarServiceParameterizedTest를 리팩토링해보자.

IntegrationRule

In-memory database, Mock server, Mockito, Spring context를 전부 테스트 규칙으로 불러오는 통합 테스트 명세이다. 지금까지 추가해왔던 것들을 모두 넣어놓은 것이다.

@ContextConfiguration(classes = MyBatisConfig.class)
@Transactional
@Slf4j
public abstract class IntegrationRule {
    @ClassRule
    public static final SpringClassRule springClassRule = new SpringClassRule();

    @ClassRule
    public static WireMockClassRule wireMockClassRule = new WireMockClassRule(WireMockConfiguration.wireMockConfig().dynamicPort());

    @Rule
    public final SpringMethodRule springMethodRule = new SpringMethodRule();

    @Rule
    public final MockitoRule mockitoRule = MockitoJUnit.rule();

    private static EmbeddedPostgres embeddedPostgres;

    @Autowired
    protected DataSource dataSource;

    /**
     * Postgres를 최초 1회 실행합니다.
     */
    @BeforeClass
    public static void startEmbeddedPostgres() throws IOException, SQLException {
        embeddedPostgres = EmbeddedPostgres.builder()
                .setPort(5432)
                .start();

        try (
                final Connection connection = embeddedPostgres.getPostgresDatabase().getConnection();
                final Statement statement = connection.createStatement()
        ) {
            statement.execute("CREATE DATABASE test");
        }


        log.info("Embedded Postgres started.");
    }

    /**
     * 각 테스트 시작 전 DB를 초기화합니다.
     */
    @Before
    public void setUp() throws SQLException {
        initializeSchema();
    }

    /**
     * 모든 테스트가 종료되면 Postgres를 종료합니다.
     */
    @AfterClass
    public static void stopEmbeddedPostgres() throws IOException {
        if (embeddedPostgres != null) {
            embeddedPostgres.close();
        }
    }

    /**
     * schema.sql를 불러와서 실행합니다.
     */
    private void initializeSchema() throws SQLException {
        final Resource schemaResource = new ClassPathResource("schema.sql");
        final DatabasePopulator databasePopulator = new ResourceDatabasePopulator(schemaResource);

        databasePopulator.populate(dataSource.getConnection());
    }
}

검증

@RunWith(Parameterized.class)
@ContextConfiguration(classes = CarServiceParameterizedTestConfiguration.class)
public class CarServiceParameterizedTest extends IntegrationRule {
    private static final List<String> badwords = Arrays.asList("fuck", "bitch", "asshole", "damn");

    @Autowired
    private CarService carService;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Parameterized.Parameter(0)
    public String goodCarName;

    @Parameterized.Parameter(1)
    public String badCarName;

    @Parameterized.Parameters(name = "{index}: Test with goodName={0}, badName={1}")
    public static Collection<Object[]> data() {
        final int size = 100;
        final List<String> goodCarNames = CarTestFixture.getNameStrings(size);
        final List<String> badCarNames = CarTestFixture.getBadWordNameStrings(badwords, size);

        return IntStream.range(0, size)
                .mapToObj(i -> new Object[]{goodCarNames.get(i), badCarNames.get(i)})
                .collect(Collectors.toList());
    }

    @Test
    public void createCarTest() throws JsonProcessingException {
        // given
        final String carName = goodCarName;

        // given - client
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result(carName)
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(carName))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        // when
        final Car actual = carService.createCar(carName);

        // then
        assertThat(actual.getId()).isNotNull();
        assertThat(actual.getCreatedAt()).isNotNull();
        assertThat(actual.getName()).isEqualTo(carName);
    }

    @Test
    public void createCarExceptionTest() throws JsonProcessingException {
        // given
        final String carName = badCarName;

        // given - client
        final String filteredName = badwords.stream()
                .filter(carName::contains)
                .findFirst()
                .map(badword -> carName.replaceAll(badword, "*"))
                .orElseThrow(() -> new IllegalArgumentException("잘못된 테스트 케이스입니다."));
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result(filteredName)
                .build();

        wireMockClassRule.stubFor(WireMock.get(WireMock.urlPathEqualTo("/json"))
                .withQueryParam("text", equalTo(carName))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody(objectMapper.writeValueAsString(response))));

        // when & then
        assertThatIllegalArgumentException().isThrownBy(() -> carService.createCar(carName));
    }
}

@Configuration
@ComponentScan(basePackageClasses = {
        CarService.class,
        CarDao.class,
        CarMapper.class,
        ProfanityFilterService.class,
})
class CarServiceParameterizedTestConfiguration {
    @Bean
    public PurgoMalumClient purgoMalumClient() {
        final String apiUrl = "http://localhost:" + wireMockClassRule.port();
        return Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(PurgoMalumClient.class, apiUrl);
    }

    @Bean
    public DatabaseSchemaFactory databaseSchemaFactory() {
        final DatabaseSchemaFactory databaseSchemaFactory = mock(DatabaseSchemaFactory.class);

        doReturn(Optional.of("test_schema")).when(databaseSchemaFactory).getSchema();

        return databaseSchemaFactory;
    }
}

IntegrationRule를 상속받아서 테스팅을 진행한다. 바로 위에 보면 DatabaseSchemaFactory는 Mock 객체로 되어있으므로, Mockito 엔진이 제대로 초기화 되어야만이 해당 클래스를 Bean으로 사용가능할 것이다. 잠깐 디버깅을 해보면 실제로 Mock 객체가 제대로 들어가있는 것을 확인할 수 있다. Mockito도 @RunWith 없이 도입하는데 성공했다.

100개의 테스트 케이스에 대해서 2개의 테스트도 모두 통과하는데 성공했다.

후기

저번 포스팅을 작성할 때는 여러모로 중복 코드도 많고 설정이 많이 더러워서 포스팅 적기도 어려웠다. 이번에는 TestRule 기반으로 많은 설정을 편리하게 하다보니, 포스팅을 적는데도 더 수월했던 것 같다. 아직까지는 Configuration을 일일이 선언하는게 불편하긴 하지만, 레거시 환경에서 각종 오래된 라이브러리의 의존성이 엮이다 보면 테스트가 너무 어려워져서 최대한 필요한 빈만 사용하는 습관을 들이고 있다. (사실 ComponentScan을 루트로 잡으면 SpringBootTest랑 똑같음)

아무리 오래된 기술이라고 하더라도 파고 들다보면 어떻게든 문제를 해결할 방법이 있다는 걸 많이 느꼈던 포스팅이다. (그래도 Junit 4.13.2는 나름 2021년 릴리즈다. 3년밖에 안 됌) Junit4에서 @RunWith 하나밖에 못 쓴다는 사실을 알고 정말 절망했었는데, 역시 까보다보니 자바 개발자 형님들이 이미 다 만들어 놓은게 있었다. 간과했던 것은 Junit, Mockito, Spring test 모두 다 프레임워크인데 Spring 프레임워크의 작동 방식만 생각했지, 정작 테스팅 프레임워크의 구조는 전혀 알려하지 않았던 것이다. 어노테이션이 너무 편리하게 해주니까 까볼 생각을 못 했던 것 같다.

여전히 Spring boot가 없는 것은 힘들지만 역설적으로 모든 걸 손수 설정해야하다보니 각종 프레임워크에 좀 더 가까이, 더 깊게 다가가게 되는 것 같다. 언젠가 Spring boot로 전환은 하고 싶지만, 레거시 환경도 나름 성장하는데 덕을 많이 보고 있다. 불편해? 맘에 안 들어? 그럼 고쳐 팀원분들한테 최대한 쉬운 설정을 공유드리고 싶었는데, 꽤나 만족스럽게 쉬운 난이도 설정을 만든 것 같아서 뿌듯하다.

하지만 반갑지 않은 Java version...

profile
일벌리기 좋아하는 사람

0개의 댓글