Help us understand the problem. What is going on with this article?

Spring Bootアプリケーションをmavenのmulti moduleで構成する

More than 1 year has passed since last update.

概要

Spring Bootを用いたRest APIアプリケーションをmavenのmulti module構成のプロジェクトで作る時の説明記事になります。
記事の前半はmulti moduleプロジェクトの論理的(pomの説明)、物理的(ディレクトリ・ファイル構造の説明)な構造の説明で、後半は各モジュールの特徴についての補足になります。

環境

  • Windows10 Professional
  • Java 1.8.0_144
  • Spring Boot 1.5.6
  • Maven 3

参考

プロジェクト

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には各モジュールで必要になる依存関係を定義します。ここで定義したライブラリはすべてのモジュールから利用できます
pom.xml
<?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に記述します
pom.xml
<?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モジュールで必要になる依存関係を定義します
pom.xml
<?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モジュールで必要になる依存関係を定義しますが、いまのところ必要なものはないので未指定です
pom.xml
<?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アプリケーションのエントリポイントクラスです。

Application
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にまとめています。なおサンプルなので設定値に特に意味はありません。

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ファイルから読み取ります。

AppConfigure
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を使わない)バージョンです。

MemoController
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. ビューオブジェクトクラス

このクラスはクライアントへレスポンスする情報を持つビューオブジェクトという想定です。
このアプリケーションではエンティティをそのまま返さず一旦ビューオブジェクトへ変換するようにしています。

MemoView
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アノテーションを付与してモック化します。また、テスト実行時はデータベースへ接続しません。

MemoControllerTests
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を使う方法があります。

MemoControllerTests
@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を使用します。

MemoControllerJoinTests
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オブジェクトに変換できることをテストします。

MemoViewTests
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ファイルから読み取ります。

DomainConfigure
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を参照します。

DataSourceConfigure
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. エンティティクラス

Memo
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. テスト対象のリポジトリクラス

MemoRepository
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. テスト対象のサービスの実装クラス

インターフェース

MemoService
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というクラスに依存しています。サンプルなのでサービスの実装内容には特に意味はありません。

MemoServiceImpl
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モジュールのパッケージを追加します。

TestApplication
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です。
データベース接続が必要なテストケースがあるのでデータソースの設定も行います。

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を使用するようになります。

MemoRepositoryTests
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)に接続したいという場合はこのような実装になるとおもいます。

MemoRepositoryJoinTests
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アノテーション

テストクラスかテストメソッドに付けることができます。sqlスクリプトファイルまたはsql文を実行してテストデータを準備することができます。

クラスとメソッドの両方にSqlアノテーションを付加した場合、メソッドレベルの設定が有効になるので注意が必要です。(クラスレベルで共通データ、メソッドレベルでメソッド固有のデータ(差分)を投入するといった使い方はできません)

21. サービスの単体テスト(Unit Test)

サービスの単体テストを行う場合、このような実装になるというサンプルです。
依存クラスはモック化します。このサンプルではSpringのコンテナ機能は使用せず、JUnitとMockito、AssertJを使っています。

MemoServiceTests
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を使って定義する必要があります。

MemoServiceJoinTests
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ファイルから読み取ります。

CommonConfigure
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サービスを呼び出して天気予報を行うユーティリティクラスという想定です。

WeatherForecast
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)

このユーティリティクラスの単体テストを行う場合、このような実装になるというサンプルです。

WeatherForecastTests
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
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした