spring
JUnit
DBUnit
spring-boot

Spring Bootでテストを書くときのやりかたまとめ

追記

  • 2017/11/2 RestTemplateのテスト追加

この資料について

  • Spring bootを使ったプロジェクトをやっていて、その際にテストどう書くねんってなったからまとめた
  • この資料では下記のテストの仕方をまとめた
    • Service(POJOっぽいやつ)
    • Controller(Mockかして順序とかの担保)
    • Repository(CSVでテストデータ用意してテスト)
    • リクエストパラメータのテスト(バリデーションを実際にしてみてテスト)
    • RestTemplateをつかって外部のAPIたたくクライアントクラスのテスト
    • 機能テスト(実際に叩いてテスト)
  • 基本調べて書いたのでもっとこうしたほうがきれいにかけるやで、っていうのがあればぜひ

実行環境

  • Java 1.8
  • SpringBoot 1.5.7.RELEASE
  • 詳しくは下記のリポジトリ参照

ここでやったことのコード

spring-boot-test-sample

Serviceのテスト

とりあえず動かす

とりあえずJUnitで普通のテストを書いてみる

まずはテスト対象のクラスを定義する

// テスト対象のクラス
@Service
public class DemoService {

    public String greeting(String greet) {
        if (StringUtils.isEmpty(greet)) {
            return "Say something...";
        }

        return "hello";
    }
}

次にテストコードを書いてみる

public class DemoServiceTest {

    private DemoService demoService;

    @Before
    public void setUp() {
        demoService = new DemoService();
    }

    @Test
    public void greeting_あいさつがないとき() {
        assertThat(demoService.greeting(null), is("Say something..."));
    }

    @Test
    public void greeting_あいさつがあるとき() {
        assertThat(demoService.greeting("something"), is("hello"));
    }
}


解説

特に書くことないけどJUnitとhamcrestをつかってテスト書いた

Mock化してテスト

何かしらのクラスをインスタンス化してテストするケースにて、呼ばれた経路だけ担保すればいいようなケースをテストしてみる

ここではcontrollerのテストでやってみる

とりあえず動かす

ちょっと冗長な書き方してるけど気にしないでください。

@RequiredArgsConstructor
@RestController
@RequestMapping("/v1/demo")
@Validated
public class DemoController {

    @Autowired
    private DemoService demoService;

    @Autowired
    private DemoRepository demoRepository;

    @GetMapping
    public String demo(@Valid DemoRequest demoRequest) {
        DemoEntity demoEntity = demoRepository.findByCode(demoRequest.getCode());
        String value = demoEntity.getValue();
        String ret = demoService.greeting(value);
        return ret;
    }
}

テストを書く

public class DemoControllerTest {

    private static String GREET = "hoge";

    @Mock
    private DemoRepository demoRepository;

    @Mock
    private DemoService demoService;

    @InjectMocks
    private DemoController demoController;

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);
    }


    @Test
    public void 順番の担保() {
        DemoEntity demoEntity = DemoEntity.builder()
                .code("hoge")
                .value("fuga")
                .updateAt(null)
                .build();
        when(demoRepository.findByCode(anyObject())).thenReturn(demoEntity);
        when(demoService.greeting(anyObject())).thenReturn("hello");
        DemoRequest demoRequest = new DemoRequest();
        demoRequest.setCode(GREET);
        String actual = demoController.demo(demoRequest);

        verify(demoRepository, times(1)).findByCode(GREET);
        verify(demoService, times(1)).greeting("fuga");

        assertThat(actual, is("hello"));
    }

}

解説

  • リクエストされたパラメータでDemoRepositoryからデータ引いてその結果からDemoServiceの関数呼んでデータ返却、みたいな関数のテストを書いた。
  • Mock化するインスタンスには@Mockとつけて、Mockされたインスタンスをつっこむインスタンスには@InjectMocksをつけて宣言する
  • @Beforeの部分で、それぞれ宣言したインスタンスを初期化して実際にMock化、つっこむ、ってところをやってる
  • テストの関数の部分で、こう呼ばれたらこう返す、ってのをwhenとthenReturnで行っている。どの引数で呼ばれるかはあとで行っているのでanyObject()にしてる
  • 何回呼ばれるか、どの引数でよばれるかの確認はverifyで行っている
  • 最終的になんの結果が呼ばれるかはassertThatとisで行っている

Repositoryのテスト(CSVでテストデータ管理する)

テストデータをCSVで用意し、テスト前にそのデータでテーブルを初期化してRepositoryのテストを行う

とりあえず動かす

テスト対象のクラスをつくる

今回findByCodeのテストをしたいのでそれだけ宣言する

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;

