Spring Bootとユニットテスト環境の設計について

More than 1 year has passed since last update.


概要

Spring Bootを利用したWebアプリケーションにおいて、実装・保守しやすいユニットテスト環境の設計について考えたことをまとめた、ある意味考察的な内容の記事です。

設計よりの記事なのでテストコードの具体的な書き方には触れていません。

環境


  • Windows 10 Professional

  • Java 1.8.0_162

  • Spring Boot 2.0.0


    • JUnit 4.12



  • Maven 3.5.2

参考


デモアプリケーション


プロジェクトの構成


パッケージの切り方

このデモアプリケーションではレイヤー別に下記の3つのサブパッケージを作っています。レイヤーをサブパッケージで分割することでレイヤー毎のテスト環境が作りやすくなります。


  • domain (ドメイン層)


    • データベースのアクセスが必要なビジネスロジックを配置しているという想定のパッケージです。



  • external (外部層)


    • domain同様にビジネスロジックに関するクラスを配置しているパッケージです。

    • domainとの違いはデータベースにアクセスしないという点で、外部のWebサービスAPIに依存するビジネスロジックを配置しているという想定です。

    • 外部層とは外部のリソースに依存していることから便宜的に付けた呼び方です。



  • web (Web層)


    • コントローラやInterceptor、ControllerAdviceなどを配置しているパッケージです。

    • ドメイン層や外部層のビジネスロジックに依存しているという想定です。



src.main.java

|
+--- com.example.demo //★package root
| |
| +--- Application.java //★Main Application Class
| |
| +--- domain //★ドメイン層
| | |
| | +--- DatasourceConfig.java //★データソースのコンフィグレーション
| | |
| | +--- entity //☆JPAのエンティティクラスを配置
| | |
| | +--- repository //☆JPAのリポジトリインターフェースを配置
| | | (spring-data-jpa)
| | +--- service //☆ビジネスロジッククラスを配置
| | |
| | +--- impl
| |
| +--- external //★外部層
| | |
| | +--- service //☆ビジネスロジッククラスを配置
| | |
| | +--- impl
| |
| +--- web //★Web層
| |
| +--- WebMvcConfig.java //★WebMvcのコンフィグレーション
| +--- JacksonConfig.java //★Jacksonのコンフィグレーション
| |
| +--- advice
| | |
| | +--- CustomControllerAdvice.java
| |
| +--- interceptor
| | |
| | +--- CustomHandlerInterceptor.java
| |
| +--- controller //☆コントローラクラスを配置
|
src.java.resources
|
+--- application.yml

src.test.java
|
+--- com.example.demo
| |
| +--- domain
| | |
| | +--- DomainTestApplication.java //★ドメイン層テストのMain Application Class
| | |
| | +--- entity
| | |
| | +--- repository
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- external
| | |
| | +--- ExternalTestApplication.java //★外部層テストのMain Application Class
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- web
| |
| +--- WebTestApplication.java //★Web層テストのMain Application Class
| |
| +--- controller
|
src.test.resources
|
+--- application.yml


エントリポイントのクラス

Spring BootではSpringBootApplicationアノテーションを付けたMain Application Classをパッケージルート(もしくは他のクラスより上位のパッケージ)に配置することが推奨されています。

他のクラスより上位の位置にこのクラスを配置することで下位にあるコンポーネント(ComponentやServiceアノテーションが付いた)クラスやコンフィグレーションクラスが自動的にスキャンされます。


14.2 Locating the Main Application Class

We generally recommend that you locate your main application class in a root package above other classes


このクラスは一般的な(よくある)内容なので特筆する点はありません。


Application

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}



ドメイン層のコンフィグレーション

ドメイン層に関係するコンフィグレーションクラスはdomainパッケージ直下に配置します。

この例はデータソースの設定を行うクラスです。このコードではコンフィグレーションは行っていませんが、データソースやトランザクションマネージャのカスタマイズが必要な場合はこのクラスで行うという想定です。


DatasourceConfig

import org.springframework.boot.autoconfigure.domain.EntityScan;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories
@EntityScan
public class DatasourceConfig {

// nothing

}


また、上記のコードでは省略していますが、下記のようにbasePackagesにスキャンするパッケージを明示することもできます。

@EnableJpaRepositories(basePackages = {"com.example.demo.domain.repository"})

@EntityScan(basePackages = {"com.example.demo.domain.entity"})


Web層のコンフィグレーション

Web層に関係するコンフィグレーションクラスはwebパッケージ直下に配置します。

この例はWebMvcのカスタマイズを行うクラスと、


WebMvcConfig

@Configuration

public class WebMvcConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CustomHandlerInterceptor())
.addPathPatterns("/memo/**");
}

}


Jacksonのカスタマイズを行うクラスです。

下記のコードではObjectMapperのカスタマイズを行っていますが、設定ファイルで同様のカスタマイズができます。


JacksonConfig

@Configuration

public class JacksonConfig {

@Bean
public Jackson2ObjectMapperBuilder objectMapperBuilder() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializationInclusion(JsonInclude.Include.NON_NULL)
.indentOutput(true)
.failOnUnknownProperties(false)
.failOnEmptyBeans(false)
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToEnable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
return builder;
}

}



テストコード


エントリポイントのクラス

テストコードのエントリポイントクラスはレイヤー別に用意します。レイヤー別に用意することで必要な依存関係だけを取り込むことができます。


ドメイン層のテスト


ドメイン層テストの依存(影響)範囲

ドメイン層テストのパッケージルートはcom.example.demo.domainで、DomainTestApplicationクラスがテスト時のエントリポイントになります。

src.main.java

|
+--- com.example.demo
| |
| +--- domain
| |
| +--- DatasourceConfig.java //★データソースのコンフィグレーション
| |
| +--- entity
| |
| +--- repository
| |
| +--- service
| |
| +--- impl
|
src.java.resources
|
+--- application.yml

src.test.java
|
+--- com.example.demo
| |
| +--- domain
| |
| +--- DomainTestApplication.java //★ドメイン層テストのMain Application Class
| |
| +--- entity
| |
| +--- repository
| |
| +--- service
| |
| +--- impl
|
src.test.resources
|
+--- application.yml


テスト用Main Application Class


DomainTestApplication

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DomainTestApplication {
public static void main(String[] args) {
SpringApplication.run(DomainTestApplication.class, args);
}
}



リポジトリの単体テスト(埋め込みDBを利用)

リポジトリの単体テストではDataJpaTestアノテーションを利用します。

DataJpaTestアノテーションを付けるとデータソースの設定に関わらず埋め込みDBが利用されます。(このデモアプリケーションではH2を利用)

またEntityManagerの代わりにTestEntityManagerが利用できます。

@RunWith(SpringRunner.class)

@DataJpaTest
public class MemoRepositoryTests {

@Autowired
private TestEntityManager testEntityManager;
@Autowired
private MemoRepository memoRepository;

// テストコード

}


リポジトリの結合テスト(定義したデータソースを利用)

埋め込みDBではなく設定ファイルに定義したデータソースを利用して結合テストを行いたい場合はAutoConfigureTestDatabaseアノテーションで設定を変更できます。

@RunWith(SpringRunner.class)

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemoRepositoryIntegrationTests {

@Autowired
private TestEntityManager testEntityManager;
@Autowired
private MemoRepository memoRepository;

//テストコード

}


テストデータの投入

テストデータの投入にsqlファイルやsql文を利用したい場合はSqlアノテーションが利用できます。

アノテーションはクラスおよびメソッドに付けることができますが、JavaDocに記載のあるように両方につけた場合はメソッドの設定が優先されます。


Method-level declarations override class-level declarations.


@Sql(statements = {

"INSERT INTO memo (id, title, description, done, updated) VALUES (11, 'test title 1', 'test description', false, CURRENT_TIMESTAMP)",
"INSERT INTO memo (id, title, description, done, updated) VALUES (12, 'test title 2', 'test description', true, CURRENT_TIMESTAMP)",
"INSERT INTO memo (id, title, description, done, updated) VALUES (13, 'test title 3', 'test description', false, CURRENT_TIMESTAMP)",
})


DataJpaTest以外でもTestEntityManagerを利用する

AutoConfigureTestEntityManagerアノテーションを利用すればDataJpaTestアノテーションが無くてもTestEntityManagerが使えるようになります。


サービスの単体テスト

サービスの単体テストではSpring Frameworkに依存しないのでSpringRunnerなどは必要ありません。

テスト対象が依存するコンポーネントはMockitoでモック化(あるいはスパイ化)します。

public class MemoServiceImplTests {

@Rule
public MockitoRule rule = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);

@Mock
private MemoRepository memoRepository;
@InjectMocks
private MemoServiceImpl sut;

// テストコード

}


サービスの結合テスト

サービスの結合テストではSpringBootTestアノテーションを利用します。またWebサーバーの機能は不要なのでwebEnvironmentにWebEnvironment.NONEを指定しています。NONEにすると組み込みのWebサーバーは起動しません。

データベースへのアクセスは設定ファイルのデータソースが利用されます。

テストデータの投入は前述のSqlアノテーションを使うことも下記コードのEntityManagerを使うこともできます。

その他にも80. Database Initializationで説明されている方法があります。

@RunWith(SpringRunner.class)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Transactional
public class MemoServiceImplIntegrationTests {

@Autowired
private EntityManager entityManager;
@Autowired
private MemoServiceImpl sut;

// テストコード

}


外部層のテスト


外部層テストの依存(影響)範囲

外部層テストのパッケージルートはcom.example.demo.externalで、ExternalTestApplicationクラスがテスト時のエントリポイントになります。

src.main.java

|
+--- com.example.demo
| |
| +--- external
| |
| +--- service
| |
| +--- impl
|
src.java.resources
|
+--- application.yml

src.test.java
|
+--- com.example.demo
| |
| +--- external
| |
| +--- ExternalTestApplication.java //★外部層テストのMain Application Class
| |
| +--- service
| |
| +--- impl
|
src.test.resources
|
+--- application.yml


テスト用Main Application Class

外部層ではデータベースに依存していないのでAutoConfigurationからDataSourceAutoConfigurationを除外します。


ExternalTestApplication

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;

@SpringBootApplication(exclude = {
DataSourceAutoConfiguration.class
})
public class ExternalTestApplication {
public static void main(String[] args) {
SpringApplication.run(ExternalTestApplication.class, args);
}
}



サービスの単体、結合テスト

ドメイン層のサービスのテストと同じようなテストクラスになるので省略します。


Web層のテスト


Web層テストの依存(影響)範囲

Web層テストのパッケージルートはcom.example.demo.webで、WebTestApplicationクラスがテスト時のエントリポイントになります。

src.main.java

|
+--- com.example.demo
| |
| +--- domain
| | |
| | +--- DatasourceConfig.java //★データソースのコンフィグレーション
| | |
| | +--- entity
| | |
| | +--- repository
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- external
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- web
| |
| +--- WebMvcConfig.java //★WebMvcのコンフィグレーション
| +--- JacksonConfig.java //★Jacksonのコンフィグレーション
| |
| +--- advice
| | |
| | +--- MyControllerAdvice.java
| |
| +--- interceptor
| | |
| | +--- MyHandlerInterceptor.java
| |
| +--- controller
|
src.java.resources
|
+--- application.yml

src.test.java
|
+--- com.example.demo
| |
| +--- domain
| | |
| | +--- entity
| | |
| | +--- repository
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- external
| | |
| | +--- service
| | |
| | +--- impl
| |
| +--- web
| |
| +--- WebTestApplication.java //★Web層テストのMain Application Class
| |
| +--- controller
|
src.test.resources
|
+--- application.yml


テスト用Main Application Class

Web層ではドメイン層、外部層へ依存しているので対象パッケージをscanBasePackagesに指定しています。


WebTestApplication

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(scanBasePackages = {
"com.example.demo.domain.service",
"com.example.demo.external.service",
"com.example.demo.web"
})
public class WebTestApplication {
public static void main(String[] args) {
SpringApplication.run(WebTestApplication.class, args);
}
}



エンティティをJsonへ変換する単体テスト

Jsonの単体テストではJsonTestアノテーションを利用します。

エンティティからjsonへ変換した結果が期待通りにシリアライズされるかという単体テストです。

JacksonのJsonPropertyやJsonIgnoreアノテーションでシリアライズをカスタマイズしていたり、とくにJPAの関連アノテーション(OneToMany、ManyToOneなど)でエンティティ間に相互参照があるような場合に無限ループが起きないかテストします。

@RunWith(SpringRunner.class)

@JsonTest
public class MemoToJsonTests {

@Autowired
private JacksonTester<Memo> json;

//テストコード

}


コントローラの単体テスト

コントローラの単体テストではWebMvcTestアノテーションを利用します。

WebMvcTestではMockMvcやWebClient(依存関係にHtmlUnitが必要です)が利用できます。

テスト対象のコントローラが依存するコンポーネントのモック化はSpring BootのMockBean(スパイ化はSpyBean)アノテーションを利用します。MockBeanでモック化されたオブジェクトはアプリケーションコンテキストに追加され、テスト対象(この例ではMemoController)に注入されます。

@RunWith(SpringRunner.class)

@WebMvcTest(MemoController.class)
public class MemoControllerTests {

@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper objectMapper;

@MockBean
private MemoService memoService;

//テストコード

}


WebMvcTest以外でもMockMvcを利用する

