안녕하세요. 상품시스템팀 권순규입니다.
저희 팀에서 DB 테스트를 위해 사용하고 있는 DbUnit 의 설정 및 사용에 대해 알려드리고자 합니다.

DbUnit 을 사용하지 않고 스프링부트만을 이용하여 테스트하기

아래와 같은 관계를 가진 User 와 Company 가 있습니다.(예제코드)

새로운 User 를 등록하기 위해서는 UserService 클래스의 register() 를 호출하여 등록하면 되는데, register() 내부에서는 기존에 등록되어 있는 Company 를 조회하여 User 에게 설정 후 저장하게 됩니다.
UserService.java

@Service
public class UserService {
    // ... 주입받는 클래스 생략

    @Transactional
    public User register(UserDto userDto) {

        Company company = companyRepository.findById(userDto.getCompanyId())
                .orElseThrow(EntityNotFoundException::new);

        User user = User.builder()
                .name(userDto.getName())
                .password(userDto.getPassword())
                .email(userDto.getEmail())
                .build();

        user.setCompany(company); // 조회한 Company 설정
        return userRepository.save(user);
    }

}

UserService 클래스의 register() 를 호출했을때 실제 DB 에 저장되는지 여부를 테스트 하기 위해서는, 각 테스트 이전에 Company 를 먼저 저장하는 작업이 필요합니다. User 를 저장하기 위해서는 저장된 Company 를 조회하여 CompanyUser 에 설정해야 하기 때문입니다.

UserServiceTest.java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
class UserServiceTest {
    // ... 주입받는 클래스 생략

    @Test
    void register() throws Exception {
        Company company1 = companyRepository.save(new Company("company1"));
        Company company2 = companyRepository.save(new Company("company2"));

        UserDto userDto1 = UserDto.builder()
                .name("name!!")
                .password("pass!!")
                .email("email1@email.com")
                .companyId(company1.getId())
                .build();

        UserDto userDto2 = UserDto.builder()
                .name("name!!")
                .password("pass!!")
                .email("email2@email.com")
                .companyId(company2.getId())
                .build();

        User savedUser1 = userService.register(userDto1);
        User savedUser2 = userService.register(userDto2);

        User actual1 = userRepository.findById(savedUser1.getId()).get();
        User actual2 = userRepository.findById(savedUser2.getId()).get();

        assertThat(actual1.getEmail()).isEqualTo(userDto1.getEmail());
        assertThat(actual1.getPassword()).isEqualTo(userDto1.getPassword());
        assertThat(actual1.getName()).isEqualTo(userDto1.getName());
        assertThat(actual1.getCompany()).isEqualTo(company1);

        assertThat(actual2.getEmail()).isEqualTo(userDto2.getEmail());
        assertThat(actual2.getPassword()).isEqualTo(userDto2.getPassword());
        assertThat(actual2.getName()).isEqualTo(userDto2.getName());
        assertThat(actual2.getCompany()).isEqualTo(company2);
    }

}

간단한 관계를 가진 예제이기 때문에 테스트에 필요한 데이터를 미리 설정하는데 별다른 힘을 들이지 않았지만, 점차 관계가 복잡해질수록 데이터를 설정하는것에 많은 고통이 따르게 됩니다.
데이터를 설정하는데 드는 힘을 조금이나마 덜어보고자 DbUnit 을 사용해보도록 하겠습니다.

스프링부트에 DbUnit 적용해보기

DbUnit 을 직접 설정하여 테스트하기

DbUnit 을 사용하기위해서는 DbUnit 의존성 추가가 필요합니다.

testCompile group: 'org.dbunit', name: 'dbunit', version: '2.6.0'

의존성 추가 후 DbUnit 으로 테스트를 진행하기 위해서는 아래와 같이 몇 몇 설정이 필요합니다.
UserServiceTestOnlyDbUnit.java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
public class UserServiceTestOnlyDbUnit {
    // ... 주입받는 클래스 생략

    @Autowired
    private DataSource dataSource;

    private Connection connection;
        private IDatabaseConnection iDatabaseConnection;
        private IDataSet flatXmlDataSet;
    