@Component
public interface DemoRepository extends JpaRepository<DemoEntity, String>{
    public DemoEntity findByCode(String code);
}

テスト対象RepositoryのEntityをつくる
@Table(name = "demo")
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DemoEntity {

    @Id
    private String code;
    private String value;
    private ZonedDateTime updateAt;
}

テストコードを書く

CSVで読み込むために色々やっているのでコメントで軽く解説を残す

package com.example.demo;

import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.DbUnitConfiguration;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
import org.springframework.transaction.annotation.Transactional;

import java.time.ZonedDateTime;

import static org.hamcrest.beans.SamePropertyValuesAs.samePropertyValuesAs;
import static org.junit.Assert.assertThat;


@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class})
@DbUnitConfiguration(
        dataSetLoader = ReplacementCsvDataSetLoader.class // ここでCSVでデータ読み込むReplacementDataSetLoaderのクラスを指定
)
@Transactional
public class DemoRepositoryTest {

    private static final String DATA_FILE_PATH = "/DemoRepository/";

    @Autowired
    private DemoRepository demoRepository;

    @DatabaseSetup(value = DATA_FILE_PATH + "findByCode/")
    @Test
    public void findByCodeTest() {
        DemoEntity expected = DemoEntity.builder()
                .code("hoge")
                .value("fuga")
                .updateAt(ZonedDateTime.parse("2017-01-01T00:00:00+09:00:00[Asia/Tokyo]"))
                .build();
        DemoEntity actual = demoRepository.findByCode("hoge");
        assertThat(actual, samePropertyValuesAs(expected));
    }
}

ReplacementDataSetLoaderを拡張する
package com.example.demo;

import com.github.springtestdbunit.dataset.ReplacementDataSetLoader;

public class ReplacementCsvDataSetLoader extends ReplacementDataSetLoader {
    public ReplacementCsvDataSetLoader() {
        super(new CsvDataSetLoader()); // ここで実装したCsvをloadするLoaderを読み込む
    }
}

Csv用のLoaderを実装する
package com.example.demo;

import com.github.springtestdbunit.dataset.AbstractDataSetLoader;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.csv.CsvURLDataSet;
import org.springframework.core.io.Resource;

public class CsvDataSetLoader extends AbstractDataSetLoader {
    public CsvDataSetLoader() {
    }

    @Override
    protected IDataSet createDataSet(Resource resource) throws Exception {
        return new CsvURLDataSet(resource.getURL());
    }
}

テストデータ

demoというテーブル名なのでresourcesにdemo.csvファイルを作成

code,value,update_at
hoge,fuga,2017-01-01 00:00:00
fuga,hoge,2017-01-01 00:00:00
table-ordering.txt

使用するテーブルを定義するファイル

demo

解説

それぞれ解説する

実装とEntity
  • 特筆すべき事はとくにない。普通にRepositoryとEntityつくっただけ
  • 強いて言うならZonedDataTimeはhibernateのバージョンが5.2以上じゃないと動かないので依存を少しいじらないといけない *spring-boot-starter-data-jpaの現行のバージョン(1.5.3くらい)だとhibernateのバージョンは5.0系
テストデータをCSVで読み込むところ
  • けっこういろんなファイルをつくったが、自分でごりごり書くような内容はなく、用意されたものを使っただけ
  • ReplacementDataSetLoaderのコンストラクタにてAbstractDataSetLoaderを拡張したLoaderを宣言すれば良い
  • AbstractDataSetLoaderを拡張したCsvDataSetLoaderを自作したが、そこではcreateDataSet関数をOverrideした関数をつくればよく、そこの返却値ではDBUnitで用意されたCSV用のインスタンスを返すだけで良い
  • CSVの読み込み方はディレクトリを指定して、そのディレクトリにあるtable-ordering.txtを読み込み、そこに定義されている同じ階層にあるテーブル名のファイルを読み込んでくれる
テストコード
  • @DatabaseSetupで指定したディレクトリのデータをテーブルにぶっこんでからテストが動く
  • その後は普通にデータを引いてきて、assertしているだけ

リクエストパラメータのテスト

リクエストパラメータをDTOでやってる場合のテストを書いた

注: リクエストパラメータをdemo(@Valid @Size(max = 5)String code)みたいに引数でバリデーション設定している場合はコントローラーとか機能テストじゃないと無理

とりあえず動かす

RequestパラメータのDTO

@Getter
@Setter
public class DemoRequest {

    @NotNull
    @Size(max = 5)
    private String code;
}

テストコード

public class DemoRequestTest {

    private Validator validator;

