概要
Spring Bootを利用したWebアプリケーションにおいて、実装・保守しやすいユニットテスト環境の設計について考えたことをまとめた、ある意味考察的な内容の記事です。
設計よりの記事なのでテストコードの具体的な書き方には触れていません。
環境
- Windows 10 Professional
- Java 1.8.0_162
- Spring Boot 2.0.0
- JUnit 4.12
- Maven 3.5.2
参考
- [Spring Boot Reference Guide - Part IV. Spring Boot features - 43. Testing] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/reference/html/boot-features-testing.html)
- [Spring Framework Documentation - Testing] (https://docs.spring.io/spring/docs/5.0.4.RELEASE/spring-framework-reference/testing.html)
- [Spring Data JPA - Reference Documentation] (https://docs.spring.io/spring-data/jpa/docs/2.0.5.RELEASE/reference/html/)
デモアプリケーション
プロジェクトの構成
パッケージの切り方
このデモアプリケーションではレイヤー別に下記の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
このクラスは一般的な(よくある)内容なので特筆する点はありません。
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パッケージ直下に配置します。
この例はデータソースの設定を行うクラスです。このコードではコンフィグレーションは行っていませんが、データソースやトランザクションマネージャのカスタマイズが必要な場合はこのクラスで行うという想定です。
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のカスタマイズを行うクラスと、
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CustomHandlerInterceptor())
.addPathPatterns("/memo/**");
}
}
Jacksonのカスタマイズを行うクラスです。
下記のコードではObjectMapperのカスタマイズを行っていますが、設定ファイルで同様のカスタマイズができます。
@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
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] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/orm/jpa/DataJpaTest.html)アノテーションを利用します。
DataJpaTestアノテーションを付けるとデータソースの設定に関わらず埋め込みDBが利用されます。(このデモアプリケーションではH2を利用)
またEntityManagerの代わりに[TestEntityManager] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/orm/jpa/TestEntityManager.html)が利用できます。
@RunWith(SpringRunner.class)
@DataJpaTest
public class MemoRepositoryTests {
@Autowired
private TestEntityManager testEntityManager;
@Autowired
private MemoRepository memoRepository;
// テストコード
}
リポジトリの結合テスト(定義したデータソースを利用)
埋め込みDBではなく設定ファイルに定義したデータソースを利用して結合テストを行いたい場合は[AutoConfigureTestDatabase] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/jdbc/AutoConfigureTestDatabase.html)アノテーションで設定を変更できます。
@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemoRepositoryIntegrationTests {
@Autowired
private TestEntityManager testEntityManager;
@Autowired
private MemoRepository memoRepository;
//テストコード
}
テストデータの投入
テストデータの投入にsqlファイルやsql文を利用したい場合は[Sql] (https://docs.spring.io/spring-framework/docs/5.0.4.RELEASE/javadoc-api/org/springframework/test/context/jdbc/Sql.html)アノテーションが利用できます。
アノテーションはクラスおよびメソッドに付けることができますが、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] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/orm/jpa/AutoConfigureTestEntityManager.html)アノテーションを利用すれば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] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/context/SpringBootTest.html)アノテーションを利用します。また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を除外します。
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に指定しています。
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] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/json/JsonTest.html)アノテーションを利用します。
エンティティからjsonへ変換した結果が期待通りにシリアライズされるかという単体テストです。
JacksonのJsonPropertyやJsonIgnoreアノテーションでシリアライズをカスタマイズしていたり、とくにJPAの関連アノテーション(OneToMany、ManyToOneなど)でエンティティ間に相互参照があるような場合に無限ループが起きないかテストします。
@RunWith(SpringRunner.class)
@JsonTest
public class MemoToJsonTests {
@Autowired
private JacksonTester<Memo> json;
//テストコード
}
コントローラの単体テスト
コントローラの単体テストでは[WebMvcTest] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/web/servlet/WebMvcTest.html)アノテーションを利用します。
WebMvcTestではMockMvcやWebClient(依存関係にHtmlUnitが必要です)が利用できます。
テスト対象のコントローラが依存するコンポーネントのモック化はSpring Bootの[MockBean] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/mock/mockito/MockBean.html)(スパイ化は[SpyBean] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/mock/mockito/SpyBean.html))アノテーションを利用します。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] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/autoconfigure/web/servlet/AutoConfigureMockMvc.html)アノテーションを利用すればWebMvcTestアノテーションが無くてもMockMvcが使えるようになります。
コントローラの結合テスト
SpringBootTestアノテーションを利用するテストクラスでは、RestTemplateの代わりに[TestRestTemplate] (https://docs.spring.io/spring-boot/docs/2.0.0.RELEASE/api/org/springframework/boot/test/web/client/TestRestTemplate.html)が利用できます。
コントローラの結合テストではデータベースへのアクセスが必要なので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();
}
}
@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の安易な使用が質の悪いテストコードの量産の後押しをしてしまっている」からということのようです。
- [Remove Whitebox class #489] (https://github.com/mockito/mockito/issues/489)
- [Whitebox.setInternalState for private static final fields #422] (https://github.com/mockito/mockito/issues/422)
代替方法
Spring (Boot)では、Whiteboxの代わりに[ReflectionTestUtils] (https://docs.spring.io/spring/docs/5.0.4.RELEASE/javadoc-api/org/springframework/test/util/ReflectionTestUtils.html)クラスを使うことができますが、リフレクションを使った置き換えは悪手ということであれば、この方法も使うことが躊躇われます。
他の代替方法にフィールドの可視範囲をprivateからpackage privateへ変える方法があります。通常はテスト対象のクラスと同じパッケージにテストクラスがあるので、テストコードから直接フィールドを書き換えることが可能です。
このようにテストをし易いように可視範囲を広げることはめずらしいことではなく、例えばGoogle Guavaには[VisibleForTesting] (https://google.github.io/guava/releases/19.0/api/docs/com/google/common/annotations/VisibleForTesting.html)というアノテーションがあります。このアノテーションはマーカーアノテーションなのでテスト時に自動的に可視範囲を広げるという訳ではありません。
なお、テスト駆動開発やユニットテストの手法について詳しくはないので、リフレクションを使うことが"bad"で、可視範囲を緩くすることが"better than"という点についてはコメントできません。