💬

TDD×SpringBoot

2022/11/28に公開

テスト駆動開発で簡単な在庫状況取得処理の作成を行ってみます
できるだけ簡単にするためシンプルなものにします

概要

以下のような商品の在庫情報を取得します
また、予定在庫と実在庫を計算して受注可能数を決定します

  • 在庫情報
    • ガム
      • 予定在庫 -5
      • 実在庫 5
        • 受注可能数 0
      • 予定在庫 5
      • 実在庫 0
        • 受注可能数 5

最終的にクライアントには、値だけ入ったDTOを返却します

  • 在庫DTO
    • 商品名
    • 予定在庫
    • 実在庫
    • 受注可能数

※この記事では実際のデータベースを利用した実装までは行いません
※IntelliJを利用して開発を行います

事前準備

今回はgradleを利用していて、以下の依存モジュールを追加しています

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.springframework.boot:spring-boot-starter-web'
}

application.yamlに設定の記述はありません

テスト

これからコードを書いていきますが、まずテストコードを作成します。その後テストを成功させるように実装を書いていきます
今回はシンプルなものを書いていくのですが、複雑な処理の場合はテスト成功後にリファクタリング手順も必要になってきます

在庫のテスト

早速テストコードを書いてみます。在庫に関する部分は受注可能数を計算するロジックがあるので、そこを中心にテストしていきます

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class StockTest {
    @Test
    public void testOrderableQuantity(){
        Stock stock = new Stock(10 , 10);
        assertEquals(20, stock.getOrderableQuantity());
    }
}

いきなりエラーが起きています。Stockクラスが見つかりませんというエラーになっていますので、Stockクラスを作成します。
※IntelliJを利用している場合は、エラーになって赤文字になっているStockにカーソルを合わせてalt+Enterで簡単にクラスの作成等できます

Target destination directoryでtestでない場所を選びます
※事前にsrc配下にフォルダがないと作成できないかもしれません

クラスの作成が完了したら、テストコードでエラーになっているコンストラクタの部分と、getOrderableQuantityメソッドの箇所も同じようにalt+Enterで作成できます

public class Stock {
    public Stock(int i, int i1) {
    }
    
    public int getOrderableQuantity() {
        return 0;
    }
}

コンパイルは通りますがテストは失敗します
しかし値が0で返ってくるという処理が走っていることは確認できます

expected: <20> but was: <0>
Expected :20
Actual   :0
<Click to see difference>

org.opentest4j.AssertionFailedError: expected: <20> but was: <0>

テストが成功するようにStockクラスを書いていきます

public class Stock {
    private final int actual;
    private final int planned;
    public Stock(int actual, int planned){
        this.actual = actual;
        this.planned = planned;
    }
    public int getOrderableQuantity() {
        return this.actual + this.planned;
    }
}

テストに予定在庫がマイナスの時と、将来入ってくる時の動きを入れてみます

class StockTest {
    @Test
    public void testOrderableQuantity(){
        Stock stock = new Stock(10,10);
        assertEquals(20, stock.getOrderableQuantity());
    }
    @Test
    public void testMinusPlanned(){
        Stock minusStock = new Stock(10, -10);
        assertEquals(0, minusStock.getOrderableQuantity());
    }
    @Test
    public void testPlusPlanned(){
        Stock willStock = new Stock(0, 10);
        assertEquals(10, willStock.getOrderableQuantity());
    }
}

テストは成功します
まずテストコードを書いてから実装を行うというステップで在庫クラスを作成することができました

次は、この在庫情報を取得する箇所のテストを書いていきます
本来であればデータベースから取得する部分ですが、モックを利用して簡単にテストを行ないます

サービスクラスのテスト

サービスクラスのテストを行います
サービスクラスはリポジトリから取得したStockクラスをStockOuputに変換して返却します
このリポジトリというのはデータベースに関わる部分で、本来であれば非常にテストしにくい部分です
このようなテストしにくい部分はモックを利用して解決します

class StockServiceImplTest {
    @Test
    public void testFindStockOutput(){
        StockServiceImpl service = new StockServiceImpl();
        StockOutput output = service.findStock(1);
        assertNotNull(output);
    }
}

在庫のテストを行ったときと同様に、クラスやメソッドが無いのでエラーになっています
StockServiceImplやStockOutputといった存在しないクラスやメソッドを、在庫の時と同じように生成してコンパイルが通る状態にしておきます。

StockOutputクラスはシンプルなDTOなので作成も簡単です
このクラスをサービスクラスからコントローラーに返却してあげます

public class StockOutput {
    public final String itemName;
    public final int actual;
    public final int planned;
    public final int orderableQuantity;
    public StockOutput(String itemName, int actual, int planned, int orderableQuantity){
        this.itemName = itemName;
        this.actual = actual;
        this.planned = planned;
        this.orderableQuantity = orderableQuantity;
    }
}

次にテスト済のStockクラスを修正して商品名フィールドとgetterの実装を行います

public class Stock {
    private final String itemName;
    private final int actual;
    private final int planned;
    public Stock(String itemName,int actual, int planned){
        this.itemName = itemName;
        this.actual = actual;
        this.planned = planned;
    }
    public String getItemName(){
        return itemName;
    }
    public int getActual(){
        return actual;
    }
    public int getPlanned(){
        return planned;
    }
    public int getOrderableQuantity() {
        return this.actual + this.planned;
    }
}

コントローラーに返却するStockOutputクラスができたので、StockServiceImplクラスのfindStockメソッドを修正します
リポジトリから取得したStockをStockoutputに変換して返してあげます

@Component
public class StockServiceImpl {
    @Autowired
    private StockRepository stockRepository;
    public StockOutput findStock(int i) {
        Stock stock = stockRepository.findStockbyStockId(i);
        StockOutput stockOutput = new StockOutput(
                "sample",
                stock.getActual(),
                stock.getPlanned(),
                stock.getOrderableQuantity()
        );
        return stockOutput;
    }
}

コンパイルエラーが出ている個所、リポジトリはインターフェースのみ作成しておきます

public interface StockRepository {
    Stock findStockbyStockId(int stockId);
}

StockServiceImplクラスのほうでStockRepositoryをインポートするとコンパイルエラーが消えますので早速テストを実行します

D:\work\shopapi\shopapi\src\test\java\com\volkruss\shopapi\stock\domain\model\StockTest.java:11: エラー: クラス Stockのコンストラクタ Stockは指定された型に適用できません。
        Stock stock = new Stock(10,10);
                      ^

先ほどの修正でStockに変更が入ったのでコンストラクタに渡す引数が増えています。そのため先ほど完了したStockTestでコンパイルエラーが発生していました
コンストラクタの引数を修正だけしておきます

class StockTest {
    @Test
    public void testOrderableQuantity(){
        Stock stock = new Stock("sample",10,10);
        assertEquals(20, stock.getOrderableQuantity());
    }
    @Test
    public void testMinusPlanned(){
        Stock minusStock = new Stock("sample",10, -10);
        assertEquals(0, minusStock.getOrderableQuantity());
    }
    @Test
    public void testPlusPlanned(){
        Stock willStock = new Stock("sample",0, 10);
        assertEquals(10, willStock.getOrderableQuantity());
    }
}

これでStockServiceImplTestクラスのテストを実行できます
テストを実行するとエラーになります

Cannot invoke "com.volkruss.shopapi.stock.domain.repository.StockRepository.findStockbyfindStockbyStockId(int)" because "this.stockRepository" is null

StockRepositoryが当然ですがnullになっています、このように@AutowiredするようなコンポーネントはMockで作成します。ここでは@Mockアノテーションを使ってモックを作成します

以下のようにテストクラスを修正します

class StockServiceImplTest {
    // モックする
    @Mock
    private StockRepository stockRepository;

    // モックを利用するクラス
    @InjectMocks
    private StockServiceImpl service;