AutoConfigureMockMvcアノテーションを利用すればWebMvcTestアノテーションが無くてもMockMvcが使えるようになります。


コントローラの結合テスト

SpringBootTestアノテーションを利用するテストクラスでは、RestTemplateの代わりにTestRestTemplateが利用できます。

コントローラの結合テストではデータベースへのアクセスが必要なのでDatasourceConfigクラスをインポートします。

@RunWith(SpringRunner.class)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(value = {DatasourceConfig.class})
public class MemoControllerIntegrationTests {

@Autowired
private TestRestTemplate testRestTemplate;

// テストコード

}


結合テスト時のデータソースの差し替え

下記コードのような設定クラスを定義することで、任意の結合テストでデータソースを埋め込みDBに差し替えることができます。

@TestConfiguration

public class WebTestConfig {

@Bean
public DataSource datasource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.setScriptEncoding("UTF-8")
.addScripts("classpath:scripts/init.sql")
.build();
}

}

TestConfigurationアノテーションを使った設定クラスはSpringBootConfigurationアノテーションの自動検出の対象外なので手動でインポートする必要があります。

@Import(value = {DatasourceConfig.class, WebTestConfig.class})

ちなみにスキーマ作成やテストデータの投入では埋め込みDBでも使えるSQLで記述しないといけないなど使いどころが難しいです。


補足


自動コンフィグレーションを確認する

設定ファイルにdebugプロパティを設定するとデバッグログが出力されます。(システムプロパティで-Ddebugを指定するのでも同様)

debug: true

デバッグログには"CONDITIONS EVALUATION REPORT"という自動コンフィグレーションの結果が出力されるのでコンフィグレーションの状態が確認できます。

============================

CONDITIONS EVALUATION REPORT
============================

Positive matches:
-----------------

AopAutoConfiguration matched:
- @ConditionalOnClass found required classes 'org.springframework.context.annotation.EnableAspectJAutoProxy', 'org.aspectj.lang.annotation.Aspect', 'org.aspectj.lang.reflect.Advice', 'org.aspectj.weaver.AnnotatedElement'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)
- @ConditionalOnProperty (spring.aop.auto=true) matched (OnPropertyCondition)

// 省略

Negative matches:
-----------------

ActiveMQAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required classes 'javax.jms.ConnectionFactory', 'org.apache.activemq.ActiveMQConnectionFactory' (OnClassCondition)

// 省略

Exclusions:
-----------

None

Unconditional classes:
----------------------

org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration

// 省略


  • Positive matches


    • Conditionalアノテーションの条件に一致した組み込まれたコンフィグレーションクラス



  • Negative matches


    • Conditionalアノテーションの条件に一致しなかった組み込まれないコンフィグレーションクラス



  • Exclusions


    • 明示的に除外したコンフィグレーションクラス



  • Unconditional classes


    • 無条件に組み込まれるコンフィグレーションクラス




テスト対象のprivate fieldを置き換える


Mockito.Whiteboxは使用できません

Mockito 1.xを使ったユニットテストでは、ときどきWhiteboxというクラスでテスト対象のprivate fieldを置き換えるというテストコードを見ることがありましたが、Mockito 2.1からこのクラスは使用できなくなりました。

Whitebox.setInternalState(sut, "someService", mockSomeService);

Spring (Boot)ではValueアノテーションでprivate fieldに設定値を注入することがあるので、このような場合にも使用されているのを見ることがありました。

@Value("${app.someValue}")

private String someValue;

Mockitoの開発チームがWhiteboxを削除した理由は以下のissuesから知ることができますが、乱暴な意訳をすれば「Whiteboxの安易な使用が質の悪いテストコードの量産の後押しをしてしまっている」からということのようです。


代替方法

Spring (Boot)では、Whiteboxの代わりにReflectionTestUtilsクラスを使うことができますが、リフレクションを使った置き換えは悪手ということであれば、この方法も使うことが躊躇われます。

他の代替方法にフィールドの可視範囲をprivateからpackage privateへ変える方法があります。通常はテスト対象のクラスと同じパッケージにテストクラスがあるので、テストコードから直接フィールドを書き換えることが可能です。

このようにテストをし易いように可視範囲を広げることはめずらしいことではなく、例えばGoogle GuavaにはVisibleForTestingというアノテーションがあります。このアノテーションはマーカーアノテーションなのでテスト時に自動的に可視範囲を広げるという訳ではありません。

なお、テスト駆動開発やユニットテストの手法について詳しくはないので、リフレクションを使うことが"bad"で、可視範囲を緩くすることが"better than"という点についてはコメントできません。