레거시 통합 테스트 구축기 - 기초적인 환경 구성

장성호·2024년 8월 15일

[Server]

목록 보기
5/6
post-thumbnail

현대오토에버에 24년 4월 부로 입사한 뒤, 업무를 진행하면서 통합 테스트를 구축해 사용하고 있다. Spring Boot 도입을 진행하고는 있지만 아직 대부분은 Spring Boot가 없는 레거시 환경이기에, 프로덕션 코드 Configuration 부터 테스트 환경 구축까지 무엇 하나 만만치 않다. 그래도 어떻게든 해내는게 개발자 아닐까. (사실 시스템 장애 낼까봐 무서움;;)

레거시에서 살아가는 사람들

현재 담당하고 있는 레거시 시스템은 기술 스택은 다음과 같다.

  • Java 1.8 & XML
  • Spring 4
  • MyBatis
  • Postgres

Postgres는 Upsert 같은 독특한 쿼리를 가지고 있기에, 영속성 테스트를 하고자 한다면 Postgres 기반 테스트 환경을 구축해줘야 한다. H2라는 인메모리 DB가 있지만 postgres 모드로 실행시켜도 Upsert 쿼리는 지원이 되지 않는다. (대신 Merge Into 문이 있지만 프로덕션과 테스트 코드가 서로 달라지는 문제 발생) 그렇다고 내부적으로 Container를 사용하기 어려운 환경이기에 영속성 테스트는 더더욱 어려운게 현실이다. 

한편 레거시 시스템은 말 그대로 오래된 시스템이기 때문에 코드량이 방대하고 많은 사람들의 손길이 닿아있는 경우가 대부분이다. 그렇다보니 코드의 히스토리조차 추적이 어려운 경우도 존재한다. 코드는 입출력조차 복잡하게 얽혀있는 상황, 이런 상황이더라도 어떻게든 개발을 해내야하는게 레거시에서 살아가는 사람들의 임무다.

기술 도입

통합 테스트 구축을 위해서 다음과 같은 기술 스택을 도입했다.

  • Junit4
  • Mockito
  • assertj
  • io.github.openfeign:13.1 - 선언적 프로그래밍을 통한 외부 API 연동
  • com.github.tomakehurst:wiremock-jre8:2.35.0 - 테스트 코드 내에서 Mock Server를 실행
  • io.zonky.test:embedded-postgres:2.0.2 - 메모리 내 Postgres DB를 실행 (2.0.1은 윈도우와 충돌하는 문제 존재)

자바 테스팅 프레임워크 삼형제인 Junit, Mockito, assertj 와 함께 외부 API, 영속성 테스트를 위한 라이브러리도 함께 사용한다. 이를 통해서 각 기능별 유닛테스트를 작성할 수 있을 뿐만 아니라, 연결되는 기능들을 Mocking 하거나 실제로 연결해 테스트를 해볼 수 있는 환경을 구성할 수 있다.

달성 목표

여러 가지 통합테스트를 경험해보기 위해서 다음과 같은 간단한 목표를 설정해보자.

  • 자동차 이름을 기입하면 자동차를 생성할 수 있다.
  • 자동차는 욕설이 포함되면 안 된다.
  • 자동차 ID 값을 주면 자동차 정보를 조회할 수 있다.

pom.xml에 의존성 등록

pom.xml은 코드가 너무 많아 깃허브 링크로 대체하였다. 구성은 다음처럼 되어있다.

  • Spring
    - core, context, web, jdbc, tx, test
    • webmvc & javax.servlect
  • MyBatis
    - mybatis, mybatis-spring, postgresql
  • OpenFeign
    - core, jackson, httpclient
  • Jackson
    - core, databind, annotations
  • Log
    - log4j-api, log4j-slf4j-impl
  • Testing
    - embedded-postgres, junit, mockito-core, assertj-core, wiremock-jre8
  • Testing Fixture
    - fixture-monkey-starter, junit-vintage-engine, jaxws-rt, rgxgen
  • Util
    - lombok, mapstruct, mapstruct-processor
  • Annotation compile
    - maven-compiler-plugin

MyBatis 통합 테스트

먼저 영속성 테스트부터 구축해보자. 레거시 환경에서는 Configuration 부터 SQL 작성까지 대부분 XML을 많이 활용한다. 이번 포스팅에서는 다음과 같은 이유로 SQL만 XML로 작성하고 나머지는 Java로 진행한다.

  • SQL 작성은 Java 1.8 보다는 XML이 훨씬 가독성이 좋다.
    • Java 문법 중 """Hello world!""" 와 같이 줄바꿈을 포함한 문자열을 편하게 작성할 수 있는 텍스트 블록 문법은 Java 13부터 지원한다. (전부 \n과 + 연산자로 연결시키면 정말 가독성도 떨어지고 작성하기도 힘들다.)
  • Configuration은 Java가 XML보다 훨씬 가독성이 좋다.
  • XML을 많이 쓰면 쓸수록 기술 스택 전환이 어렵다. (Java 고급 기능, JDBC, JPA 등)

