見出し画像

【初心者向け】SpringBootにおける単体テスト【ログ、例外処理編】

こんにちは!
ALH開発事業部のAKIRAです!

今回は、前回書ききれなかった部分について書いていきます。


この記事を書いてる会社って?ALHが気になってくれた方におすすめ!
200以上の記事で会社の様子がわかる♪

前提条件(前回と同じ)

Java11
SpringBoot 2.5.6
Junit5
H2
MyBatis
REST API

今回は以下の項目についてまとめていきます。

  • ログのテスト

  • 例外のテスト

ログのテスト

ログのテストは標準出力の中身を取得してあげることになるのでまた少し特殊な書き方になります。
以下がテスト対象であるログを出力するコントローラークラスです。

package com.example.demo.presentation.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 {

    private static final Logger logger = LoggerFactory.getLogger(DemoController.class);

    @Autowired
    DemoService demoService;

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

        logger.info("処理開始");
        Boolean response = demoService.decide(goods);
        logger.info("処理完了");

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

以下がこれに対応するテストクラスです。

package com.example.demo.presentation.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
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.junit.jupiter.api.extension.ExtendWith;
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.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
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)
@ExtendWith(OutputCaptureExtension.class)
public class DemoControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    DemoService mockService;

    @Test
    public void shoppingTest01(CapturedOutput output) 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);
        assertThat(output).contains("処理開始");
        assertThat(output).contains("処理完了");

    }

    @Test
    public void shoppingTest02(CapturedOutput output) throws Exception {

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

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

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

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

        verify(mockService, times(1)).decide(any(Goods.class));
        assertTrue(responseJson.equals("an error has occured."));
        assertThat(output).contains("処理開始");

    }

    private Goods createGoods() {

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

@ExtendWith(OutputCaptureExtension.class)をテストクラスに付け、
テストメソッドの引数にCapturedOutputを指定してあげることで、標準出力を取得してあげることができます。
この引数の内容をassertThat(output).contains("処理完了");のようにassertしてあげることでログの内容をテストできます。
この方法だとモック化の必要もないので簡単にテストを書くことが可能です。

例外のテスト

例外がthrowされる場合のテストは、そのまま処理を実行してしまうとエラーで処理が途中で終了してしまうだけになるため、こちらも少し特殊な書き方になります。
以下がテスト対象の例外処理を実装したクラスです。

package com.example.demo.application.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;

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

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

    private static final Logger logger = LoggerFactory.getLogger(DemoService.class);

    @Autowired
    DemoRepository demoRepository;

    //
    public boolean decide(Goods goods) throws Exception {
        try {
            Goods ordinaryGoods = demoRepository.findByName(goods.getName());
            if (goods.getPrice() <= ordinaryGoods.getPrice())
                return true;
            return false;
        } catch (DataAccessException e) {
            logger.debug("データベースで問題が発生しました");
            throw new Exception(e);
        }

    }
}

以下がこれに対応するテストクラスです。

package com.example.demo.application.service;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
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.dao.DataAccessException;
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() throws Exception {

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

        // どんなString型の引数でも作成したオブジェクトを返すようにmockを定義
        when(mockRepository.findByName(Mockito.anyString())).thenReturn(createOrdinaryGoods());

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

    // 異常系のテストメソッド
    @Test
    public void decideTest02() throws Exception {

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

        // どんなString型の引数でもDataAccessExceptionを返すようにmockを定義
        doThrow(new DataAccessException("") {
        }).when(mockRepository).findByName(Mockito.anyString());

        // throwされるExceptionのクラスをassert
        Throwable e = assertThrows(Exception.class, () -> {
            target.decide(goods);
        });

        // throwされるExceptionの元のクラスをassert
        assertTrue(e.getCause() instanceof DataAccessException);
    }

    // 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;
    }

}

まず例外処理を行わせるためにmockが何らかのExceptionを投げるようにdoThrowで設定をしてあげます。
そしてassertThrowsの第一引数にテスト対象が投げるはずの期待値のExceptionのクラス、第二引数にテスト対象を実行する関数を指定してあげることで、実際に投げられたExceptionのクラスと期待値のクラスを比較してassertすることが可能です。
また、
Throwable e = assertThrows(Exception.class, () -> {target.decide(goods); });
のようにThrowableにassertThrowsの結果を格納し、
assertTrue(e.getCause() instanceof DataAccessException);
のようにgetCause()してあげることでExceptionの原因をassertすることも可能です。

この記事を書いてる会社って?ALHが気になってくれた方におすすめ!
200以上の記事で会社の様子がわかる♪

まとめ

前回の記事と合わせてとりあえず一通り自分の学んだ単体テストの書き方についてはまとめられたと思います。
今回はテスト対象が分岐もほとんどなく処理も単純だったのでテストクラスが小さく収まりましたが、実際に現場で作られるような処理に対するテストクラスは思っている以上に巨大になりがちです。テストを実施する方法が大体わかっているからまあ大丈夫だろうという考えでは、後々実際にテストを書く時に泣くことになる(冗談じゃなく泣きます)ので、テストを楽に書くことができるような処理を意識して書くことも重要です。(if文やswitch文をネストしないだとかassertしやすい引数を使うだとか色々あります)
とはいえ、この記事の内容が基本事項であることに変わりはないので忘れた時に見返してみるといいかもしれません。





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


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


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