    @Before
    public void setUp() {
        validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    @Test
    public void validatorTest_桁あふれ() {
        DemoRequest demoRequest = new DemoRequest();
        demoRequest.setCode("123456");
        Set<ConstraintViolation<DemoRequest>> violationSet = validator.validate(demoRequest);
        assertThat(violationSet.size(), is(1));
        violationSet.forEach(violation -> {
            assertThat(violation.getConstraintDescriptor().getAnnotation(), instanceOf(Size.class));
        });
    }
}

解説

  • テストコード内で実際にvalidateしてその結果をテストしている
  • Validatorをインスタンス化して対象のDTOをvalidateしている
  • validateした結果はConstraintViolationのSetとして返却されるのでその内容をぐるぐる回してテストしている

機能テスト

TestRestTemplateを使ってAPIを実際に動かすテストを書いた

とりあえず動かす

  • Controllerは上記のものを流用
  • DbUnit周りも上記のものを流用
テストコード
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestExecutionListeners(
        listeners = {
                TransactionalTestExecutionListener.class,
                DbUnitTestExecutionListener.class},
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
@DbUnitConfiguration(
        dataSetLoader = ReplacementCsvDataSetLoader.class
)
public class DemoApiFunctionalTest {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @DatabaseSetup(value = "/DemoRepository/findByCode/")
    @Test
    public void demoGetTest_正常系() {

        // 実行
        ResponseEntity actual = testRestTemplate.exchange(
                "/v1/demo?code=hoge", HttpMethod.GET, new HttpEntity<>(null, null), String.class);
        assertThat(actual.getBody().toString(), is("hello"));
        assertThat(actual.getStatusCode(), is(HttpStatus.OK));
    }
}

解説

  • TestRestTemplateを利用して実際にアプリケーションを実行してその返却値をテストした
  • DBにテスト用のデータをいれたいのでDBUnitの設定をしてテストデータをいれた
  • portをランダムにしたいので@SpringBootTestで設定した
  • @TestExecutionのmergeModeでデフォルトのリスナーに追加している

RestTemplateのテスト

とりあえず動かす

実行するAPIの返却値

同一リポジトリにhttp://localhost:8080/v1/demo/api
で以下を返却するAPIをつくった


{
    "code" : "012",
    "name" : "nanika"
}
テスト対象のクラス
@Component
public class DemoClient {

    private final RestTemplate restTemplate;

    @Value("${demo.api.hostname}")
    private String hostname;

    @Value("${demo.api.endpoint}")
    private String endpoint;

    public DemoClient(RestTemplateBuilder restTemplateBuilder) {
        this.restTemplate = restTemplateBuilder
                .setConnectTimeout(1000)
                .setReadTimeout(1000)
                .build();
    }

    public DemoApiResponse get() {
        UriComponents uriComponents = UriComponentsBuilder.fromUriString(hostname + endpoint).build();
        return restTemplate.getForObject(uriComponents.toUri(), DemoApiResponse.class);
    }
}
application.ymlの設定
demo:
  api:
    hostname: "http://localhost:8080"
    endpoint: "/v1/demo/api"
テストコード
@RunWith(SpringRunner.class)
@RestClientTest(DemoClient.class)
public class DemoClientTest {

    @Autowired
    private MockRestServiceServer mockServer;

    @Autowired
    private DemoClient demoClient;

    @Test
    public void testGet() {
        mockServer.expect(requestTo("http://test.api.server/v1/demo/api"))
                .andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess("{\"code\":\"123\",\"name\":\"name\"}", MediaType.APPLICATION_JSON));
        DemoApiResponse expected = new DemoApiResponse("123", "name");

        DemoApiResponse actual = demoClient.get();

        assertThat(actual, samePropertyValuesAs(expected));
    }

}
テストに使うapplication.ymlの設定
demo:
  api:
    hostname: "http://test.api.server"
    endpoint: "/v1/demo/api"

解説

  • DemoClientではRestTemplateをコンストラクタでRestTemplateBuilderを使ってインスタンス化し、application.ymlで設定されたURLにリクエストするようにした
  • テストではMockRestServiceServerを使ってRestTemplateに割り込んでテストデータが返却できるようにした
  • テストの際はtestパッケージ以下のapplication.ymlの設定を読み込むためMock化したURLにリクエストがいくようになっている

まとめ

  • CSVを使ってDBのデータをセットアップするのは面倒だけど、基本用意されているものだけでできるので楽ちん
  • TestRestTemplateがあるから機能テストもさっとできる。DBUnit使えばテストデータもぶっこめる
  • Spring bootのテストなのかそうでないのかで書き方変わるから注意

参考にしたサイト