환경 구축

먼저 Postgres에 환경을 구축한다. 목표는 다음과 같다.

  1. 각 테스트마다 고립된 환경을 보장하기 위해 schema.sql에 DDL(DROP & CREATE)을 정의하고 각 테스트 전에 실행한다.
  2. XML에 정의된 MyBatis SQL Mapper를 미리 불러온다.

가장 먼저 src/test/resources/schema.sql 파일에 아래처럼 테이블을 정의한다.

schema.sql

CREATE SCHEMA test_schema;

DROP TABLE IF EXISTS test_schema.cars;

CREATE TABLE IF NOT EXISTS test_schema.cars 
(
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    name       VARCHAR(255)            NOT NULL,
    created_at TIMESTAMP DEFAULT now() NOT NULL,
    updated_at TIMESTAMP,
    deleted_at TIMESTAMP
);

MyBatisConfig.java

이제 MyBatis를 사용하기 위한 여러가지 설정을 등록한다.

@Configuration
public class MyBatisConfig {
    @Bean    
    public DataSource dataSource() {
        final PGSimpleDataSource dataSource = new PGSimpleDataSource();

        dataSource.setUrl("jdbc:postgresql://localhost:5432/test");
        dataSource.setUser("postgres");
        dataSource.setPassword("password");

        return dataSource;
    }

    @Bean    
    public SqlSessionFactory sqlSessionFactory(@NonNull final DataSource dataSource) throws Exception {
        final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();

        sessionFactory.setDataSource(dataSource);

        // XML 매퍼 파일 위치 설정        
        sessionFactory.setMapperLocations(resolver.getResources("classpath*:sql/**/*.xml"));

        return sessionFactory.getObject();
    }

    @Bean    
    public DataSourceTransactionManager transactionManager(@NonNull final DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

MyBatisIntegrationTest.java

Postgres를 인메모리에 로드하기 위한 설정을 등록한다.

@ContextConfiguration(classes = MyBatisConfig.class)
@Transactional
@Slf4j
public abstract class MyBatisIntegrationTest extends AbstractJUnit4SpringContextTests {
    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();
        }
    }

    @NonNull
    protected DataSource getDataSource() {
        return applicationContext.getBean(DataSource.class);
    }

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

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

CarDaoImplTest.java

목표를 달성하기 위한 테스트코드를 짜보자.

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

    @Test
    public void saveTest() {
        // given
        final String schema = "test_schema";
        final Car expected = Car.builder()
                .name("testCar")
                .build();

        // 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();
    }

    @Test
    public void findByIdTest() {
        // given
        final String schema = "test_schema";
        final Car entity = Car.builder()
                .name("testCar")
                .build();
        final Car expected = carDao.save(schema, entity);

        // 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 {

}

프로덕션 코드 작성

이제 프로덕션 코드를 작성해나가보자.

CarDaoImplSql.xml

간단한 save와 findById 쿼리를 만들어보자.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="CarDaoImpl">

