概要
Spring Bootを用いたRest APIアプリケーションをmavenのmulti module構成のプロジェクトで作る時の説明記事になります。
記事の前半はmulti moduleプロジェクトの論理的(pomの説明)、物理的(ディレクトリ・ファイル構造の説明)な構造の説明で、後半は各モジュールの特徴についての補足になります。
環境
- Windows10 Professional
- Java 1.8.0_144
- Spring Boot 1.5.6
- Maven 3
参考
- [GETTING STARTED / Creating a Multi Module Project] (https://spring.io/guides/gs/multi-module/)
- [spring-guides/gs-multi-module] (https://github.com/spring-guides/gs-multi-module)
- [Maven by Example - Chapter 6. A Multi-Module Project] (http://books.sonatype.com/mvnex-book/reference/multimodule.html)
- [Spring Framework Reference Documentation - Part IV. Testing - 15. Integration Testing] (https://docs.spring.io/spring/docs/current/spring-framework-reference/html/integration-testing.html)
- [Spring Boot Reference Guide - Part IV. Spring Boot features - 41. Testing] (https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html)
- [Unit and Integration Tests for RestControllers in Spring Boot] (https://thepracticaldeveloper.com/2017/07/31/guide-spring-boot-controller-tests/)
- [Spring Boot 1.4: @MockBean and @SpyBean] (https://gooroo.io/GoorooTHINK/Article/16943/Spring-Boot-14--MockBean-and-SpyBean/24301#.WcfszMhJaHs)
プロジェクト
multi module構造のプロジェクトを説明する題材として、下記のような3つのモジュールから成るRest APIアプリケーションを使用します。
モジュール | ルートパッケージ | 説明 |
---|---|---|
application | com.example.application | コントローラーなどクライアントとの通信処理を実装。 domainモジュールに依存する。 |
domain | com.example.domain | データアクセス(エンティティやリポジトリ)やビジネスロジック(サービス)を実装。 commonモジュールに依存する。 |
common | com.example.common | ユーティリティーなどの共通処理を実装。 |
pomの説明
pom.xmlはプロジェクトに1つ、各モジュール毎に1つの計4つあります。それぞれのpomの説明は下記のとおりです。
プロジェクトのpom.xml
親プロジェクトのpomではプロジェクト情報の設定、モジュールの定義、プロジェクト全体で必要となる依存関係の定義を行います。
no | point |
---|---|
1 | packageingにpomを指定します |
2 | parentにspring-boot-starter-parentを指定します |
3 | modulesにはプロジェクトを構成するモジュールを指定します |
4 | dependenciesには各モジュールで必要になる依存関係を定義します。ここで定義したライブラリはすべてのモジュールから利用できます |
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>mmsbs</artifactId>
<version>0.0.1-SNAPSHOT</version>
<!-- point.1 -->
<packaging>pom</packaging>
<name>mmsbs</name>
<description>Multi Modules Spring Boot Sample application</description>
<!-- point.2 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.6.RELEASE</version>
<relativePath/>
</parent>
<!-- point.3 -->
<modules>
<module>application</module>
<module>domain</module>
<module>common</module>
</modules>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<!-- point.4 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.8</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
applicationモジュールのpom.xml
no | point |
---|---|
1 | parentに親プロジェクトを指定します |
2 | dependenciesにはapplicationモジュールで必要になる依存関係を定義します |
3 | ビルド設定はapplicationモジュールのpomに記述します |
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>application</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>application</name>
<description>Application Module</description>
<!-- point.1 -->
<parent>
<groupId>com.example</groupId>
<artifactId>mmsbs</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<!-- point.2 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<finalName>mmsbs</finalName>
<plugins>
<!-- point.3 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludeDevtools>true</excludeDevtools>
<executable>true</executable>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<compilerVersion>1.8</compilerVersion>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
<compilerArgs>
<!--<arg>-verbose</arg>-->
<arg>-Xlint:all,-options,-path</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>
domainモジュールのpom.xml
no | point |
---|---|
1 | parentに親プロジェクトを指定します |
2 | dependenciesにはdomainモジュールで必要になる依存関係を定義します |
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>domain</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>domain</name>
<description>Domain Module</description>
<!-- point.1 -->
<parent>
<groupId>com.example</groupId>
<artifactId>mmsbs</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<!-- point.2 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-java8</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
commonモジュールのpom.xml
no | point |
---|---|
1 | parentに親プロジェクトを指定します |
2 | dependenciesにはcommonモジュールで必要になる依存関係を定義しますが、いまのところ必要なものはないので未指定です |
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>common</name>
<description>Common Module</description>
<!-- point.1 -->
<parent>
<groupId>com.example</groupId>
<artifactId>mmsbs</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<!-- point.2 -->
<dependency>
</dependency>
</project>
プロジェクトのビルド
プロジェクトのディレクトリで下記のmvnコマンドを実行してビルドします。
> mvn clean package
テストをスキップするには下記のオプションを追加します。
> mvn clean package -Dmaven.test.skip=true
成果物はそれぞれのモジュールのtargetディレクトリ下に出力されています。
Spring Bootアプリケーションとしての成果物(実行可能jar)はapplicationモジュールのtargetディレクトリに出来ています。実行するには下記のコマンドを実行します。
> java -jar application\target\mmsbs.jar
...省略...
Tomcat started on port(s): 8080 (http)
Started Application in 14.52 seconds (JVM running for 15.502)
Rest APIの実行例
> curl -X GET http://localhost:8080/memo/id/1 | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 72 0 72 0 0 4800 0 --:--:-- --:--:-- --:--:-- 4800
{
"title": "memo shopping",
"description": "memo1 description",
"done": false
}
ディレクトリ・ファイル構造の説明
プロジェクトの物理的なディレクトリ・ファイル構造は下記のようになっています。
プロジェクトディレクトリは、プロジェクト用のpom.xmlと各モジュールのディレクトリを格納するだけの単純な構造です。プロジェクトをgitで管理する場合は.gitリポジトリディレクトリもここに存在することになります。
各モジュールのディレクトリは通常のmavenプロジェクトの構造になっています。
/mmsbs
|
+--- /.git
+--- pom.xml
+--- README.md
|
+--- /application <---(1)
| |
| +--- pom.xml
| |
| +--- /src
| | |
| | +--- /main
| | | |
| | | +--- /java
| | | | |
| | | | +--- /com.example
| | | | |
| | | | +--- Application.java <---(2) アプリケーションのエントリポイント
| | | | +--- WebMvcConfigure.java
| | | | |
| | | | +--- /application
| | | | |
| | | | +--- /config <---(4)
| | | | +--- /controller <---(5)
| | | | +--- /interceptor
| | | | +--- /vo <---(6)
| | | |
| | | +--- /resources
| | | |
| | | +--- application.yml <---(3) アプリケーションの設定ファイル
| | | +--- logback-spring.xml
| | | +--- messages.properties
| | |
| | +--- /test
| | |
| | +--- /java
| | | |
| | | +--- /com.example
| | | |
| | | +--- /application
| | | |
| | | +--- /controller <---(7,8)
| | | +--- /vo <---(9)
| | |
| | +--- /resources
| |
| +--- /target
| |
| +--- mmsbs.jar <---(10) executable jar
|
+--- /domain <---(11)
| |
| +--- pom.xml
| |
| +--- /src
| | |
| | +--- /main
| | | |
| | | +--- /java
| | | |
| | | +--- /com.example.domain
| | | |
| | | +--- /config <---(12)
| | | +--- /datasource <---(13)
| | | +--- /entity <---(14)
| | | +--- /repository <---(15)
| | | +--- /service <---(16)
| | | |
| | | +--- /impl <---(16)
| | +--- /test
| | |
| | +--- /java
| | | |
| | | +--- /com.example.domain
| | | |
| | | +--- TestApplication.java <---(17) for testing
| | | |
| | | +--- /repository <---(19,20)
| | | +--- /service <---(21,22)
| | |
| | +--- /resources
| | |
| | +--- application.yml <---(18) for testing
| |
| +--- /target
| |
| +--- domain-0.0.1-SNAPSHOT.jar
|
+--- /common <---(23)
|
+--- pom.xml
|
+--- /src
| |
| +--- /main
| | |
| | +--- /java
| | |
| | +--- /com.example.common
| | |
| | +--- /confing <---(24)
| | +--- /util <---(25)
| +--- /test
| |
| +--- /java
| | |
| | +--- /com.example.common
| | |
| | +--- /util <---(26)
| |
| +--- /resources
|
+--- /target
|
+--- common-0.0.1-SNAPSHOT.jar
モジュールの説明
1. applicationモジュール
2. アプリケーションのエントリポイントクラス
multi module構成での特別な実装はなく、ごく普通のSpring Bootアプリケーションのエントリポイントクラスです。
package com.example;
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);
}
}
3. application.ymlファイル
Spring Bootの設定値に加えて、各モジュール固有の設定値も記述しています。
モジュールの独立性を考えるとモジュール固有の設定値はモジュールに配置した方がいいと思いますが、簡便性を優先してapplication.ymlにまとめています。なおサンプルなので設定値に特に意味はありません。
spring:
datasource:
url: jdbc:mysql://localhost:3306/sample_db
username: test_user
password: test_user
driverClassName: com.mysql.jdbc.Driver
tomcat:
maxActive: 4
maxIdle: 4
minIdle: 0
initialSize: 4
jpa:
properties:
hibernate:
# show_sql: true
# format_sql: true
# use_sql_comments: true
# generate_statistics: true
jackson:
serialization:
write-dates-as-timestamps: false
server:
port: 8080
logging:
level:
root: INFO
org.springframework: INFO
# application settings
custom:
application:
key1: app_a
key2: app_b
key3: ajToeoe04jtmtU
domain:
key1: domain_c
key2: domain_d
common:
key1: common_e
key2: common_f
datePattern: yyyy-MM-dd
4. applicationモジュールの設定クラス
applicationモジュールに実装するクラスが参照するコンフィグ情報を保持するクラスという想定です。設定値はapplication.ymlファイルから読み取ります。
package com.example.application.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
@ConfigurationProperties(prefix = "custom.application")
@Data
@Slf4j
public class AppConfigure {
private String key1;
private String key2;
//private String key3;
@PostConstruct
public void init() {
log.info("AppConfigure init : {}", this);
}
}
5. テスト対象のコントローラークラス
Memoテーブルのデータを(ビューオブジェクトに加工して)レスポンスするだけの簡単なAPIです。
コントローラーはdomainモジュールで実装しているMemoServiceと、applicationモジュールのAppConfigureクラスに依存しています。
ちなみに、id2というハンドラメソッドは、idというハンドラメソッドの戻り値を変えた(ResponseEntityを使わない)バージョンです。
package com.example.application.controller;
import com.example.application.config.AppConfigure;
import com.example.application.vo.MemoView;
import com.example.domain.entity.Memo;
import com.example.domain.service.MemoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.annotation.PostConstruct;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping(path = "memo", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@Slf4j
public class MemoController {
@Autowired
private MemoService service;
@Autowired
private AppConfigure config;
@Value("${custom.application.key3}")
private String key3;
@PostConstruct
public void init() {
log.info("MemoController init : config.key1:{}, config.key2:{}, key3:{}", config.getKey1(), config.getKey2(), key3);
}
@GetMapping(path = "id/{id}")
public ResponseEntity<MemoView> id(@PathVariable(value = "id") Long id) {
log.info("id - id:{}, config.key1:{}, config.key2:{}, key3:{}", id, config.getKey1(), config.getKey2(), key3);
Memo memo = service.findById(id);
return new ResponseEntity<>(convert(memo), HttpStatus.OK);
}
// ResponseEntityを使用しないパターン
@GetMapping(path = "id2/{id}")
@ResponseBody
public MemoView id2(@PathVariable(value = "id") Long id) {
log.info("id2 - id:{}, config.key1:{}, config.key2:{}, key3:{}", id, config.getKey1(), config.getKey2(), key3);
Memo memo = service.findById(id);
return convert(memo);
}
@GetMapping(path = "title/{title}")
public ResponseEntity<List<MemoView>> title(@PathVariable(value = "title") String title, Pageable page) {
Page<Memo> memos = service.findByTitle(title, page);
return new ResponseEntity<>(convert(memos.getContent()), HttpStatus.OK);
}
private MemoView convert(final Memo memo) {
return MemoView.from(memo);
}
private List<MemoView> convert(final List<Memo> memos) {
return memos.stream()
.map(MemoView::from)
.collect(Collectors.toList());
}
}
6. ビューオブジェクトクラス
このクラスはクライアントへレスポンスする情報を持つビューオブジェクトという想定です。
このアプリケーションではエンティティをそのまま返さず一旦ビューオブジェクトへ変換するようにしています。
package com.example.application.vo;
import com.example.domain.entity.Memo;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Value;
import java.io.Serializable;
@Value
@Builder
public class MemoView implements Serializable {
private static final long serialVersionUID = -6945394718471482993L;
private String title;
private String description;
@JsonProperty("completed")
private Boolean done;
public static MemoView from(final Memo memo) {
return MemoView.builder()
.title(memo.getTitle())
.description(memo.getDescription())
.done(memo.getDone())
.build();
}
}
7. コントローラーの単体テスト(Unit Test)
このコントローラーの単体テストを行う場合、このような実装になるというサンプルです。
コントローラーが依存するクラスはMockBeanアノテーションを付与してモック化します。また、テスト実行時はデータベースへ接続しません。
package com.example.application.controller;
import com.example.application.config.AppConfigure;
import com.example.application.vo.MemoView;
import com.example.domain.entity.Memo;
import com.example.domain.service.MemoService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.Charset;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.collection.IsCollectionWithSize.hasSize;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.log;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@RunWith(SpringRunner.class)
@WebMvcTest(value = MemoController.class, secure = false)
public class MemoControllerTests {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private MemoService service;
@MockBean
private AppConfigure config;
private MediaType contentType = new MediaType(MediaType.APPLICATION_JSON.getType(),
MediaType.APPLICATION_JSON.getSubtype(), Charset.forName("utf8"));
@Before
public void setup() {
Mockito.when(config.getKey1()).thenReturn("TEST_APP_VALUEA");
Mockito.when(config.getKey2()).thenReturn("TEST_APP_VALUEB");
}
@Test
public void test_id() throws Exception {
Long id = 1L;
LocalDateTime updated = LocalDateTime.of(2017, 9, 20, 13, 14, 15);
Memo expected = Memo.builder().id(id).title("memo").description("memo description").done(false).updated(updated).build();
Mockito.when(service.findById(Mockito.anyLong())).thenReturn(expected);
RequestBuilder builder = MockMvcRequestBuilders
.get("/memo/id/{id}", id)
.accept(MediaType.APPLICATION_JSON_UTF8);
MvcResult result = mockMvc.perform(builder)
.andExpect(status().isOk())
.andExpect(content().contentType(contentType))
.andExpect(jsonPath("$").isNotEmpty())
.andExpect(jsonPath("$.title").value(expected.getTitle()))
.andExpect(jsonPath("$.description").value(expected.getDescription()))
.andExpect(jsonPath("$.completed").value(expected.getDone()))
.andDo(print())
.andReturn();
}
@Test
public void test_id2() throws Exception {
Long id = 1L;
LocalDateTime updated = LocalDateTime.of(2017, 9, 20, 13, 14, 15);
Memo expected = Memo.builder().id(id).title("memo").description("memo description").done(false).updated(updated).build();
Mockito.when(service.findById(Mockito.anyLong())).thenReturn(expected);
RequestBuilder builder = MockMvcRequestBuilders
.get("/memo/id2/{id}", id)
.accept(MediaType.APPLICATION_JSON_UTF8);
MvcResult result = mockMvc.perform(builder)
.andExpect(status().isOk())
.andExpect(content().contentType(contentType))
.andDo(print())
.andReturn();
MemoView actual = objectMapper.readValue(result.getResponse().getContentAsString(), MemoView.class);
assertThat(actual)
.extracting("title", "description", "done")
.contains(expected.getTitle(), expected.getDescription(), expected.getDone());
}
@Test
public void test_title() throws Exception {
Memo m1 = Memo.builder().id(1L).title("memo1 job").description("memo1 description").done(false).updated(LocalDateTime.now()).build();
Memo m2 = Memo.builder().id(2L).title("memo2 job").description("memo2 description").done(false).updated(LocalDateTime.now()).build();
Memo m3 = Memo.builder().id(3L).title("memo3 job").description("memo3 description").done(false).updated(LocalDateTime.now()).build();
List<Memo> memos = Arrays.asList(m1, m2, m3);
Page<Memo> expected = new PageImpl<>(memos);
Mockito.when(service.findByTitle(Mockito.anyString(), Mockito.any(Pageable.class))).thenReturn(expected);
RequestBuilder builder = MockMvcRequestBuilders
.get("/memo/title/{title}", "job")
.param("page","1")
.param("size", "3")
.accept(MediaType.APPLICATION_JSON_UTF8);
MvcResult result = mockMvc.perform(builder)
.andExpect(status().isOk())
.andExpect(content().contentType(contentType))
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$", hasSize(3)))
.andDo(log())
.andDo(print())
.andReturn();
}
}
AppConfigureのインジェクションの方法
MemoControllerはAppConfigureクラスに依存しているので単体テスト実行時にインスタンスを、モック化またはインジェクションする必要があります。
上記の方法ではMockBeanを使用していますが、これ以外にImportとTestPropertySourceを使う方法があります。
@RunWith(SpringRunner.class)
@WebMvcTest(value = MemoController.class, secure = false)
@Import(AppConfigure.class)
@TestPropertySource(properties = {
"custom.application.key1=TEST_APP_VALUEA",
"custom.application.key2=TEST_APP_VALUEB"
})
public class MemoControllerTests {
//... 省略
}
8. コントローラーの結合テスト(Integration Test)
このコントローラーの結合テストを行う場合、このような実装になるというサンプルです。
コントローラーが依存するクラスのモック化は行いませんので、テスト実行時はデータベースへ接続します。
このテストコードでは予めテストデータを、接続するデータベースのテーブルに格納しているという前提になっています。
Rest APIのコールにはTestRestTemplateを使用します。
package com.example.application.controller;
import com.example.application.vo.MemoView;
import org.assertj.core.groups.Tuple;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;
import java.net.URI;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MemoControllerJoinTests {
@Autowired
private TestRestTemplate restTemplate;
private MediaType contentType = new MediaType(MediaType.APPLICATION_JSON.getType(),
MediaType.APPLICATION_JSON.getSubtype(), Charset.forName("utf8"));
@Test
public void test_id() {
MemoView expected = MemoView.builder().title("memo shopping").description("memo1 description").done(false).build();
Map<String, Object> params = new HashMap<>();
params.put("id", 1L);
ResponseEntity<MemoView> actual = restTemplate.getForEntity("/memo/id/{id}", MemoView.class, params);
assertThat(actual.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(actual.getHeaders().getContentType()).isEqualTo(contentType);
assertThat(actual.getBody()).isEqualTo(expected);
}
@Test
public void test_id2() {
MemoView expected = MemoView.builder().title("memo shopping").description("memo1 description").done(false).build();
Map<String, Object> params = new HashMap<>();
params.put("id", 1L);
MemoView actual = restTemplate.getForObject("/memo/id2/{id}", MemoView.class, params);
assertThat(actual).isEqualTo(expected);
}
@Test
public void test_title() {
RequestEntity requestEntity = RequestEntity.get(URI.create("/memo/title/job?page=1&size=3&sort=id,desc")).build();
ResponseEntity<List<MemoView>> actual = restTemplate.exchange(requestEntity,
new ParameterizedTypeReference<List<MemoView>>(){});
assertThat(actual.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(actual.getHeaders().getContentType()).isEqualTo(contentType);
assertThat(actual.getBody())
.extracting("title", "description", "done")
.containsExactly(
Tuple.tuple("memo job", "memo4 description", false),
Tuple.tuple("memo job", "memo2 description", false)
);
}
}
9. ビューオブジェクトの単体テスト(Unit Test)
ビューオブジェクトの単体テストを行う場合、このような実装になるというサンプルです。
ビューオブジェクトが期待するJSONオブジェクトに変換できることをテストします。
package com.example.application.vo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.json.JsonContent;
import org.springframework.boot.test.json.ObjectContent;
import org.springframework.test.context.junit4.SpringRunner;
import java.io.IOException;
@RunWith(SpringRunner.class)
@JsonTest
public class MemoViewTests {
@Autowired
private JacksonTester<MemoView> json;
@Test
public void test_serialize() throws IOException {
String expected = "{\"title\":\"memo\",\"description\":\"memo description\",\"completed\":false}";
MemoView memoView = MemoView.builder().title("memo").description("memo description").done(false).build();
JsonContent<MemoView> actual = json.write(memoView);
actual.assertThat().isEqualTo(expected);
actual.assertThat().hasJsonPathStringValue("$.title");
actual.assertThat().extractingJsonPathStringValue("$.title").isEqualTo("memo");
actual.assertThat().hasJsonPathStringValue("$.description");
actual.assertThat().extractingJsonPathStringValue("$.description").isEqualTo("memo description");
actual.assertThat().hasJsonPathBooleanValue("$.completed");
actual.assertThat().extractingJsonPathBooleanValue("$.completed").isEqualTo(false);
}
@Test
public void test_deserialize() throws IOException {
MemoView expected = MemoView.builder().title("memo").description("memo description").done(false).build();
String content = "{\"title\":\"memo\",\"description\":\"memo description\",\"completed\":false}";
ObjectContent<MemoView> actual = json.parse(content);
actual.assertThat().isEqualTo(expected);
}
}
10. executable jar
ビルドが成功するとtargetディレクトリ下にmmsbs.jar
というファイルが生成されます。
11. domainモジュール
12. domainモジュールの設定クラス
domainモジュールに実装するクラスが参照するコンフィグ情報を保持するクラスを想定しています。設定値はapplication.ymlファイルから読み取ります。
package com.example.domain.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
@ConfigurationProperties(prefix = "custom.domain")
@Data
@Slf4j
public class DomainConfigure {
private String key1;
private String key2;
@PostConstruct
public void init() {
log.info("DomainConfigure init : {}", this);
}
}
13. データソースの設定クラス
domainモジュールではデータアクセス処理を担当するのでデータソースを設定するクラスがあります。データソースの設定値はapplication.ymlを参照します。
package com.example.domain.datasource;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = {"com.example.domain.repository"}
)
public class DataSourceConfigure {
@ConfigurationProperties(prefix = "spring.datasource")
@Bean
public DataSource datasource() {
DataSource dataSource = DataSourceBuilder.create().build();
return dataSource;
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder) {
LocalContainerEntityManagerFactoryBean factory = builder
.dataSource(datasource())
.persistenceUnit("default")
.packages("com.example.domain.entity")
.build();
return factory;
}
@Bean
public PlatformTransactionManager transactionManager(
EntityManagerFactory entityManagerFactory) {
JpaTransactionManager tm = new JpaTransactionManager();
tm.setEntityManagerFactory(entityManagerFactory);
tm.afterPropertiesSet();
return tm;
}
}
14. エンティティクラス
package com.example.domain.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name="memo")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Memo implements Serializable {
private static final long serialVersionUID = -7888970423872473471L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="title", nullable = false)
private String title;
@Column(name="description", nullable = false)
private String description;
@Column(name="done", nullable = false)
private Boolean done;
@Column(name="updated", nullable = false)
private LocalDateTime updated;
public static Memo of(String title, String description) {
return Memo.builder()
.title(title)
.description(description)
.done(false)
.updated(LocalDateTime.now())
.build();
}
@PrePersist
private void prePersist() {
done = false;
updated = LocalDateTime.now();
}
@PreUpdate
private void preUpdate() {
updated = LocalDateTime.now();
}
}
テーブルスキーマ
CREATE TABLE IF NOT EXISTS memo (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE NOT NULL,
updated TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) NOT NULL,
PRIMARY KEY (id)
)
ENGINE = INNODB,
CHARACTER SET = utf8mb4,
COLLATE utf8mb4_general_ci;
テストデータ
INSERT INTO memo (id, title, description, done, updated) VALUES
(1, 'memo shopping', 'memo1 description', false, '2017-09-20 12:01:00.123'),
(2, 'memo job', 'memo2 description', false, '2017-09-20 13:02:10.345'),
(3, 'memo private', 'memo3 description', false, '2017-09-20 14:03:21.567'),
(4, 'memo job', 'memo4 description', false, '2017-09-20 15:04:32.789'),
(5, 'memo private', 'memo5 description', false, '2017-09-20 16:05:43.901'),
(6, 'memo travel', 'memo6 description', false, '2017-09-20 17:06:54.234'),
(7, 'memo travel', 'memo7 description', false, '2017-09-20 18:07:05.456'),
(8, 'memo shopping', 'memo8 description', false, '2017-09-20 19:08:16.678'),
(9, 'memo private', 'memo9 description', false, '2017-09-20 20:09:27.890'),
(10,'memo hospital', 'memoA description', false, '2017-09-20 21:10:38.012')
;
15. テスト対象のリポジトリクラス
package com.example.domain.repository;
import com.example.domain.entity.Memo;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemoRepository extends JpaRepository<Memo, Long> {
Page<Memo> findByTitleLike(String title, Pageable page);
}
16. テスト対象のサービスの実装クラス
インターフェース
package com.example.domain.service;
import com.example.domain.entity.Memo;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.LocalDate;
public interface MemoService {
Memo findById(Long id);
Page<Memo> findByTitle(String title, Pageable page);
Memo registerWeatherMemo(LocalDate date);
}
実装クラス
このサービスはcommonモジュールに実装しているWeatherForecastというクラスに依存しています。サンプルなのでサービスの実装内容には特に意味はありません。
package com.example.domain.service.impl;
import com.example.common.util.WeatherForecast;
import com.example.domain.config.DomainConfigure;
import com.example.domain.entity.Memo;
import com.example.domain.repository.MemoRepository;
import com.example.domain.service.MemoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
@Service
@Slf4j
public class MemoServiceImpl implements MemoService {
@Autowired
private DomainConfigure config;
@Autowired
private MemoRepository memoRepository;
@Autowired
private WeatherForecast weatherForecast;
@Transactional(readOnly = true)
@Override
public Memo findById(Long id) {
log.info("findById - id:{}, config.key1:{}, config.key2:{}", id, config.getKey1(), config.getKey2());
return memoRepository.findOne(id);
}
@Transactional(readOnly = true)
@Override
public Page<Memo> findByTitle(String title, Pageable page) {
log.info("findByTitle - title:{}, page:{}, config.key1:{}, config.key2:{}", title, page, config.getKey1(), config.getKey2());
return memoRepository.findByTitleLike(String.join("","%", title, "%"), page);
}
@Transactional(timeout = 10)
@Override
public Memo registerWeatherMemo(LocalDate date) {
log.info("registerWeatherMemo - date:{}", date);
String title = "weather memo : [" + weatherForecast.getReportDayStringValue(date) + "]";
String description = weatherForecast.report(date);
Memo memo = Memo.builder().title(title).description(description).build();
return memoRepository.saveAndFlush(memo);
}
}
17. テスト環境のアプリケーションのエントリポイントクラス
domainモジュールでテストを行う際に必要となるテスト環境用のエントリポイントクラスです。
一部の実装クラスがcommonモジュールに依存しているのでscanBasePackagesにcommonモジュールのパッケージを追加します。
package com.example.domain;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {
"com.example.domain",
"com.example.common"
})
public class TestApplication {
public static void main(String... args) {
SpringApplication.run(TestApplication.class, args);
}
}
18. テスト環境のapplication.ymlファイル
テスト環境で有効になる設定情報を持つapplication.ymlです。
データベース接続が必要なテストケースがあるのでデータソースの設定も行います。
spring:
datasource:
url: jdbc:mysql://localhost:3306/sample_db
username: test_user
password: test_user
driverClassName: com.mysql.jdbc.Driver
jpa:
properties:
hibernate:
show_sql: true
custom:
domain:
key1: test_domain_c
key2: test_domain_d
19. リポジトリの単体テスト(Unit Test)
リポジトリの単体テストを行う場合、このような実装になるというサンプルです。
テストクラスにDataJpaTestアノテーションを付けることで、実行時にインメモリのH2を使用するようになります。
package com.example.domain.repository;
import com.example.domain.entity.Memo;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.transaction.AfterTransaction;
import org.springframework.test.context.transaction.BeforeTransaction;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@DataJpaTest
public class MemoRepositoryTests {
@Autowired
private TestEntityManager entityManager;
@Autowired
private MemoRepository sut;
@BeforeTransaction
public void init() {
// トランザクション開始前に実行されます
System.out.println("1. init");
}
@Before
public void setUp() {
// トランザクション開始後、テストメソッド開始前に実行されます
System.out.println("2. setUp");
}
@After
public void tearDown() {
// テストメソッド終了後、トランザクション終了前に実行されます
System.out.println("3. tearDown");
}
@AfterTransaction
public void clear() {
// トランザクション終了後に実行されます
System.out.println("4. clear");
}
@Test
@Sql(statements = {
"INSERT INTO memo (id, title, description, done, updated) VALUES (99999, 'memo test', 'memo description', FALSE, CURRENT_TIMESTAMP)"
})
public void test_findOne() {
Memo expected = entityManager.find(Memo.class, 99999L);
Memo actual = sut.findOne(expected.getId());
assertThat(actual).isEqualTo(expected);
}
@Test
public void test_save() {
Memo expected = Memo.builder().title("memo").description("memo description").build();
sut.saveAndFlush(expected);
entityManager.clear();
Memo actual = entityManager.find(Memo.class, expected.getId());
assertThat(actual).isEqualTo(expected);
}
@Test
public void test_findByTitleLike() {
Memo m1 = Memo.builder().title("memo shopping").description("memo1 description").done(false).updated(LocalDateTime.now()).build();
entityManager.persistAndFlush(m1);
Memo m2 = Memo.builder().title("memo job").description("memo2 description").done(false).updated(LocalDateTime.now()).build();
entityManager.persistAndFlush(m2);
Memo m3 = Memo.builder().title("memo private").description("memo3 description").done(false).updated(LocalDateTime.now()).build();
entityManager.persistAndFlush(m3);
Memo m4 = Memo.builder().title("memo job").description("memo4 description").done(false).updated(LocalDateTime.now()).build();
entityManager.persistAndFlush(m4);
Memo m5 = Memo.builder().title("memo private").description("memo5 description").done(false).updated(LocalDateTime.now()).build();
entityManager.persistAndFlush(m5);
entityManager.clear();
List<Memo> expected = Arrays.asList(m4, m2);
Pageable page = new PageRequest(0, 3, Sort.Direction.DESC, "id");
Page<Memo> actual = sut.findByTitleLike("%job%", page);
assertThat(actual.getContent()).isEqualTo(expected);
}
}
20. リポジトリの結合テスト(Integration Test)
リポジトリの結合テストを行う場合、このような実装になるというサンプルです。
リポジトリの結合テストを行うことはあまりないかと思いますが、事情により外部のデータベース(MySQLやPostgreSQL)に接続したいという場合はこのような実装になるとおもいます。
package com.example.domain.repository;
import com.example.domain.entity.Memo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest
public class MemoRepositoryJoinTests {
@Autowired
private EntityManager entityManager;
@Autowired
private MemoRepository sut;
@Transactional
@Test
@Sql(statements = {
"INSERT INTO memo (id, title, description, done) VALUES (99999, 'memo test', 'memo description', TRUE)"
})
public void test_findOne() {
Memo expected = entityManager.find(Memo.class, 99999L);
Memo actual = sut.findOne(expected.getId());
assertThat(actual).isEqualTo(expected);
}
@Transactional
@Test
public void test_save() {
Memo expected = Memo.builder().title("memo").description("memo description").build();
sut.saveAndFlush(expected);
entityManager.clear();
Memo actual = entityManager.find(Memo.class, expected.getId());
assertThat(actual).isEqualTo(expected);
}
@Transactional
@Test
public void test_findByTitleLike() {
Memo m1 = entityManager.find(Memo.class, 2L);
Memo m2 = entityManager.find(Memo.class, 4L);
List<Memo> expected = Arrays.asList(m2, m1);
Pageable page = new PageRequest(0, 3, Sort.Direction.DESC, "id");
Page<Memo> actual = sut.findByTitleLike("%job%", page);
assertThat(actual.getContent()).isEqualTo(expected);
}
}
[Sql] (https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/jdbc/Sql.html)アノテーション
テストクラスかテストメソッドに付けることができます。sqlスクリプトファイルまたはsql文を実行してテストデータを準備することができます。
クラスとメソッドの両方にSqlアノテーションを付加した場合、メソッドレベルの設定が有効になるので注意が必要です。(クラスレベルで共通データ、メソッドレベルでメソッド固有のデータ(差分)を投入するといった使い方はできません)
21. サービスの単体テスト(Unit Test)
サービスの単体テストを行う場合、このような実装になるというサンプルです。
依存クラスはモック化します。このサンプルではSpringのコンテナ機能は使用せず、JUnitとMockito、AssertJを使っています。
package com.example.domain.service;
import com.example.common.config.CommonConfigure;
import com.example.common.util.WeatherForecast;
import com.example.domain.config.DomainConfigure;
import com.example.domain.entity.Memo;
import com.example.domain.repository.MemoRepository;
import com.example.domain.service.impl.MemoServiceImpl;
import org.junit.Before;
import org.junit.Test;
import org.mockito.*;
import org.mockito.internal.util.reflection.Whitebox;
import org.springframework.data.domain.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
public class MemoServiceTests {
@Mock
private MemoRepository repository;
@Spy
private WeatherForecast weatherForecast;
@InjectMocks
private MemoServiceImpl sut;
@Before
public void setup(){
MockitoAnnotations.initMocks(this);
DomainConfigure config = new DomainConfigure();
config.setKey1("bean_domain_c");
config.setKey2("bean_domain_d");
Whitebox.setInternalState(sut, "config", config);
CommonConfigure commonConfigure = new CommonConfigure();
commonConfigure.setDatePattern("yyyy-MM-dd");
Whitebox.setInternalState(weatherForecast, "config", commonConfigure);
}
@Test
public void test_findById() {
Memo expected = Memo.builder().id(1L).title("memo").description("memo description").done(false).updated(LocalDateTime.now()).build();
Mockito.when(repository.findOne(Mockito.anyLong())).thenReturn(expected);
Memo actual = sut.findById(expected.getId());
assertThat(actual).isEqualTo(expected);
}
@Test
public void test_findByTitle() {
Memo m1 = Memo.builder().id(2L).title("memo job").description("memo2 description").done(false).updated(LocalDateTime.now()).build();
Memo m2 = Memo.builder().id(4L).title("memo job").description("memo4 description").done(false).updated(LocalDateTime.now()).build();
List<Memo> memos = Arrays.asList(m2, m1);
Page<Memo> expected = new PageImpl<>(memos);
String title = "job";
Pageable page = new PageRequest(0,3, Sort.Direction.DESC, "id");
Mockito.when(repository.findByTitleLike(eq("%" + title + "%"), eq(page))).thenReturn(expected);
Page<Memo> actual = sut.findByTitle(title, page);
assertThat(actual.getContent()).isEqualTo(expected.getContent());
}
@Test
public void test_registerWeatherMemo() {
LocalDate date = LocalDate.of(2017, 9, 20);
Mockito.when(weatherForecast.report(date)).thenReturn("weather forecast : test-test-test-2017-09-20");
Memo expected = Memo.builder().id(1L).title("weather memo :").description("weather forecast : sunny").done(false).updated(LocalDateTime.now()).build();
Mockito.when(repository.saveAndFlush(any(Memo.class))).thenReturn(expected);
Memo actual = sut.registerWeatherMemo(date);
assertThat(actual).isEqualTo(expected);
}
}
DomainConfigureのモック化は上記のWhitebox.setInternalStateを使う方法の他に、ReflectionTestUtilsを使う方法があります。
@Before
public void setup(){
MockitoAnnotations.initMocks(this);
DomainConfigure config = new DomainConfigure();
config.setKey1("bean_domain_c");
config.setKey2("bean_domain_d");
ReflectionTestUtils.setField(sut, "config", config);
}
22. サービスの結合テスト(Integration Test)
サービスの結合テストを行う場合、このような実装になるというサンプルです。
テストするサービスが依存するクラスはモック化(一部SpyBeanしてます)しませんので、データベース接続も行います。
また、このサービスが依存するcommonモジュールのWeatherForecastクラスが参照しているプロパティ値はTestPropertySourceを使って定義する必要があります。
package com.example.domain.service;
import com.example.common.util.WeatherForecast;
import com.example.domain.TestApplication;
import com.example.domain.datasource.DataSourceConfigure;
import com.example.domain.entity.Memo;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = {
TestApplication.class, DataSourceConfigure.class})
@TestPropertySource(properties = {
"custom.common.datePattern=yyyy-MM-dd"
})
public class MemoServiceJoinTests {
@Autowired
private EntityManager entityManager;
@Autowired
private MemoService sut;
@SpyBean
private WeatherForecast weatherForecast;
@Transactional
@Test
public void test_findById() {
Long id = 1L;
Memo expected = entityManager.find(Memo.class, id);
Memo actual = sut.findById(id);
assertThat(actual).isEqualTo(expected);
}
@Transactional
@Test
public void test_findByTitle() {
Memo m1 = entityManager.find(Memo.class, 2L);
Memo m2 = entityManager.find(Memo.class, 4L);
List<Memo> expected = Arrays.asList(m2, m1);
Pageable page = new PageRequest(0,3, Sort.Direction.DESC, "id");
Page<Memo> actual = sut.findByTitle("job", page);
assertThat(actual.getContent()).isEqualTo(expected);
}
@Transactional
@Test
public void test_registerWeatherMemo() {
LocalDate date = LocalDate.of(2017,9,20);
Mockito.when(weatherForecast.report(date)).thenReturn("weather forecast : test-test-test");
Memo actual = sut.registerWeatherMemo(date);
assertThat(actual.getId()).isNotNull();
assertThat(actual.getTitle()).isEqualTo("weather memo : [2017-09-20]");
assertThat(actual.getDescription()).isEqualTo("weather forecast : test-test-test");
}
}
23. commonモジュール
24. commonモジュールの設定クラス
commonモジュールに実装するクラスが参照するコンフィグ情報を保持するクラスを想定しています。設定値はapplication.ymlファイルから読み取ります。
package com.example.common.config;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
@ConfigurationProperties(prefix = "custom.common")
@Data
@Slf4j
public class CommonConfigure {
private String key1;
private String key2;
private String datePattern;
@PostConstruct
public void init() {
log.info("CommonConfigure init : {}", this);
}
}
25. テスト対象のユーティリティクラス
このユーティリティクラスは、モジュール間の依存関係を発生させるための実装なので内容に特に意味はありません。一応、外部webサービスを呼び出して天気予報を行うユーティリティクラスという想定です。
package com.example.common.util;
import com.example.common.config.CommonConfigure;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Component
@Slf4j
public class WeatherForecast {
@Autowired
private CommonConfigure config;
public String getReportDayStringValue(LocalDate reportDay) {
log.debug("getReportDayStringValue - reportDay:{}, config:{}", reportDay, config);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(config.getDatePattern());
return reportDay.format(formatter);
}
public String report(LocalDate reportDay) {
log.debug("report - reportDay:{}", reportDay);
String weather = "weather forecast : " + callForecastApi(reportDay);
return weather;
}
String callForecastApi(LocalDate date) {
// call External Weather API
String apiResult = UUID.randomUUID().toString();
String dateStr = date.toString();
return apiResult + "-" + dateStr;
}
}
26. ユーティリティクラスの単体テスト(Unit Test)
このユーティリティクラスの単体テストを行う場合、このような実装になるというサンプルです。
package com.example.common.util;
import com.example.common.config.CommonConfigure;
import org.junit.Before;
import org.junit.Test;
import org.mockito.*;
import org.mockito.internal.util.reflection.Whitebox;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
public class WeatherForecastTests {
@Spy
@InjectMocks
private WeatherForecast sut;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
CommonConfigure config = new CommonConfigure();
config.setKey1("test_common_e");
config.setKey2("test_common_f");
config.setDatePattern("yyyy/MM/dd");
Whitebox.setInternalState(sut, "config", config);
}
@Test
public void test_getReportDayStringValue() {
LocalDate date = LocalDate.of(2017, 9, 20);
String actual = sut.getReportDayStringValue(date);
assertThat(actual).isEqualTo("2017/09/20");
}
@Test
public void test_report() {
LocalDate date = LocalDate.of(2017, 9, 20);
Mockito.when(sut.callForecastApi(date)).thenReturn("test-test-test");
String actual = sut.report(date);
assertThat(actual).isEqualTo("weather forecast : test-test-test");
}
}
補足
モジュールのpom.xmlのparent
この記事の例では、各モジュールのparentにはプロジェクトを指定しましたが、githubなどにあるmulti module構造のプロジェクトを見ると、各モジュールもparentにspring-boot-starter-parentを指定するものがありました。
Hibernateが返す日付の実装クラス
下記のようなDate型フィールドを含むエンティティのインスタンス同士を比較した際に、日付の文字列表現が異なるためにアサートが失敗することがあります。
これはHibernateが返すエンティティのDate型フィールドのインスタンスの実装クラスがjava.sql.Timestamp
型なためです。
@Column(name="updated", nullable = false)
@Temporal(TemporalType.TIMESTAMP)
private java.util.Date updated;
org.junit.ComparisonFailure:
Expected :Memo{id=2, title='title', description='desc', done=false, updated=Wed Sep 20 10:34:21 JST 2017}
Actual :Memo{id=2, title='title', description='desc', done=false, updated=2017-09-20 10:34:21.853}
テストコードで使用したアノテーションの種類
JUnit
annotation | package |
---|---|
RunWith | org.junit.runner.RunWith |
Test | org.junit.Test |
Before | org.junit.Before |
After | org.junit.After |
Spring
annotation | package |
---|---|
SpringBootTest | org.springframework.boot.test.context.SpringBootTest |
ContextConfiguration | org.springframework.test.context.ContextConfiguration |
TestPropertySource | org.springframework.test.context.TestPropertySource |
WebMvcTest | org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest |
DataJpaTest | org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest |
JsonTest | org.springframework.boot.test.autoconfigure.json.JsonTest |
MockBean | org.springframework.boot.test.mock.mockito.MockBean |
SpyBean | org.springframework.boot.test.mock.mockito.SpyBean |
BeforeTransaction | org.springframework.test.context.transaction.BeforeTransaction |
AfterTransaction | org.springframework.test.context.transaction.AfterTransaction |
Sql | org.springframework.test.context.jdbc.Sql |
MockBean,SpyBeanは、モック化、スパイ化したオブジェクトをAutowiredさせるときに使用します。
Mockito
annotation | package |
---|---|
InjectMocks | org.mockito.InjectMocks |
Mock | org.mockito.Mock |
Spy | org.mockito.Spy |