- はじめに
- 背景について
- build.gradleやH2とFlywayの設定など
- @SpringBootTestとConfigurationクラス
- coreサブプロジェクトのServiceとRepository
- さて本題の単体テスト
- つまづいた点(指定のH2が立ちあがらない場合)
※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であれば、別の単体テスト用のデータベースを立てる必要がないので便利ですね!
H2は起動時に動作モードをMySQL/PostgreSQL/Oracleなどで指定してエミュレートの指定が出来ます。
また単体テスト実行時のスキーマやテスト・データの管理は
■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)