    <insert id="save" parameterType="java.util.Map">
        INSERT INTO ${schema}.cars (id, name, created_at, updated_at, deleted_at)
        VALUES (#{id}, #{name}, #{created_at}, #{updated_at}, #{deleted_at})
        ON CONFLICT (id)
            DO UPDATE SET name       = EXCLUDED.name,
                          updated_at = now(),
                          deleted_at = EXCLUDED.deleted_at
    </insert>

    <select id="findById" parameterType="java.util.Map" resultType="java.util.Map">
        SELECT *
        FROM ${schema}.cars
        WHERE id = #{id};
    </select>

</mapper>

Car.java

테스트 코드 통과를 위해 이제 차량이라는 Entity 클래스를 만들어보자.

@Getter
@EqualsAndHashCode
@ToString
public abstract class BaseEntity {
    protected final UUID id;

    protected final LocalDateTime createdAt;

    protected final LocalDateTime updatedAt;

    protected final LocalDateTime deletedAt;

    public BaseEntity(
            final UUID id,
            final LocalDateTime createdAt,
            final LocalDateTime updatedAt,
            final LocalDateTime deletedAt
    ) {
        this.id = id;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
        this.deletedAt = deletedAt;
    }
}

@Getter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class Car extends BaseEntity {
    private final String name;

    @Builder(toBuilder = true)
    public Car(
            final UUID id,
            final LocalDateTime createdAt,
            final LocalDateTime updatedAt,
            final LocalDateTime deletedAt,
            final String name
    ) {
        super(
                id,
                Optional.ofNullable(createdAt).orElse(LocalDateTime.now()),
                updatedAt,
                deletedAt
        );
        this.name = name;
    }
}

CarDao

이번에는 Car라는 Entity에 대한 DAO를 만들어보자.

public interface CarDao {
    @NonNull
    Car save(
            @NonNull final String schema,
            @NonNull final Car car
    );

    @NonNull
    Optional<Car> findById(
            @NonNull final String schema,
            @NonNull final UUID uuid
    );
}

@Repository
public class CarDaoImpl extends SqlSessionDaoSupport implements CarDao {
    private final CarMapper carMapper;

    public CarDaoImpl(
            @NonNull final CarMapper carMapper,
            @NonNull final SqlSessionFactory sqlSessionFactory
    ) {
        this.carMapper = carMapper;
        setSqlSessionFactory(sqlSessionFactory);
    }

    @Override
    public @NonNull Car save(
            @NonNull final String schema,
            @NonNull final Car car
    ) {
        final Car newCar = car.toBuilder()
                .id(UUID.randomUUID())
                .build();
        final Map<String, Object> insertParams = carMapper.entityToDatabaseParams(schema, newCar);
        final Map<String, Object> selectParams = new HashMap<>();

        selectParams.put(CarMapper.SCHEMA, schema);
        selectParams.put(CarMapper.ID, newCar.getId());

        final SqlSession sqlSession = this.getSqlSession();
        final int insertCount = sqlSession.insert("CarDaoImpl.save", insertParams);
        final Map<String, Object> result = sqlSession.selectOne("CarDaoImpl.findById", selectParams);

        return carMapper.databaseResultToEntity(result);
    }

    @Override
    public @NonNull Optional<Car> findById(
            @NonNull final String schema,
            @NonNull final UUID id
    ) {
        final Map<String, Object> params = new HashMap<>();

        params.put(CarMapper.SCHEMA, schema);
        params.put(CarMapper.ID, id);

        final Map<String, Object> result = this.getSqlSession().selectOne("CarDaoImpl.findById", params);

        if(result == null) {
            return Optional.empty();
        }
        return Optional.of(carMapper.databaseResultToEntity(result));
    }
}

CarMapper.java

마지막으로 Map 클래스와 Entity를 매핑해주는 매퍼를 만들어보자.

@Mapper
public interface CarMapper {
    CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);

    String SCHEMA = "schema";
    String ID = "id";
    String NAME = "name";
    String CREATED_AT = "created_at";
    String UPDATED_AT = "updated_at";
    String DELETED_AT = "deleted_at";

    @NonNull
    default Map<String, Object> entityToDatabaseParams(
            @NonNull final String schema,
            @NonNull final Car car
    ) {
        final Map<String, Object> params = new HashMap<>();

        params.put(SCHEMA, schema);
        params.put(ID, Optional.ofNullable(car.getId()).orElse(null));
        params.put(NAME, car.getName());
        params.put(CREATED_AT, car.getCreatedAt());
        params.put(UPDATED_AT, car.getUpdatedAt());
        params.put(DELETED_AT, car.getDeletedAt());

        return params;
    }

    @NonNull
    default Car databaseResultToEntity(@NonNull final Map<String, Object> result) {
        final UUID id = (UUID) result.get(ID);
        final String name = (String) result.get(NAME);
        final Timestamp createdAt = (Timestamp) result.get(CREATED_AT);
        final Optional<Timestamp> updatedAt = Optional.ofNullable((Timestamp) result.get(UPDATED_AT));
        final Optional<Timestamp> deletedAt = Optional.ofNullable((Timestamp) result.get(DELETED_AT));

        return Car.builder()
                .id(id)
                .name(name)
                .createdAt(createdAt.toLocalDateTime())
                .updatedAt(updatedAt.map(Timestamp::toLocalDateTime).orElse(null))
                .deletedAt(deletedAt.map(Timestamp::toLocalDateTime).orElse(null))
                .build();
    }
}

log4j2.xml

진짜 마지막으로 로그를 보기 위한 설정을 추가한다.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="DEBUG">
    <!-- Console Appender -->
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n"/>
        </Console>
    </Appenders>

    <!-- Loggers 설정 -->
    <Loggers>
        <!-- Spring 및 MyBatis 로그 -->
        <Logger name="org.springframework" level="INFO" additivity="false">
            <AppenderRef ref="Console"/>
        </Logger>
        <Logger name="com.example" level="DEBUG" additivity="false">
            <AppenderRef ref="Console"/>
        </Logger>
        <Logger name="org.apache.ibatis" level="DEBUG" additivity="false">
            <AppenderRef ref="Console"/>
        </Logger>
        <Logger name="org.mybatis" level="DEBUG" additivity="false">
            <AppenderRef ref="Console"/>
        </Logger>