        @BeforeEach
        void setup() throws Exception {
            connection = dataSource.getConnection();
            iDatabaseConnection = new MySqlConnection(connection, "dbunit"); // DbUnit 이 사용할 Connection 설정
    
            InputStream is = this.getClass().getResourceAsStream("company.xml"); // 테스트용 데이터셋 설정
            flatXmlDataSet = new FlatXmlDataSetBuilder().build(is);
            DatabaseOperation.CLEAN_INSERT.execute(iDatabaseConnection, flatXmlDataSet); // 테스트용 데이터셋 DB 에 입력
        }
    
        @AfterEach
        void tearDown() throws Exception {
            // DatabaseOperation.DELETE_ALL.execute(iDatabaseConnection, flatXmlDataSet); // 입력한 데이터셋 삭제
            if (connection != null) {
                connection.close();
            }
            if (iDatabaseConnection != null) {
                iDatabaseConnection.close();
            }
        }

}

DBUnit 이 직접 DB 에 접근하여 테스트용 데이터들을 Insert 해야 하기 때문에 IDatabaseConnection 을 생성할때 connection 을 전달 해주어야 합니다.
IDatabaseConnection 은 DB 타입에 따라 구현체들이 존재하는데 MySqlConnection, Db2Connection, HsqldbConnection, OracleConnection, MsSqlConnection, H2Connection 등의 구현체가 존재합니다.
사용하는 DB 에 해당하는 IDatabaseConnection 구현체가 존재하는 않을 경우 DatabaseConnection 을 사용하면 됩니다. DatabaseConnection 과 DB 이름에 해당하는 구현체의 차이점은 각 DB 의 비표준 타입을 DBUnit 에서 사용할 수 있도록 해주는 설정이 추가 되어 있습니다. DbUnit 에서 지원하지 않는 타입을 테스트하고 싶으면, MySqlConnection 클래스처럼 IMetadataHandlerIDataTypeFactory 를 구현하여 DatabaseConnection 의 Config 에 설정해주면 됩니다.

public class MySqlConnection extends DatabaseConnection
{
    public MySqlConnection(Connection connection, String schema) throws DatabaseUnitException
    {
        super(connection, schema);
        getConfig().setProperty(DatabaseConfig.PROPERTY_DATATYPE_FACTORY,
                new MySqlDataTypeFactory());
        getConfig().setProperty(DatabaseConfig.PROPERTY_METADATA_HANDLER, 
                new MySqlMetadataHandler());
    }
}

DB에 실제 데이터 생성은 DatabaseOperation 의 execute() 를 실행할때 xml 에 지정해놓은 데이터들이 DB에 생성되게 됩니다. DatabaseOperation 은 abstract 클래스로서 직접 생성이 불가능하기 때문에 위의 예제에서는 CLEAN_INSERT 라는 내부에 정의한 상수를 통해 execute() 를 실행하게 됩니다. DatabaseOperation 내부에 선언되어 있는 상수는 아래와 같습니다.

  • NONE : 아무런 행위도 하지 않습니다.
  • UPDATE : 데이터셋의 값으로 데이터베이스를 갱신합니다. 동일한 기본키를 가진 데이터가 DB 에 없으면 예외가 발생합니다.
  • INSERT : 데이터셋을 데이터베이스에 추가합니다. 동일한 기본키를 가진 데이터가 이미 존재한다면 예외가 발생합니다.
  • REFRESH : 데이터가 존재하면 UPDATE 를 하고, 존재하지 않으면 INSERT 를 수행합니다.
  • DELETE : 데이터셋에 설정해놓은 데이터만 DB 에서 삭제합니다.
  • DELETE_ALL : 데이터셋에 설정되어있는 테이블에 해당하는 모든 데이터를 DB 에서 삭제합니다.
  • TRUNCATE_TABLE : 데이터셋에 설정되어 있는 테이블을 TRUNCATE 합니다. TRUNCATE 를 지원하지 않는 DB 에서는 사용이 불가합니다.
  • CLEAN_INSERT : DELETE_ALL 을 먼저 수행 후 INSERT 를 수행합니다.

위의 테스트에서는 @Transactional 을 클래스에 선언해 두었기 때문에 테스트가 종료되면 자동으로 롤백이 진행되어야 합니다. 하지만 테스트 종료 후 company 테이블을 조회하게 되면 아래와 같이 데이터셋에 설정해두었던 데이터들이 롤백되지 않았습니다.

하지만 테스트를 반복하여 실행하여도 동일한 결과를 얻을 수 있습니다. 위에서는 DBUnit 의 IDatabaseConnection 에 Connection 을 직접 할당해주었기 때문에 롤백이 발생하지 않았고, DatabaseOperation.CLEAN_INSERT.execute() 를 테스트 전에 실행했기 때문에 테스트는 실패하지 않았습니다.
만약, 테스트 종료후에 설정해둔 데이터셋 까지도 완전히 지우려면 tearDown() 안에 DatabaseOperation.DELETE_ALL.execute() 를 주석 해제해주게 되면 설정되었던 데이터셋 또한 완전히 삭제되게 됩니다.
DatabaseOperation.DELETE_ALL.execute() 을 수행하게되면 DB 에 존재하는 모든 테이블의 내용을 삭제하는 것이 아닌, 데이터셋에 설정해둔 테이블의 내용만을 삭제하게 됩니다.

DBUnit 의 데이터셋은 아래와 같이 xml 파일로 설정하여 사용 가능합니다.
dataset.xml

<dataset>
    <company company_id="12345" name="company12345" />
    <company company_id="23456" name="company23456" />

    <user user_id="675" name="name1" password="pass111" email="user1@email.com" company_id="23456" />
    <user user_id="676" name="name1" password="pass111" company_id="23456" />
</dataset>

설정할 데이터의 테이블명을 태그로 묶어서 각 컬럼에 값을 적어주면 됩니다. 만약 컬럼에 null 값을 설정하고 싶다면, 위의 user_id=676email 컬럼 같이 해당 컬럼을 생략해주면 null 로 저장되게 됩니다.

데이터를 설정할때 주의하실점은 테이블의 첫번째 데이터는 반드시 모든 컬럼을 입력해야 한다는 점 입니다. 만약 아래와 같이 첫번째 데이터에 생략된 컬럼이 있다면 해당 컬럼은 이후 데이터에서도 모두 생략되어 값이 null 로 입력되게 됩니다.

<dataset>
        <user user_id="676" name="name1" password="pass111" company_id="23456" />
        <user user_id="675" name="name1" password="pass111" email="user1@email.com" company_id="23456" />
        <user user_id="677" name="name1" password="pass111" email="user1@email.com" company_id="23456" />
        <user user_id="678" name="name1" password="pass111" email="user1@email.com" company_id="23456" />
</dataset>

위에서 사용했던 데이터셋은 데이터의 양이 별로 많지 않고, 컬럼의 갯수도 몇 개 되지 않기 때문에 그럭저럭 직접 키보드로 설정해줄만 합니다. 그렇지만 테스트 코드에서 직접 데이터셋을 설정하는 것에 비해 들이는 힘이 많이 줄어든 것 같지는 않습니다.
이러한 수고로움을 덜기 위해 IntelliJ 를 사용하고 있는 저는 DbUnit Extractor 라는 플러그인의 도움을 받아 데이터를 설정합니다.
개발용 DB 에서 원하는 테이블을 데이터를 조회한 후 아래와 같이 우클릭을 통해 데이터를 복사 한 뒤 xml 파일에 붙여넣는 방식으로 테스트용 데이터셋을 설정하고 있습니다.

springtestdbunit 적용해보기

가장먼저 아래의 의존성을 추가해줘야 합니다.

testCompile group: 'com.github.springtestdbunit', name: 'spring-test-dbunit', version: '1.3.0'

그리고 아래와 같이 Bean 을 등록해주어야 합니다.
TestConfig.java

@Configuration
public class TestConfig {

    @Bean
    public DatabaseConfigBean dbUnitDatabaseConfig() {
        DatabaseConfigBean config = new DatabaseConfigBean();
        config.setAllowEmptyFields(true);
        config.setDatatypeFactory(new MySqlDataTypeFactory());
        config.setMetadataHandler(new MySqlMetadataHandler());
        return config;
    }

    @Bean
    public DatabaseDataSourceConnectionFactoryBean dbUnitDatabaseConnection(DataSource dataSource) {
        DatabaseDataSourceConnectionFactoryBean dbUnitDatabaseConnection = new DatabaseDataSourceConnectionFactoryBean();
        dbUnitDatabaseConnection.setDataSource(dataSource);
        dbUnitDatabaseConnection.setDatabaseConfig(dbUnitDatabaseConfig());
        dbUnitDatabaseConnection.setSchema("dbunit");
        return dbUnitDatabaseConnection;
    }

}

Bean 을 등록 후 아래와 같이 테스트를 실행 할 수 있습니다.
UserServiceTestWithSpringDbUnit.java

package woowa.dbunit.example.dbunit;
// ... 
@Transactional
@Import(TestConfig.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestExecutionListeners({
        DbUnitTestExecutionListener.class,
        DependencyInjectionTestExecutionListener.class, // Bean 을 DI 받기 위해 선언해줘야 한다.
//        TransactionDbUnitTestExecutionListener.class // @DatabaseSetup 부터 @DatabaseTearDown 까지 트랜잭션이 적용된다.
                                                    // @DatabaseTearDown 선언시 DbUnitTestExecutionListener 와 함께 사용할 수 없다.
})
@DbUnitConfiguration(databaseConnection = "dbUnitDatabaseConnection")
@DatabaseSetup(value = {"company.xml", "user.xml"}, type = DatabaseOperation.CLEAN_INSERT)
@DatabaseTearDown(value = {"company.xml", "user.xml"}, type = DatabaseOperation.DELETE_ALL)
public class UserServiceTestWithSpringDbUnit {
    // ...
}

SpringTestDbUnit 을 통하여 테스트를 실행시키기 위해서는 반드시 @TestExecutionListeners 를 선언해주어야 합니다. 그리고 @TestExecutionListeners 의 값으로 DbUnitTestExecutionListener.class 를 선언해 주어야 SpringTestDbUnit 을 통하여 테스트를 실행 할 수 있습니다.
Bean 을 DI 를 받기 위해서는 DependencyInjectionTestExecutionListener.class@TestExecutionListeners 의 값으로 함께 추가해 주어야 DI 를 받을 수 있습니다. TransactionDbUnitTestExecutionListener.class@TestExecutionListeners 에 추가해주게 되면 @DatabaseSetup@DatabaseTearDown 실행 시에도 트랜잭션이 유지되어 테스트데이터가 롤백되게 됩니다.

@DatabaseSetup 은 테스트 실행전, @DatabaseTearDown 는 테스트 실행 후 type 에 지정된 행위를 실행하게 됩니다. value 에는 데이터셋 파일명을 적어주면 되는데, 문자열 배열로 선언되어 있어 위의 코드와 같이 여러개의 데이터셋 파일을 지정 할 수 있습니다.
value 에 경로없이 데이터셋의 파일명을 적어주게 되면 현재 테스트클래스의 패키지와 동일한 resources 하위 경로에서 파일을 읽어들이게 됩니다.

위와 같이 @TestExecutionListeners 를 클래스에 선언하는 방법도 있지만, main/resources/META-INF/ 경로 하위에 spring.factories 파일을 생성후에 아래와 같은 내용을 추가하여도 SpringTestDbUnit 을 통하여 테스트를 실행 할 수 있습니다.

org.springframework.test.context.TestExecutionListener=com.github.springtestdbunit.DbUnitTestExecutionListener

SpringTestDbUnit 을 통하여 테스트 실행할때는 DbUnit 만을 이용하여 테스트할 때와는 달리, 첫번째 데이터셋에 컬럼을 생략하였어도 이후 데이터에서 생략된 컬럼이 무시되지 않고 정상적으로 데이터가 DB 에 입력 됩니다.

<dataset>
    <user user_id="676" name="name1" password="pass111" company_id="23456" />
    <user user_id="675" name="name1" password="pass111" email="user1@email.com" company_id="23456" />
</dataset>

마무리

DbUnit 을 이용하여 테스트를 하게되면 데이터셋을 좀 더 편리하게 설정할 수 있다는 장점이 있습니다.
하지만, DbUnit 과 SpringTestDbUnit 모두 스프링처럼 기업에서 관리되는 프로젝트가 아니라는 단점이 있습니다.

제가 아는 지식은 여기까지라 여기서 마무리 짓겠습니다.

끝!!!