見出し画像

【初心者向け】SpringBootにおける単体テスト【基本事項編】

こんにちは!
食欲を通り越して暴飲暴食(お店で飲むお酒は格別!!!!!)の秋になりつつある、開発事業部のAKIRAです。

今回は、まだまだJavaワカラナイ状態ですが、プロジェクト先で書いているSpringBootのテストに関して自分なりにまとめてみました

コードのせいで思っていた以上にボリュームが出てしまいましたが、ある程度参考になると思います。

この記事を書いているALHが運営するもう一つのオウンドメディア!
こちらは技術を持つ「人」にフォーカスしています!


SpringBootにおける単体テストの基本事項

最近になってやっと単体テストの基本的な考え方・書き方がわかってきた(自分比)のでいったん自分なりにまとめようと思います。
以下の項目について簡単にまとめます。これらの項目を知っていれば、もちろん状況によって追加で調べることは必要ですが、特殊なケースを除いて単体テストの作成にはあまり困らないと思います。
以下のリンクにコードを残しておきます。
https://github.com/Shukupon/TestTemplate

  • クラスの単位

  • モック化

  • コントローラークラスのテスト

  • リポジトリクラスのテスト

前提条件

Java11
SpringBoot 2.5.6
Junit5
H2
MyBatis
REST API

クラスの単位

単体テストを作成する単位は基本的に1つのクラスに対して1つのテストクラスです。
基本的にというのは、メソッド内の処理が適用されるプロファイルの内容によって分岐するため、プロファイルの数だけテストクラスを作った方が便利な場合などがあり得るためです。
テストクラスはやろうと思えば、複数のクラスをまとめためちゃくちゃ大きなテストクラスだったり、メソッド一つしか入っていない極小のテストクラスだったりというように自由にその粒度を決めることができます。ただし、あるクラスに対応したテストクラスがどれなのかがわかりやすいように、できる限り1クラス1テストクラスとなるように作成する方が無難です。他人が作った粒度がめちゃくちゃなテストクラスなんて触りたくないですからね。

モック化

テスト対象のクラスが他のクラスに依存している場合にはモック化が避けて通れません。
モック化とは、テスト対象が呼び出している他クラスをテスト用にモックで差し替え、モックの動作内容を定義してあげることで、テスト条件を実現することができるようにすることです。
例えば以下のようなテスト対象のクラスがあるとします。

package com.example.demo.application.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.example.demo.application.repository.DemoRepository;
import com.example.demo.bean.Goods;

// 業務処理を行うクラス
@Service
public class DemoService {

    @Autowired
    DemoRepository demoRepository;

    //
    public boolean decide(Goods goods) {
        Goods ordinaryGoods = demoRepository.findByName(goods.getName());
        if (goods.getPrice() <= ordinaryGoods.getPrice())
            return true;
        return false;
    }
}

このクラスはServiceクラスに依存しているため、こいつをモック化してあげる必要があります。
そのため以下のような感じでモックを定義してあげます。(やり方は数種類あります)

package com.example.demo.application.service;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringExtension;

import com.example.demo.application.repository.DemoRepository;
import com.example.demo.bean.Goods;

@ExtendWith(SpringExtension.class)
public class DemoServiceTest {

    @TestConfiguration
    static class DemoUseCaseTestConfiguration {

        // テスト対象クラスのBean生成
        @Bean
        public DemoService demoService() {
            return new DemoService();
        }
    }

    // モックの作成
    @MockBean
    DemoRepository mockRepository;

    // テスト対象クラスのインスタンスにモックを注入
    @Autowired
    private DemoService target;

    // テストメソッド
    @Test
    public void decideTest01() {

        // 渡す引数の準備
        Goods goods = createGoods();

        // どんなGoods型の引数でもtrueを返すようにmockを定義
        when(mockRepository.findByName(Mockito.anyString())).thenReturn(createOrdinaryGoods());

        // assert
        assertTrue(target.decide(goods));
    }

    // Test用のデータ作成
    private Goods createGoods() {

        Goods goods = new Goods();
        goods.setName("avocado");
        goods.setPrice(180);
        return goods;
    }

    // モック用のデータ作成
    private Goods createOrdinaryGoods() {

        Goods goods = new Goods();
        goods.setName("avocado");
        goods.setPrice(200);
        return goods;
    }

}

これでcheckPriceメソッドがtrueの場合はdecideメソッドがtrueを返却することのテストができます。
※anyメソッドはorg.mockito.ArgumentMatchers.anyをimportしてください。
ちなみに
verify(mockService, times(1)).checkPrice(any(Goods.class))
というようにverifyメソッドを使ってあげると対象のモックの関数が呼び出された回数をassertしたり、

@Captor
ArgumentCaptor<Goods> goodsCaptor;

verify(mockService).checkPrice(goodsCaptor.capture());
assertEquals(180, goodsCaptor.getValue().getPrice());

上記のようにArgumentCaptorを利用してメソッドの引数をassertすることもできます。

コントローラークラスのテスト