        <!-- Root Logger 설정 -->
        <Root level="DEBUG">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

테스팅 결과

인메모리 Postgres와 실제로 상호작용하는데 성공했다.

saveTest

8월 15, 2024 6:29:04 오후 org.springframework.jdbc.datasource.init.ScriptUtils executeSqlScript
정보: Executing SQL script from class path resource [schema.sql]
8월 15, 2024 6:29:04 오후 org.springframework.jdbc.datasource.init.ScriptUtils executeSqlScript
정보: Executed SQL script from class path resource [schema.sql] in 5 ms.
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@c65a5ef] was not registered for synchronization because synchronization is not active
2024-08-15 18:29:04 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@410954b] will not be managed by Spring
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - ==>  Preparing: INSERT INTO test_schema.cars (id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = now(), deleted_at = EXCLUDED.deleted_at
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - ==> Parameters: 4b7afeca-4468-490e-a1df-d7c1948def92(UUID), testCar(String), 2024-08-15T18:29:04.792(LocalDateTime), null, null
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - <==    Updates: 1
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@c65a5ef]
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5922ae77] was not registered for synchronization because synchronization is not active
2024-08-15 18:29:04 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@27cf3151] will not be managed by Spring
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==>  Preparing: SELECT * FROM test_schema.cars WHERE id = ?;
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==> Parameters: 4b7afeca-4468-490e-a1df-d7c1948def92(UUID)
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - <==      Total: 1
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Closing no

findByIdTest

8월 15, 2024 6:29:04 오후 org.springframework.jdbc.datasource.init.ScriptUtils executeSqlScript
정보: Executing SQL script from class path resource [schema.sql]
8월 15, 2024 6:29:04 오후 org.springframework.jdbc.datasource.init.ScriptUtils executeSqlScript
정보: Executed SQL script from class path resource [schema.sql] in 3 ms.
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33a2499c] was not registered for synchronization because synchronization is not active
2024-08-15 18:29:04 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@e72dba7] will not be managed by Spring
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - ==>  Preparing: INSERT INTO test_schema.cars (id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = now(), deleted_at = EXCLUDED.deleted_at
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - ==> Parameters: 510caf51-f236-4569-acaa-e06fd5816281(UUID), testCar(String), 2024-08-15T18:29:04.898(LocalDateTime), null, null
2024-08-15 18:29:04 DEBUG CarDaoImpl.save - <==    Updates: 1
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33a2499c]
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33c2bd] was not registered for synchronization because synchronization is not active
2024-08-15 18:29:04 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@1dfd5f51] will not be managed by Spring
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==>  Preparing: SELECT * FROM test_schema.cars WHERE id = ?;
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==> Parameters: 510caf51-f236-4569-acaa-e06fd5816281(UUID)
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - <==      Total: 1
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@33c2bd]
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@3c321bdb] was not registered for synchronization because synchronization is not active
2024-08-15 18:29:04 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@24855019] will not be managed by Spring
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==>  Preparing: SELECT * FROM test_schema.cars WHERE id = ?;
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - ==> Parameters: 510caf51-f236-4569-acaa-e06fd5816281(UUID)
2024-08-15 18:29:04 DEBUG CarDaoImpl.findById - <==      Total: 1
2024-08-15 18:29:04 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@3c321bdb]

OpenFeign 기반 외부 API 통합 테스트

이번에는 외부 API를 연결해야하는 상황에서의 통합 테스트를 작성해보자. 영속성 테스트와 마찬가지로 외부 API를 실제로 호출하지 않도록 환경을 구성해 테스트할 것이다. 따라서 목표는 다음과 같다.

  • 각 테스트 별로 서로 영향을 주지 않기 위해, 고립된 환경에서 외부 API 테스트 진행
  • 외부 API를 실제로 호출하지 않기 위해 Mock Server를 구성
  • 랜덤 포트를 사용해 이미 사용하고 있는 포트가 아닌, 쓰지 않는 포트에서 Mock Server 구성

환경 구성

먼저 Mock Server와 랜덤 포트는 다음처럼 설정할 수 있다.

public class FeignWireMockTest {

    private WireMockServer wireMockServer;
    
    private String apiUrl;

    @BeforeEach
    public void setup() {
        wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()); 
        wireMockServer.start();
        
        apiUrl = "http://localhost:" + wireMockServer.port();
    }
    
    @AfterEach
    public void teardown() {
        wireMockServer.stop();
    }

그리고 OpenFeign을 통한 외부 API 연결은 다음처럼 할 수 있다.

public interface MyFeignClient {
	@RequestLine("GET /test")
	MyResponse getTest();
}

public class MyResponse {
	private String message;

	public String getMessage() {
		return message;
	}

	public void setMessage(String message) {
		this.message = message;
	}
}

그럼 이제 MockServer의 실제 행동을 정의하는 Stub을 설정해보자. 스펙은 다음과 같다.

Scenario: OpenFeign 시작하기
	Given <GET> /test API는 "{"message": "hello world"}" 라는 json을 응답할 때
    When /test URI로 GET 요청을 보내면
    Then 200을 수신받는다.
    And "{"message": "hello world"}" 라는 json을 수신받는다.

이를 Mock Server와 Stub을 통해 테스트를 할 수 있다.

public class FeignWireMockTest {

    private WireMockServer wireMockServer;
    private MyFeignClient myFeignClient;
	private String apiUrl;
    
    @Before
    public void setup() {
        wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
        wireMockServer.start();

		apiUrl = "http://localhost:" + wireMockServer.port();
        
        myFeignClient = Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(MyFeignClient.class, apiUrl);
    }

    @After
    public void teardown() {
        wireMockServer.stop();
    }

    @Test
    public void testFeignClient() {
        wireMockServer.stubFor(WireMock.get(WireMock.urlEqualTo("/test"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withBody("{\"message\": \"Hello World\"}")));

        final MyResponse response = myFeignClient.getTest();

        assertEquals("Hello World", response.getMessage());
    }
}

위와 같은 내용을 바탕으로 테스트를 작성해보자. 욕설 체크를 위해서 PurgoMalum 이라는 사이트를 이용해보자. 영여 욕설에 대해 간단하게 체크해볼 수 있는 API를 제공한다. 여러가지 API 형태 중, 아래를 이용하기 위한 테스트 코드를 작성해보자.

public class PurgoMalumClientTest {
    private WireMockServer wireMockServer;
    private PurgoMalumClient purgoMalumClient;
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Before
    public void setup() {
        wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
        wireMockServer.start();

        purgoMalumClient = Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(PurgoMalumClient.class, "http://localhost:" + wireMockServer.port());
    }

    @After
    public void teardown() {
        wireMockServer.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();

        wireMockServer.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);
    }
}

프로덕션 코드 작성

ProfanityFilterClient.java

먼저 우리가 실제로 필요한 스펙을 정의해보자. 간단하게 문장에 비속어가 있는지 여부를 체크해주는 명세서를 작성했다.

public interface ProfanityFilterClient {
    boolean isCleanText(final String text);
    
    boolean isNotCleanText(final String text);
}

PurgoMalumClient.java

이제 PurgoMalum API를 연결할 Open Feign Client를 작성해보자. 다음처럼 구체적으로 클래스 로직을 일일이 작성할 필요 없이, 내가 필요한 API 명세만 깔끔하게 적어주면 OpenFeign이 알아서 코드를 만들어준다.

public interface PurgoMalumClient {
    @RequestLine("GET /json?text={text}")
    ProfanityJsonResponse profanityJson(@Param("text") String text);
}

@Getter
@Getter
public class ProfanityJsonResponse {
    private final String result;

    @Builder(toBuilder = true)
    public ProfanityJsonResponse(@JsonProperty("result") final String result) {
        this.result = result;
    }
}

ProfanityFilterService.java

이제 인터페이스를 구현하는 구현체를 만들어보자.

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

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

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

PurgoMalumConfig.java

이제 OpenFeign으로 PurgoMalumClient를 만들고 Spring Bean을 등록해보자.

@Configuration
public class PurgoMalumConfig {
    @Bean
    public PurgoMalumClient purgoMalumClient() {
        return Feign.builder()
                .encoder(new JacksonEncoder())
                .decoder(new JacksonDecoder())
                .target(PurgoMalumClient.class, "https://www.purgomalum.com/service");
    }
}

테스팅 결과

Request

2024-08-21 19:41:57 DEBUG org.eclipse.jetty.server.HttpChannel - REQUEST for //localhost:53491/json?text=this%20is%20some%20test%20fuck on HttpChannelOverHttp@149c2c13{s=HttpChannelState@26fde586{s=IDLE rs=BLOCKING os=OPEN is=IDLE awp=false se=false i=true al=0},r=1,c=false/false,a=IDLE,uri=//localhost:53491/json?text=this%20is%20some%20test%20fuck,age=0}
GET //localhost:53491/json?text=this%20is%20some%20test%20fuck HTTP/1.1
Accept: */*
User-Agent: Java/1.8.0_392
Host: localhost:53491
Connection: keep-alive

Response

2024-08-21 19:41:57 DEBUG org.eclipse.jetty.server.HttpChannel - sendResponse info=null content=HeapByteBuffer@3216b72c[p=0,l=35,c=32768,r=35]={<<<{"result":"this is some test ****"}>>>\x00\x00\x00\x00\x00\x00\x00\x00\x00...\x00\x00\x00\x00\x00\x00\x00} complete=false committing=true callback=Blocker@30ff3d9{null}
2024-08-21 19:41:57 DEBUG org.eclipse.jetty.server.HttpChannel - COMMIT for /json on HttpChannelOverHttp@149c2c13{s=HttpChannelState@26fde586{s=HANDLING rs=BLOCKING os=COMMITTED is=READY awp=false se=false i=true al=0},r=1,c=false/false,a=HANDLING,uri=//localhost:53491/json?text=this%20is%20some%20test%20fuck,age=52}
200 null HTTP/1.1
Matched-Stub-Id: defe5897-9a76-4c51-a034-9af3f5944624
Vary: Accept-Encoding, User-Agent

Service 통합 테스트

이제 외부 API와 DB가 모두 연결된 상황에서의 비즈니스 로직 통합 테스트를 만들어보자. 스펙은 다음과 같다.

  • 자동차 이름을 기입하면 자동차를 생성할 수 있다.
  • 자동차는 욕설이 포함되면 안 된다.
  • 자동차 ID 값을 주면 자동차 정보를 조회할 수 있다.

환경은 위에서 구축했던 것을 사용한다.

테스트 코드 작성

CarServiceTest.java

검증하고자 하는 건 다음과 같다.

Scenario: 자동차 생성
	Given "This is test car name"이라는 이름이 주어질 때
    When CarService의 createCar를 호출하면
    Then Car 인스턴스를 응답받는다.
    And Car 인스턴스는 Id가 Null이 아니어야 한다.
    And Car 인스턴스는 CreatedAt이 Null이 아니어야 한다.
    And Car 인스턴스의 Name은 "This is test car name"여야 한다.

Scenario: 자동차 생성시 비속어 기입
	Given "This is test car name fuck"이라는 이름이 주어질 때
    When CarService의 createCar를 호출하면
    Then IllegalArgumentException이 발생한다.
@ContextConfiguration(classes = CarServiceTestConfiguration.class)
public class CarServiceTest extends MyBatisIntegrationTest {
    @Autowired
    private  CarService carService;

    @Autowired
    private  WireMockServer wireMockServer;

    private static WireMockServer wireMockServerForStop;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Before
    public void setUpClass() {
        if(wireMockServerForStop == null) {
            wireMockServerForStop = wireMockServer;
        }
    }

    @AfterClass
    public static void teardownClass() {
        if(wireMockServerForStop != null) {
            wireMockServerForStop.stop();
        }
    }


    @Test
    public void createCarTest() throws JsonProcessingException {
        // given
        final String carName = "This is test car name";

        // given - client
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result("This is test car name")
                .build();

        wireMockServer.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 = "This is test car name fuck";

        // given - client
        final ProfanityJsonResponse response = ProfanityJsonResponse.builder()
                .result("This is test car name ****")
                .build();

        wireMockServer.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));
    }
}

CarServiceTestConfiguration.java

Spring boot가 아니기 때문에 의존성으로 필요한 객체들을 미리 미리 ApplicationContext에 Bean으로 등록해주어야 한다.

@Configuration
@ComponentScan(basePackageClasses = {
        CarService.class,
        CarDao.class,
        CarMapper.class,
        ProfanityFilterService.class,
})
class CarServiceTestConfiguration {
    @Bean
    public WireMockServer wireMockServer() {
        final WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());

        wireMockServer.start();

        return wireMockServer;
    }

    @Bean
    public PurgoMalumClient purgoMalumClient(final WireMockServer wireMockServer) {
        final String apiUrl = "http://localhost:" + wireMockServer.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;
    }
}

프로덕션 코드 작성

CarService.java

이제 차량 이름에 비속어가 포함되는지 여부를 확인하는 비즈니스 로직을 구현해보자.

@Service
@RequiredArgsConstructor
@Slf4j
public class CarService {
    private final ProfanityFilterClient profanityFilterClient;

    private final DatabaseSchemaFactory databaseSchemaFactory;

    private final CarDao carDao;

    public Car createCar(final String carName) {
        if(profanityFilterClient.isNotCleanText(carName)) {
            throw new IllegalArgumentException("비속어는 포함할 수 없습니다.");
        }

        final String schema = databaseSchemaFactory.getSchema().orElse(null);
        final Car car = Car.builder()
                .name(carName)
                .build();

        return carDao.save(schema, car);
    }
}

DatabaseSchemaFactory.java

Database Schema를 환경에 맞게 반환하기 위한 객체를 만들어보자.

@Component
public class DatabaseSchemaFactory {
    public Optional<String> getSchema() {
        return Optional.empty();
    }
}

테스팅 결과

CreateCarTest

외부 API 로그

2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - REQUEST for //localhost:57023/json?text=This%20is%20test%20car%20name on HttpChannelOverHttp@500e234{s=HttpChannelState@615c4a3e{s=IDLE rs=BLOCKING os=OPEN is=IDLE awp=false se=false i=true al=0},r=1,c=false/false,a=IDLE,uri=//localhost:57023/json?text=This%20is%20test%20car%20name,age=0}
GET //localhost:57023/json?text=This%20is%20test%20car%20name HTTP/1.1
Accept: */*
User-Agent: Java/1.8.0_392
Host: localhost:57023
Connection: keep-alive

