au Commerce&Life Tech Blog

au コマース&ライフ株式会社の開発ブログ

Java × Spring Boot のマルチプロジェクトでのMyBatisの単体テストについて

KDDIコマースフォワード㈱ 、略称「KCF」は2019年4月1日、同グループ会社の㈱ルクサと合併し「auコマース&ライフ株式会社」として再設立いたしました。  本記事は2019年3月31日以前に書かれた記事のアーカイブとなります。予めご了承ください。

はじめに

こんにちは、KCFのエンジニアの坂本です。

私たちの開発チームでは、Java × Spring Boot でORマッパーに MyBatis 3 を採用しています。

今日は、そのMyBatisを使ってる場合の単体テストについて書いてみたいと思います。

背景について

自分たちのチームでは、
システム全体の保守性を上げるために(相互依存性を下げるために)
以下の構成を採用しています。

●マルチプロジェクト構成
●データベースにアクセスするロジックなどはcoreというサブプロジェクトへ集める

うーん、ブログって、こういう背景というか、そもそもの前提を書く部分って、なかなか難しいですよね。
※まあ、そのまま進めます。

「そのcoreプロジェクトについて単体テストする場合はMockじゃなくて本当のデータを入れてテストしたいよね」

ということで、
単体テストのポリシーは、色いろな方法・アプローチがあると思いますが、 自分たちのチームでは「こうやってるよ」という「例」として、書いてみたいと思います。

build.gradleやH2とFlywayの設定など

これから、順を追って説明して行きますが、
もしかすると最後に書いてある「さて本題の単体テスト」をまず読んでから、逆に追って読んだ方が分かりやすいかもしれません。
※とはいえ、このまま進めます。

まず、自分たちのチームでの、
●マルチプロジェクト構成
のbuild.gradleとsettings.gradleは
以下のような感じになってます。

■build.gradle

buildscript {
    ext {
        springBootVersion = "2.0.1.RELEASE"
    }
    repositories {
        mavenCentral()
        maven {
            url("https://plugins.gradle.org/m2/")
        }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("gradle.plugin.com.arenagod.gradle:mybatis-generator-plugin:1.4")
    }
}

subprojects {
    apply plugin: "java"
    apply plugin: "eclipse"
    apply plugin: "org.springframework.boot"
    apply plugin: "io.spring.dependency-management"
    sourceCompatibility = 1.8
    repositories {
        mavenCentral()
        maven {
            url("https://repo.spring.io/libs-snapshot")
            url("http://www.datanucleus.org/downloads/maven2/")
        }
    }
    dependencies {
        // ここにプロジェクトに応じて様ざまな依存関係が入ると思われます
        compileOnly("org.projectlombok:lombok")
    }
}

project(":datasource") {
    apply plugin: "com.arenagod.gradle.MybatisGenerator"
    dependencies {
        compile("org.mybatis.spring.boot:mybatis-spring-boot-starter:1.3.2")
    }
    configurations {
        mybatisGenerator
    }
    mybatisGenerator {
        verbose = true
        configFile = "${projectDir}/src/main/resources/mybatis/generatorConfig.xml"
    }
    jar {
        baseName = "datasource"
        version = "1.0.0"
        exclude("mybatis/**")
        enabled = true
    }
    bootJar.enabled = false
}

project(":core") {
    dependencies {
        compile project(":datasource")
        compile("org.springframework.boot:spring-boot-starter-aop")
        compile("oracle:ojdbcXX:YYYY")
        // ここにプロジェクトに応じて様ざまな依存関係が入ると思われます
        testCompile("org.mybatis.spring.boot:mybatis-spring-boot-starter-test:1.3.2")
        testCompile("org.flywaydb:flyway-core")
        testRuntime("com.h2database:h2")
     }
    jar {
        baseName = "core"
        version = "1.0.0"
        enabled = true
    }
    bootJar.enabled = false
}

project(":web") {
    dependencies {
        compile project(":core")
        compile("org.springframework.boot:spring-boot-starter-actuator")
        compile("org.springframework.boot:spring-boot-starter-thymeleaf")
        testCompile("org.springframework.boot:spring-boot-starter-test")
    }
    bootJar.enabled = true
    bootJar {
        launchScript()
    }
}

■settings.gradle

include 'datasource', 'core', 'web'


そろそろ、単体テストの話に入って行きますね。
以下が、coreプロジェクトのtestのresourceファルダに置いている設定ファイルです。

■core/src/test/resources/application-test.yml

spring:
  profiles:
    active: test

---

spring:
  profiles: test

  datasource:
    url: jdbc:h2:mem:sampledb;MODE=Oracle;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;AUTOCOMMIT=OFF
    username: sa
    password:
    driver-class-name: org.h2.Driver

私たちのチームでは、単体テストに使うデータベースは専用でインメモリの
■H2
を使ってます。

インメモリのH2であれば、別の単体テスト用のデータベースを立てる必要がないので便利ですね!

www.h2database.com

H2は起動時に動作モードをMySQLPostgreSQLOracleなどで指定してエミュレートの指定が出来ます。


また単体テスト実行時のスキーマやテスト・データの管理は
■Flyway
に任せています。

始めに載せた
build.gradleにあったように
testの依存関係にFlywayを入れておけば、
あとはSpring BootのAutoConfigurationが「よしな」にやってくれます。

以下は、Flyway用のスキーマ・ファイルの「例」です。


V1_create_table.sql

/* SAMPLE_DATAテーブル */

drop table if exists SAMPLE_DATA;

create table "SAMPLE_DATA"
 ("ID" NUMBER(3,0) NOT NULL ENABLE,
  "NAME" VARCHAR2(10) NOT NULL ENABLE,
  CONSTRAINT "SAMPLE_DATA_PK" PRIMARY KEY ("ID")
 );

V2_insert_test_data.sql

/* SAMPLE_DATAテスト・データ */

insert into SAMPLE_DATA (ID, NAME) values (1, 'name1');

@SpringBootTestとConfigurationクラス

単体テスト・クラスには@SpringBootTestアノテーションを付けているのですが、 そこに合わせて、下記のようなConfigurationクラスを指定しています。

@SpringBootTest(classes = {TestConfiguration.class, TestDataSourceConfiguration.class})

以下は、この2個のConfigurationクラスの説明です。

■TestConfigurationクラス

マルチプロジェクト構成の中でcore(とcoreに依存関係のあるサブプロジェクト)を読み込むよ
というComponentScanの設定をしています。

package jp.samplesample.application.core.configuration;

import static org.mockito.Mockito.mock;

import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@ComponentScan(basePackages = "jp.samplesample.application.core")
@Profile("test")
public class TestConfiguration {

    // ここにMockのBeanが必要な場合もあると思います

    @Bean
    public XXX xxx() {
        return mock(XXX.class);
    }

}


■TestDataSourceConfigurationクラス

こちらはMyBatisの自動生成ファイルのあるdatasourceサブプロジェクトを読み込むMapperScanの設定と
上で既に挙げていたapplication-test.ymlの読み込みなどを指定してます。

package jp.samplesample.application.core.configuration;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import jp.samplesample.application.core.constant.TestValue;
import jp.samplesample.application.datasource.mapper.XXXCustomMapper;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
@MapperScan("jp.samplesample.application.datasource.mapper")
@TestPropertySource(locations = "classpath:application-test.yml")
@Profile("test")
public class TestDataSourceConfiguration {

    // 本体のデータベースでは通るがH2では通らないようなSQLを持っている場合には
    // 以下のようにMockのBeanを入れる場合もあるでしょう

    @Bean
    public XXXMapper xxxMapper() {
        XXXMapper xxxMapper = mock(XXXMapper.class);
        // ここにMockの振る舞いを仕込んでおく
        return xxxMapper;
    }

}

coreサブプロジェクトのServiceとRepository

本題の単体テストの例を載せた時に、
具体的に分かりやすいようにするため、
単体テストの対象となるServiceクラス
(と合わせてServiceクラスを構成している要素)
のサンプルみたいなものも書いておきます。


■SampleDomainクラス

package jp.samplesample.application.core.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class SampleDomain {

    private Integer id;

    private String name;

}


■SampleDomainRepositoryクラス(インターフェース)

package jp.samplesample.application.core.repository;

import jp.samplesample.application.core.domain.SampleDomain;
import org.springframework.stereotype.Repository;

@Repository
public interface SampleDomainRepository {

    SampleDomain findOne(Integer id);

    SampleDomain save(SampleDomain sampleDomain);

}

※「Repository」は「DAO」と呼ぶ人・プロジェクトもあるかもしれないですね


以下が、SampleDomainRepositoryインターフェースの実装クラス(の枠・概要)です。

■SampleDomainRepositoryImplクラス

package jp.samplesample.application.core.repository.impl;