コントローラークラスは他のクラスと比較して、リクエストを受け付けてレスポンスを返すという、アプリケーションの外部とやりとりを行うという点で少し特殊です。
このクラスのテストをする場合はMockMvcを使い、擬似的にリクエストを飛ばしてあげる必要があります。
以下がテスト対象のコントローラクラスです。

package com.example.demo.presentation;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.application.service.DemoService;
import com.example.demo.bean.Goods;

//RESTAPIのコントローラクラス
@RestController
@RequestMapping("/demo")
public class DemoController {

    @Autowired
    DemoService demoService;

    @PostMapping("/shopping")
    public ResponseEntity<Boolean> shopping(@RequestBody Goods goods) {

        Boolean response = demoService.decide(goods);

        HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.setContentType(MediaType.APPLICATION_JSON);
        return ResponseEntity.status(HttpStatus.OK).headers(responseHeaders).body(response);
    }
}

これに対応するテストクラスは以下のような感じになります。

package com.example.demo.presentation;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
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.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import com.example.demo.application.service.DemoService;
import com.example.demo.bean.Goods;
import com.fasterxml.jackson.databind.ObjectMapper;

@WebMvcTest(DemoController.class)
public class DemoControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    DemoService mockService;

    @Test
    public void shoppingTest01() throws Exception {

        // 使用する商品情報の準備
        Goods goods = createGoods();

        // リクエストの準備
        ObjectMapper objectMapper = new ObjectMapper();
        String requestJson = objectMapper.writeValueAsString(goods);

        // モックの設定
        when(mockService.decide(any(Goods.class))).thenReturn(true);

        // execute
        String responseJson = this.mockMvc
                .perform(post("/demo/shopping").contentType(MediaType.APPLICATION_JSON).content(requestJson))
                .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();

        Boolean result = objectMapper.readValue(responseJson, Boolean.class);
        verify(mockService, times(1)).decide(any(Goods.class));
        assertTrue(result);

    }

    private Goods createGoods() {

        // Test用のデータ作成
        Goods goods = new Goods();
        goods.setName("アボガド");
        goods.setPrice(180);
        return goods;
    }

}

リクエストの準備と実行部分がかなり特徴的ですね。
mockMvcのメソッドチェインでandExpectメソッドを使えば色々とassertすることが可能です。

この記事を書いているALHが運営するもう一つのオウンドメディア!
こちらは技術を持つ「人」にフォーカスしています!

リポジトリクラスのテスト

リポジトリといっても、今回はインタフェースをJavaで用意しMyBatisでxmlでSQLを実行する想定で書きます。

package com.example.demo.application.repository;

import org.apache.ibatis.annotations.Mapper;

import com.example.demo.bean.Goods;
//リポジトリのインターフェース
@Mapper
public interface DemoRepository {

    Goods findByName(String name);
}

リポジトリの実装クラスとしてのxmlファイルは以下です。今回はシンプルなSELECT文だけにしています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">


<mapper
    namespace="com.example.demo.application.repository.DemoRepository">

    <resultMap id="GoodsResultMap"
        type="com.example.demo.bean.Goods">
        <id property="name" column="name" />
        <result property="price" column="price" />
    </resultMap>


    <select id="findByName" parameterType="String"
        resultMap="GoodsResultMap">
    <![CDATA[
        SELECT
            name,
            price
        FROM
            goods
        WHERE
            name = #{name}
    ]]>
    </select>

</mapper>

こちらのSQLをテストするためのテストクラスが以下になります。

package com.example.demo.application.repository;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.samePropertyValuesAs;

import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;

import com.example.demo.bean.Goods;

@MybatisTest
public class DemoRepositoryTest {

    @Autowired
    private DemoRepository demoRepository;

    @Test
    public void findByNameTest() {
        Goods goods = createGoods();
        Goods result = demoRepository.findByName(goods.getName());
        assertThat(goods, samePropertyValuesAs(result));
    }

    private Goods createGoods() {

        // Test用のデータ作成
        Goods goods = new Goods();
        goods.setName("avocado");
        goods.setPrice(180);
        return goods;
    }
}

もちろんこのままでは動かないので、テスト用のDBを用意するため、src/test/java/resources配下にdata.sqlとschema.sqlを用意してあげます。
スキーマ定義

CREATE TABLE IF NOT EXISTS goods(
    name VARCHAR(30) NOT NULL PRIMARY KEY,
    price INT
);

初期データ定義

INSERT INTO goods(
    name,
    price
)VALUES(
    'avocado',
    180
);

application.propertiesにも設定しておく必要があります。

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.datasource.password=
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:resources/schema.sql
spring.sql.init.data-locations=classpath:resources/data.sql

まとめ

1から全て自分一人でコードを書くとなるとまだまだうろ覚えの部分がたくさんあると実感しました。
特にエラー内容を見ただけで解決できないような致命的なエラーに遭遇した時は発狂しました。何とか解決しましたが、、、
本当はもっと内容を詰めたかったのですが、想像以上にボリュームが大きくなってしまったのでまた別で投稿しようと思います。





↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ 採用サイトはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓


↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ALHについてはこちら ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓


↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ もっとALHについて知りたい? ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