2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - sendResponse info=null content=HeapByteBuffer@74d1d699[p=0,l=34,c=32768,r=34]={<<<{"result":"This is test car name"}>>>\x00\x00\x00\x00\x00\x00\x00\x00\x00...\x00\x00\x00\x00\x00\x00\x00} complete=false committing=true callback=Blocker@716a5dad{null}
2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - COMMIT for /json on HttpChannelOverHttp@500e234{s=HttpChannelState@615c4a3e{s=HANDLING rs=BLOCKING os=COMMITTED is=READY awp=false se=false i=true al=0},r=1,c=false/false,a=HANDLING,uri=//localhost:57023/json?text=This%20is%20test%20car%20name,age=44}
200 null HTTP/1.1
Matched-Stub-Id: baa887d8-28d9-4a7c-8e18-ff6445337caf
Vary: Accept-Encoding, User-Agent

DB 로그

2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4d6ccc97] was not registered for synchronization because synchronization is not active
2024-08-21 20:37:53 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@18a25bbd] will not be managed by Spring
2024-08-21 20:37:53 DEBUG CarDaoImpl.save - ==>  Preparing: INSERT INTO test_schema.cars (id, name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = now(), deleted_at = EXCLUDED.deleted_at
2024-08-21 20:37:53 DEBUG CarDaoImpl.save - ==> Parameters: a0227f0b-013a-45b5-8ab1-8bcabb48b264(UUID), This is test car name(String), 2024-08-21T20:37:53.140(LocalDateTime), null, null
2024-08-21 20:37:53 DEBUG CarDaoImpl.save - <==    Updates: 1
2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@4d6ccc97]
2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - Creating a new SqlSession
2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7e9f2c32] was not registered for synchronization because synchronization is not active
2024-08-21 20:37:53 DEBUG org.mybatis.spring.transaction.SpringManagedTransaction - JDBC Connection [org.postgresql.jdbc.PgConnection@56928e17] will not be managed by Spring
2024-08-21 20:37:53 DEBUG CarDaoImpl.findById - ==>  Preparing: SELECT * FROM test_schema.cars WHERE id = ?;
2024-08-21 20:37:53 DEBUG CarDaoImpl.findById - ==> Parameters: a0227f0b-013a-45b5-8ab1-8bcabb48b264(UUID)
2024-08-21 20:37:53 DEBUG CarDaoImpl.findById - <==      Total: 1
2024-08-21 20:37:53 DEBUG org.mybatis.spring.SqlSessionUtils - Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@7e9f2c32]

CreateCarExceptionTest

외부 API 로그