import jp.samplesample.application.core.domain.SampleDomain;
import jp.samplesample.application.core.repository.SampleDomainRepository;
import jp.samplesample.application.datasource.mapper.SampleDataMapper;
import jp.samplesample.application.datasource.model.SampleDataExample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import lombok.NonNull;

@Repository
public class SampleDomainRepositoryImpl implements SampleDomainRepository {

    @Autowired
    private SapmleDataMapper sampleDataMapper;

    @Override
    public SampleDomain findOne(@NonNull Integer id) {
        // ここにMyBatisのMapperとModelを使って実装を書く
    }

    @Override
    public SampleDomain save(@NonNull SampleDomain sampleDomain) {
        // ここにMyBatisのMapperとModelを使って実装を書く
    }

}


やっと今回の単体テストの対象である
SampleDomainServiceクラス
に辿り着きました!


■SampleDomainServiceクラス

package jp.samplesample.application.core.service;

import jp.samplesample.application.core.SampleDomain;
import jp.samplesample.application.core.repository.SampleDomainRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigInteger;

import lombok.NonNull;

@Service
public class SampleDomainService {

    @Autowired
    private SampleDomainRepository sampleDomainRepository;

    @Transactional(readOnly = true)
    public SampleDomain get(@NonNull Long id) {
        return sampleDomainRepository.findOne(id);
    }

    @Transactional(readOnly = false)
    public SampleDomain save(@NonNull SampleDomain sampleDomain) {
        // 例えば以下のようなsaveのための何らかの条件が書いてある(これはちょっとひどい例だけど)
        if (sampleDomain.getId() <= 2) {
            return sampleDomainRepository.save(sampleDomain);
        } else {
            return null;
        }
    }

}

さて本題の単体テスト

coreサブプロジェクトのSampleDomainServiceクラスの
getメソッドとsaveメソッド
が今回の単体テストの対象です。

package jp.samplesample.application.core.service;

import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.junit.Assert.assertThat;

import jp.samplesample.application.core.configuration.TestConfiguration;
import jp.samplesample.application.core.configuration.TestDataSourceConfiguration;

import jp.samplesample.application.core.domain.SampleDomain;
import jp.samplesample.application.core.repository.SampleDomainRepository;
import jp.samplesample.application.core.service.SampleDomainService;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {TestConfiguration.class, TestDataSourceConfiguration.class})
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
public class SampleServiceTest {

    @Autowired
    private SampleDomainService sampleDomainService;

    @Autowired
    private SampleDomainRepository sampleDomainRepository;

    @Test
    public void test_get_期待通りデータが取得できること() {
        Integer id = 1;
        String name = "name1";

        // テスト対象メソッドの実行
        SampleDomain sampleDomain = sampleDomainService.get(id);

        // 結果検証
        assertThat(sampleDomain.getId(), is(id));
        assertThat(sampleDomain.getName(), is(name));
    }

    @Test
    public void test_save_IDが2の場合はデータが作成できること() {
        Integer id = 2;
        String name = "name2";

        SampleDomain saveSampleDomain = new SampleDomain();
        saveSampleDomain.setId(id);
        saveSampleDomain.setId(name);

        // テスト対象メソッドの実行
        sampleDomainService.save(saveSampleDomain);

        // 検証対象データの取得
        SampleDomain resultSampleDomain = sampleDomainRepository.findOne(id);

        // 結果検証
        assertThat(resultSampleDomain.getId(), is(id));
        assertThat(resultSampleDomain.getName(), is(name));
    }

    @Test
    public void test_save_IDが3の場合はデータが作成されないこと() {
        Integer id = 3;
        String name = "name3";

        SampleDomain saveSampleDomain = new SampleDomain();
        saveSampleDomain.setId(id);
        saveSampleDomain.setId(name);

        // テスト対象メソッドの実行
        sampleDomainService.save(saveSampleDomain);

        // 検証対象データの取得
        SampleDomain resultSampleDomain = sampleDomainRepository.findOne(id);

        // 結果検証
        assertThat(resultSampleDomain, is(nullValue()));
    }

}


こんなに単純な例だと、
あまり「ありがたみ」が感じられないかもしれませんが、
もっと複雑になる実際のアプリケーションでは、例えば、
「ある条件のもとでデータベースに入るデータの更新時刻を単体テストでチェックしておく」など、
色いろと助かることがあると思います。

つまづいた点(指定のH2が立ちあがらない場合)

上記の単体テストの「例」ですが、
特に以下のアノテーションが重要でした。
これを付けないと、
application-test.ymlで指定したH2が、
立ちあがらないという現象が起こったはずです。

@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)