    @BeforeEach
    public void setup(){
        // @Mockを使う時には初期化処理が必須
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testFindStockOutput(){
        // findStockbyStockId(数値)が呼ばれた時の挙動を定義する
        Mockito.when(stockRepository.findStockbyStockId(Mockito.anyInt()))
                .thenReturn(new Stock("sample", 10 , 10));
        StockOutput output = service.findStock(1);
        assertNotNull(output);
    }
}

テストは成功しますが、書いててあまりにも無意味なテストでしたので以下のように修正してテストを実行します

@Test
public void testFindStockOutput(){
    // findStockbyStockId(数値)が呼ばれた時の挙動を定義する
    Mockito.when(stockRepository.findStockbyStockId(Mockito.anyInt()))
            .thenReturn(new Stock("sample", 10 , 10));
    StockOutput output = service.findStock(1);
    assertEquals("sample", output.itemName);
    assertEquals(10, output.actual);
    assertEquals(10,output.planned);
    assertEquals(20, output.orderableQuantity);
}

ここまでのテストでリポジトリからStockを正しく取得した場合に、StockOutputに正しく変換されていることがテストできました

マッパークラスの作成

次にStockからStockOutputにマッピングするクラスを作成したいと思いました
なのでテストコードから作成します

public class StockMapperTest {
    @Test
    public void testMapToStockOutput(){
        Stock stock = new Stock("sample", 10, 10);
        StockOutput output = StockMapper.toOut(stock);
        assertEquals(output.itemName, "sample");
        assertEquals(output.actual, 10);
        assertEquals(output.planned, 10);
        assertEquals(output.orderableQuantity, 20);
    }
}

エラーになっているStockMapperクラスとtoOutメソッドを生成します
ここではStockの内容をStockOutputに変換するだけです

public class StockMapper {
    public static StockOutput toOut(Stock stock) {
        return new StockOutput(
                stock.getItemName(),
                stock.getActual(),
                stock.getPlanned(),
                stock.getOrderableQuantity()
        );
    }
}

作成したStockMapperTestのテストを実行して成功を確認します

次にStockServiceImplクラスで、このStockMapperを利用するように修正します
findStockメソッドを以下のように修正します

public StockOutput findStock(int stockId) {
    Stock stock = stockRepository.findStockbyStockId(stockId);
    return StockMapper.toOut(stock);
}

またStockServiceImplTestクラスのtestFindStockOutputテストが問題なく通ることも確認しておきます
これでマッパーがサービスクラスで正しく利用されていることがわかりました

コントローラーのテスト

コントローラーのテストを行いますが、ここではWebMvcTestとMockMvcを利用してWebレイヤーに対するテストを行います

/stock/1にアクセスしたら期待通りのStockOutputが取得できることを確認します
また、ここではMockBeanというアノテーションを利用してアプリケーションコンテナに対してサービスクラスを登録します

MockBeanを利用することで、コントローラークラス内でAutowiredしているオブジェクトに、テストでもモックをインジェクションすることができます(WebMvcTestのアノテーションを使います)
WebMvcTestアノテーションはコントローラーのみテストする際に便利です

他にも完全な Spring アプリケーションコンテキストを開始する方法として@SpringBootTestと
@AutoConfigureMockMvcを使う方法がありますが、参考サイトのほうにサンプルコードなど載っていますのでご参照ください

jsonPathを利用してAPIから取得したオブジェクトを精査しています

@WebMvcTest(value = StockController.class)
public class StockControllerTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private StockService stockService;
    @Test
    public void testScenario() throws Exception {
        Mockito.when(stockService.findStock(Mockito.anyInt())).thenReturn(
                new StockOutput("sample", 10 , 10 , 20)
        );
        this.mockMvc.perform(get("/stock/1").contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.itemName").value("sample"))
                .andExpect(jsonPath("$.actual").value(10))
                .andExpect(jsonPath("$.planned").value(10))
                .andExpect(jsonPath("$.orderableQuantity").value(20));
    }
}

※ここのWebMvcTestアノテーションのvalueにテスト対象のコントローラークラスを明記しないと、テスト以外のコントローラークラスなどでインジェクションなどの処理が走るため、明記しておくほうが良いです

サービスクラスは作成済でしたがインターフェースが未定義でしたので、サービスクラスのインターフェースを作成してインターフェースの実装だけさせておきます

public interface StockService {
    StockOutput findStock(int stockId);
}

@Component
public class StockServiceImpl implements  StockService{
    @Autowired
    private StockRepository stockRepository;
    public StockOutput findStock(int stockId) {
        Stock stock = stockRepository.findStockbyStockId(stockId);
        return StockMapper.toOut(stock);
    }
}

次にコンパイルエラーとなっているStockControllerクラスを作成します
まずはクラスの作成と@RestControllerアノテーションの付与だけておきます

@RestController
public class StockController {
}

この状態で一度テストを実行するとHTTPステータスが404ですというエラーになり、Webレイヤーへのテストが実施されていることが確認できます

Status expected:<200> but was:<404>
Expected :200
Actual   :404
<Click to see difference>

java.lang.AssertionError: Status expected:<200> but was:<404>
	at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
	at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
	at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$9(StatusResultMatchers.java:627)
	at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:214)
	at com.volkruss.shopapi.stock.application.controller.StockControllerTest.testScenario(StockControllerTest.java:22)

コントローラークラスにルーティングとなるメソッドを追加してテストが動くようにします
テスト済であるサービスクラスを利用して取得したStockOutputを、ResponseEntityにセットして返却するようにします

@RestController
public class StockController {
    @Autowired
    private StockService stockService;
    @GetMapping("/stock/{id}")
    public ResponseEntity<StockOutput> getStock(@PathVariable("id") int id){
        StockOutput stockOutput = this.stockService.findStock(id);
        return new ResponseEntity<>(stockOutput, HttpStatus.OK);
    }
}

これでテストを実行すればテストが成功します

プロジェクト全体のビルド

gradleのbuilタスクを実行するとエラーになります。理由はリポジトリの実装クラスのコンポーネントがないため、サービスクラスでインジェクションできないからです

この記事では処理内容の実装はしませんが、以下のようなリポジトリの実装クラスを用意しておけばビルドもできるようになります

@Component
public class StockRepositoryImpl implements StockRepository {
    @Override
    public Stock findStockbyStockId(int stockId) {
        return null;
    }
}

後から気になった事

  • findStockbyStockIdはfindStockbyIdのほうが適切でした

参考

https://spring.pleiades.io/guides/gs/testing-web/

Discussion