2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - REQUEST for //localhost:57023/json?text=This%20is%20test%20car%20name%20fuck on HttpChannelOverHttp@500e234{s=HttpChannelState@615c4a3e{s=IDLE rs=BLOCKING os=OPEN is=IDLE awp=false se=false i=true al=0},r=2,c=false/false,a=IDLE,uri=//localhost:57023/json?text=This%20is%20test%20car%20name%20fuck,age=0}
GET //localhost:57023/json?text=This%20is%20test%20car%20name%20fuck HTTP/1.1
Accept: */*
User-Agent: Java/1.8.0_392
Host: localhost:57023
Connection: keep-alive

2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - sendResponse info=null content=HeapByteBuffer@74d1d699[p=0,l=39,c=32768,r=39]={<<<{"result":"This is test car name ****"}>>>\x00\x00\x00\x00\x00\x00\x00\x00\x00...\x00\x00\x00\x00\x00\x00\x00} complete=false committing=true callback=Blocker@716a5dad{null}
2024-08-21 20:37:53 DEBUG org.eclipse.jetty.server.HttpChannel - COMMIT for /json on HttpChannelOverHttp@500e234{s=HttpChannelState@615c4a3e{s=HANDLING rs=BLOCKING os=COMMITTED is=READY awp=false se=false i=true al=0},r=2,c=false/false,a=HANDLING,uri=//localhost:57023/json?text=This%20is%20test%20car%20name%20fuck,age=2}
200 null HTTP/1.1
Matched-Stub-Id: dbc8ae24-b4b6-4781-9049-df16d5fcbc28
Vary: Accept-Encoding, User-Agent

비즈니스 로직 상 Exception이 먼저 발생하기 때문에 호출되지 않아 DB 로그는 없다.

Testing Fixture 자동 생성

테스트 코드를 짜온지 1년 반 쯤 되는데, 매번 테스트 케이스를 일일이 만드는게 너무너무 귀찮았다. 만들고 나니 생각보다 재사용하지도 못해서 정말 귀찮은 일이었다. 사내 강의로 TDD, 실전 테스트 2개 강의를 듣다보니까 더 심해져서 더이상 못 참고 라이브러리를 찾았는데 정말 좋은게 있어서 내용을 추가로 적게 됐다. 일단 결과부터 보자.

goodName과 badName을 랜덤으로 100개씩 만들어내서 테스트를 자동화했다. 입력값이... 좀 그렇긴 한데 그래도 어쨋든 정규식 조건을 만족하는 문자열들로 통합 테스트를 100번이나 안정적으로 통과하게끔 만들었다. 수정사항을 살펴보자.

환경 구축

사용한 라이브러리는 다음과 같다.

  • com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.23
  • org.junit.vintage:junit-vintage-engine:5.9.3
  • com.sun.xml.ws:jaxws-rt:4.0.1
  • com.github.curious-odd-man:rgxgen:2.0

특이사항으로는 Testing Fixture 쓸 때 JDK 8에서 fixture-monkey-starter 사용한다면, Junit5에서 Junit4를 사용할 수 있게 해주는 junit-vintage-engine을 추가해줘야 한다. 또한 JDK 9부터 Java to XML이 기본 포함이 아니기 때문에, jaxws-rt도 추가해줘야 fixture-monkey를 사용할 수 있다.

테스트 코드

먼저 짚고 넘어갈 것은 Junit4의 Parameterized 테스트와 Spring test를 같이 못 쓴다는 점이다. @RunWith는 하나만 쓰게끔 스펙이 되어있어서 둘 중 하나를 포기해야한다.. 이번에는 Parameterized를 선택하고 Spring context 설정을 직접 해주었으나, 아직 내가 방법을 잘 모르는 것 같다는게 확실하게 느껴졌다. 냅다 context에 하나씩 등록하기...!

MyBatisIntegrationWithoutSpringTest.class

Spring Context 설정이 빠졌다.

@Transactional
@Slf4j
public abstract class MyBatisIntegrationWithoutSpringTest {
    private static ApplicationContext context;

    private static EmbeddedPostgres embeddedPostgres;

    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.");
    }

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

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

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

CarServiceParameterizedTest

Parameterized 테스트로 원하는 만큼의 파라미터들을 받아서 테스트하는 클래스이다. 파라미터는 100개까지 지원된다. 기존에 자동화해놨던 Spring Context는 직접 설정해야한다.

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

    private static ApplicationContext context;

    private static WireMockServer wireMockServerForStop;

    private CarService carService;

    private WireMockServer wireMockServer;

    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());
    }

	// Spring Context 직접 설정
    @BeforeClass
    public static void setUpContext() {
        context = new AnnotationConfigApplicationContext(
                MyBatisConfig.class,
                CarServiceParameterizedTestConfiguration.class
        );
    }

    @Before
    public void setUpClass() throws SQLException {
        dataSource = context.getBean(DataSource.class);
        carService = context.getBean(CarService.class);
        wireMockServer = context.getBean(WireMockServer.class);

        if (wireMockServerForStop == null) {
            wireMockServerForStop = wireMockServer;
        }

        initializeSchema();
    }

    @AfterClass
    public static void teardownClass() {
        if (wireMockServerForStop != null) {
            wireMockServerForStop.stop();
        }
    }


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

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

        wireMockServer.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();

        wireMockServer.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));
    }
}

CarServiceParameterizedTestConfiguration.java

기존과 별반 다를바 없는 Configuration이다.

@Configuration
@ComponentScan(basePackageClasses = {
        CarService.class,
        CarDao.class,
        CarMapper.class,
        ProfanityFilterService.class,
})
class CarServiceParameterizedTestConfiguration {
    @Bean
    public WireMockServer wireMockServer() {
        final WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());

        wireMockServer.start();

        return wireMockServer;
    }

    @Bean
    public PurgoMalumClient purgoMalumClient(final WireMockServer wireMockServer) {
        final String apiUrl = "http://localhost:" + wireMockServer.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;
    }
}

후기

Spring boot 없이 레거시 개발 환경에서 여러 가지 테스트 환경들을 구성해보니, boot 덕분에 정말 편리하게 개발해오고 있었다는 걸 뼈저리게 느끼게 됐다. (특히 Pameterized Test) 그래도 Spring Context와 Bean에 대해 정말 많이 이해하게 되었다. 덕분에 요즘 실무에서도 Bean과 디자인 패턴을 자유자재로 쓰게 되면서 또 한 번 성장했다는게 느껴지고 있다. 이 글에는 내용이 벗어나는 것 같아 기재하지 않았지만, 어쩌다보니 Spring 프레임워크 코드를 계속 까보게 돼서 그런 것 같다.

하면서 커스텀 어노테이션을 직접 만들어서 써볼까 라는 고민도 많이 했다. Spring & Paramterized Test 통합 테스트 설정 코드를 매번 할 귀찮음을 견딜 자신이 없어서... 이거는 방법을 좀 고안해봐야겠다.

실무는 이런 토이 프로젝트에 비해 여러 가지 제약 사항이 많으면서, 반대로 요구 사항과 테스트 해야할 것은 정말 많다는 것을 계속해서 느끼고 있다. 그리고 서비스 영향도도 체감되는 환경이다보니 테스트 코드에 많이 기대게 된다. 이런 마음과는 다르게 테스트 할게 너무나 많아서 요즘은 배보다 배꼽이 더 큰거 같다. 그래도 지금 고생해서 쌓아올리는 것들이 앞으로의 실무에서는 시간을 단축시켜주는 기둥이 될 거라고 믿으며 정진해야겠다.

profile
일벌리기 좋아하는 사람

2개의 댓글

comment-user-thumbnail
2024년 8월 15일

엄청 많은 사람들의 손을 탄 엄청 오래된 코드들을 어떻게든 테스트하려고 하는게 넘 대단해요

1개의 답글