🌱

Spring Data JPAのリポジトリテスト作成までの流れと考え方

2024/12/13に公開

はじめに

初めまして、バックエンドエンジニアの横山です!
この記事では、JUnit5を使用したリポジトリテストを書くまでの流れやテストの書き方を、サンプルプロジェクトを通して解説していきます。
今回想定しているJavaのバージョンは11と古めですが、JUnit5は最新のバージョン(2024/10/29時点)なので、JUnitにおいては大きな問題はないと思います。

弊社では、Aurora MySQL バージョン2のサポート期間終了のため、Aurora MySQL バージョン3へのバージョンアップに伴うMySQL5.7からMySQL8.0.28へのアップグレード対応をしています。MySQL5系とMySQL8系両方でプログラムが動くようにするためのプロダクトコードの改修や、Spring Data JPAにおけるリポジトリテストを進めています。今回はリポジトリのテストについて掘り下げ、テスト作成までの流れと考え方、JUnit5でのテストを書く方法をまとめます。

大まかな構成としては、テスト対象のプロジェクトについての説明、テストケースを考える方法、テストの設定、JUnitによるテストの記述、テストのGood Practicesという構成になります。

1. テスト対象のプロジェクト

まずは前提となる、対象プロジェクトについて見ていきます。プロジェクトは、簡単な在庫管理システムを想定しています。

1.1 概要

ここでは、使用するテーブルとデータベースの処理を示します。

1.1.1 テーブルのER図

1.1.2 データベース処理

// 在庫数量が範囲内のものの商品名を取得
SELECT p.ProductName
FROM Stock s
JOIN Products p ON s.ProductId = p.ProductId
WHERE Quantity >= :minQuantity
AND :maxQuantity >= Quantity;

// 取扱中の中で価格が範囲内で、色・カテゴリが一致するものの商品名を取得
SELECT p.ProductName
FROM Stock s
JOIN Products p ON s.ProductId = p.ProductId
WHERE s.Quantity > 0
AND p.Price >= :minPrice
AND :maxPrice >= p.Price
AND p.ColorId = :colorId
AND p.CategoryId = :categoryId
AND p.Dealing = :dealing;

1.2 実装

1.1の概要をコードで表現したものが以下になります。

1.2.1 プロジェクト構成

実際のプロジェクト構成は以下のようになります。

プロジェクト構成
.
└── main/
   └── java/com/example/adcal/
        ├── entity/
        │   ├── Categories.java
        │   ├── Colors.java
        │   ├── Products.java
        │   └── Stock.java
        ├── repository/
        │   └── StockRepository.java
        └── AdcalApplication.java

1.2.2 Gradleの依存関係

build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	runtimeOnly 'com.mysql:mysql-connector-j:8.0.33'

	testImplementation "org.testcontainers:testcontainers:1.16.3"
	testImplementation "org.testcontainers:junit-jupiter:1.16.3"
	testImplementation "org.testcontainers:mysql:1.16.3"
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

1.2.3 Entity実装

1.1.1 で登場したテーブルを実装したものが以下になります。
EntityではLombokの@Getter、@Setterを使用しています。

@Getter
@Setter
@Entity
public class Categories {
    @Id
    private Integer CategoryId;
    private String CategoryName;
}

@Getter
@Setter
@Entity
public class Colors {
    @Id
    private Integer ColorId;
    private String ColorName;
}

@Getter
@Setter
@Entity
public class Products {
    @Id
    private Integer ProductId;
    private String ProductName;
    private Integer CategoryId;
    private Integer ColorId;
    private BigDecimal Price;
    private Boolean Dealing;
}

@Getter
@Setter
@Entity
public class Stock {
    @Id
    private Integer StockId;
    private String ProductName;
    private Integer Quantity;
}

1.2.4 Repository実装

1.1.2 で登場したデータベース処理を実装したものが以下になります。

StockReposiroty.java
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {

    // 例1
    // 在庫数量が範囲内のものの商品名を取得
    @Query(
            nativeQuery = true,
            value = " SELECT p.ProductName" +
                    " FROM Stock s" +
                    " JOIN Products p ON s.ProductId = p.ProductId" +
                    " WHERE Quantity >= :minQuantity" +
                    " AND :maxQuantity >= Quantity;"
    )
    List<String> findProductNameByQuantityRange(@Param("minQuantity") int minQuantity, @Param("maxQuantity") int maxQuantity);

    // 例2
    // 取扱中の中で価格が範囲内で、色・カテゴリが一致するものの商品名を取得
    @Query(
            nativeQuery = true,
            value = " SELECT p.ProductName" +
                    " FROM Stock s" +
                    " JOIN Products p ON s.ProductId = p.ProductId" +
                    " WHERE s.Quantity > 0" +
                    " AND p.Price >= :minPrice" +
                    " AND :maxPrice >= p.Price" +
                    " AND p.ColorId = :colorId" +
                    " AND p.CategoryId = :categoryId" +
                    " AND p.Dealing = :dealing;"
    )
    List<String> findProductNameByPriceAndColorAndCategoryAndDealing(
            @Param("minPrice") int minPrice,
            @Param("maxPrice") int maxPrice,
            @Param("colorId") int colorId,
            @Param("categoryId") int categoryId,
            @Param("dealing") boolean dealing
    );
}

2. テストケースを考える

次は実際に検証したいクエリから必要なテストケースを考えていきます。簡単なクエリであれば全ての条件のテストケースを考えることは難しくないかもしれません。

しかし実際には簡単なクエリばかりではなく、複雑で巨大なクエリも見かけると思います。また、必ずしもテストケースが多いからといって有効なテストとはなりませんので、必要な分だけテストケースを考えることが求められます。

効率よくテストする上で知っておきたい考え方はいくつかあり、それらを表にまとめました。特に境界値テストは考えるケースを減らせるだけでなく、検証の優先度も高いため、重要です。

テスト手法 特徴
境界値テスト 仕様条件の境界となる値の前後で検証を行う。仕様条件の誤解やコーディング中の見落としなどにより、境界値周辺で不具合が多くなる傾向がある。日付などの連続値の検証で有効。
同値分割テスト 同じ処理結果となる入力値の集まり(正常系の集合を有効同値パーティション、異常系の集合を無効同値パーティションと呼ぶ)からそれぞれ少なくとも1つ代表値を選択し、検証を行う。連続値以外の検証も可能。
組み合わせテスト 2因子間の組み合わせを全て網羅するデータを作成して検証する。組み合わせが多いテストでは、検証する数を大幅に減らすことができる。機械的にデータを作成するため、不具合の原因を特定できない場合にはあまり有効ではない。

まずは例1のクエリから見ていきましょう。

StockRepository.java(抜粋)
    // 例1
    // 在庫数量が範囲内のものの商品名を取得
    @Query(
            nativeQuery = true,
            value = " SELECT p.ProductName" +
                    " FROM Stock s" +
                    " JOIN Products p ON s.ProductId = p.ProductId" +
                    " WHERE Quantity >= :minQuantity" +
                    " AND :maxQuantity >= Quantity;"
    )
    List<String> findProductNameByQuantityRange(@Param("minQuantity") int minQuantity, @Param("maxQuantity") int maxQuantity);

例1はminQuantityとmaxQuantityの範囲の数量を持つ商品のProductNameを取得するクエリになっています。数量という連続値の範囲が条件として指定されているので、境界値テストが有効そうです。2.テストケースを考えるでは正常系の検証(値が取得できる/値が取得できない)についてのみ扱います。

普通の境界値テストであれば、事前条件として値の範囲が与えられ、入力値をその条件の境界周辺に設定して検証します。今回はすでに何らかのテストデータがあるとして、事前条件を、テストデータの値周辺に設定して検証を行います。例えばQuantity = 3のデータがすでに入っているとした場合、

minQuantity maxQuantity (条件1) AND (条件2)
3 4 true
2 3 true
3 3 true
1 2 false
4 5 false

minQuantityとmaxQuantityの組み合わせは無数にありますが、上の表を見ると、minQuantityとmaxQuantityともに3の前後で結果が変わっていることがわかります。また、結果がtrueとなった条件のうち、(minQuantity, maxQuantity) = (3, 4), (3, 3), (2, 3)の中で(3, 3)が一番範囲が狭く、最も厳しい条件であることがわかります。このことから結果がtrueの検証の場合には(minQuantity, maxQuantity) = (3, 3)で事足ります。結果がfalseの検証の場合には、3の前後で検証できれば良いので、(minQuantity, maxQuantity) = (1, 2), (4, 5)などを検証できれば事足ります。よって、検証すべき条件は以下の3通りになります。

minQuantity maxQuantity (条件1) AND (条件2)
3 3 true
1 2 false
4 5 false

また、境界値テストを使用できる場合、同値分割テストも使用することができます。再びQuantity = 3とした場合の例を考えると、minQuantity、maxQuantityの組み合わせによる有効同値パーティションは、以下のように考えることができます。

minQuantity, maxQuantity の条件 代表値の例
有効同値パーティション(productNameを取得できる) 0 ≤ minQuantity ≤ 3 かつ maxQuantity ≥ 3 minQuantity = 1, maxQuantity = 5
有効同値パーティション(productNameを取得できない) (minQuantity > 3 または 0 ≤ maxQuantity < 3)かつ minQuantity < maxQuantity minQuantity = 4, maxQuantity = 6

同値分割テストでは、先ほど分割したパーティションから少なくとも1つの代表値を選択してテストを行うため、正常系の検証には2つのテストケースがあれば事足りることになります。よって、正常系に限定した場合、同値分割テストは境界値テストよりもテストケースを少なく抑えられることがわかります。ですがトレードオフとして、境界値テストに比べて不具合の検出漏れの可能性が大きくなることに注意してください。

このことから、テスト手法を適切に導入することで、テストの有効性を大きく損なわずにテストケースを削減できることがわかります。

次は少し複雑な例2のクエリを見てみましょう。

StockRepository.java(抜粋)
    // 取扱中の中で価格が範囲内で、色・カテゴリが一致するものの商品名を取得
    @Query(
            nativeQuery = true,
            value = " SELECT p.ProductName" +
                    " FROM Stock s" +
                    " JOIN Products p ON s.ProductId = p.ProductId" +
                    " WHERE s.Quantity > 0" +
                    " AND p.Price >= :minPrice" +        // 条件1
                    " AND :maxPrice >= p.Price" +        // 条件2
                    " AND p.ColorId = :colorId" +        // 条件3
                    " AND p.CategoryId = :categoryId" +  // 条件4
                    " AND p.Dealing = :dealing;"         // 条件5
    )
    List<String> findProductNameByPriceAndColorAndCategoryAndDealing(
            @Param("minPrice") BigDecimal minPrice,
            @Param("maxPrice") BigDecimal maxPrice,
            @Param("colorId") int colorId,
            @Param("categoryId") int categoryId,
            @Param("dealing") boolean dealing
    );

例2のクエリを見てまず見てわかることは、条件1から条件5に関しては全てAND条件であるということです。なので、正しく商品名が取得できる場合では「全ての条件がtrue」、取得できない場合では「1つの条件がfalse、それ以外の条件がtrue」を検証すれば問題ありません。このようにすることで、正しく商品名が取得できない場合のテストに関しては取得できない原因が明確になり、また考える条件数を減らすことができます。

条件1 条件2 条件3 条件4 条件5
true true true true true
true true true true false
true true true false true
true true false true true
true false true true true
false true true true true

ここからは例1の場合とほとんど変わらず、テストデータを先に想定し、条件1から条件5でそのテストデータのカラムの値周辺で、事前条件(変数)を設定します。また、条件1または条件2がfalseとなる場合は、例1で考えたテストケースとほとんど同じであるため、境界値テストによる検証が可能です。

今回のように複雑な条件のテストでは組み合わせテストを使用することもあります。組み合わせテストをする場合には因子と水準を選択する必要があるため、Productsテーブルに以下のようなデータが入っている場合を考えます。

因子と水準

因子は、テスト対象の設定項目を指し、水準は因子が持つ選択肢や値のことを指します。

ProductId ProductName CategoryId ColorId Price Dealing
1 ブーツ 2 3 8000 1

テストデータを参考にして、因子と水準を以下のように設定します。

因子名 水準1 水準2
minPrice 8000以下 8000より大きい
maxPrice 8000以上 8000より小さい
categoryId 2 2以外
colorId 3 3以外
dealing 1 0

先ほど設定した因子と水準から、L8直交表を用いてテストケースを作成すると、以下のような組み合わせになります。

水準 / 因子 minPrice maxPrice categoryId colorId dealing
水準1 8000より大きい 8000より小さい 2以外 3以外 0
水準2 8000より大きい 8000より小さい 2以外 3 1
水準3 8000より大きい 8000以上 2 3以外 0
水準4 8000より大きい 8000以上 2 3 1
水準5 8000以下 8000より小さい 2 3以外 1
水準6 8000以下 8000より小さい 2 3 0
水準7 8000以下 8000以上 2以外 3以外 1
水準8 8000以下 8000以上 2以外 3 0

例2の始めに考えたテストケースと比べると、数は増えていますが、2因子網羅によって2つの因子にまたがる不具合を検出できる可能性が大きくなります。組み合わせテストはツールを使うことで機械的にテストケースを作成することができますが、何を検証したいのかを明確にしておかないと検証の意味が薄れてしまうため注意しましょう。

このように、様々なテスト手法を正しく使用することによって、少ないテストケースで有効な検証を行うことができます。

3. テストの準備

テストメソッドを実装する前に、テストの準備を行います。今回は例1で考えたテストケースについてのみ扱います。

3.1 テストプロジェクト構成

プロジェクトテスト構成
.
└── test/
    ├── java/com/example/adcal/
    │   ├── repository/
    │   │   └── StockRepositoryTest.java
    │   ├── TestContainer.java
    │   └── TestApplication.java
    └── resources/com/example/adcal/
        ├── repository/
        │   ├── StockRepositoryTest.sql
        │   └── StockRepositoryTest_rollback.sql
        └── create_table.sql

3.2 テスト用SQLの作成

3.2.1 テーブル定義

create_table.sqlに、1.1.1で確認したテーブル定義を書いていきます。必要であれば外部キーと、参照先のテーブルも記述します。

create_table.sql
create_table.sql
create table TESTDB.Colors
(
    ColorId   int not null,
    ColorName varchar(255),
    primary key (ColorId)
)
;

create table TESTDB.Categories
(
    CategoryId   int not null,
    CategoryName varchar(255),
    primary key (CategoryId)
)
;

create table TESTDB.Products
(
    ProductId   int not null,
    ProductName varchar(255),
    ColorId     int,
    CategoryId  int,
    Price       decimal,
    Dealing     tinyint unsigned,
    primary key (ProductId),
    foreign key (ColorId) references Colors (ColorId),
    foreign key (CategoryId) references Categories (CategoryId)
)
;

create table TESTDB.Stock
(
    StockId   int not null,
    ProductId int,
    Quantity  int,
    primary key (StockId),
    foreign key (ProductId) references Products (ProductId)
)
;

3.2.2 テストデータ

テストケースを考えた時に想定していたデータを、テーブルに挿入するSQLを記述します。外部キー制約を含む場合は、それも考慮して作成しなくてはいけないため注意が必要です。データが多くなり、何の検証のためのテストデータなのかわかりにくい場合にはコメントを記述するといいでしょう。

StockRepositoryTest.sql
StockRepositoryTest.sql
insert into TESTDB.Colors (ColorId, ColorName)
values (2, '黒色')
;

insert into TESTDB.Categories (CategoryId, CategoryName)
values (2, '靴');

insert into TESTDB.Products (ProductId, ProductName, ColorId, CategoryId, Price, Dealing)
values (1, 'ブーツ', 2, 2, 8000, 1);

-- Quantity = 3
insert into TESTDB.Stock (StockId, ProductId, Quantity)
values (1, 1, 3);

テストメソッドの検証後に、データベースは最初の状態に戻しておきましょう。

StockRepositoryTest_rollback.sql
StockRepositoryTest_rollback.sql
set session foreign_key_checks = 0;
truncate table TESTDB.Colors;
truncate table TESTDB.Categories;
truncate table TESTDB.Products;
truncate table TESTDB.Stock;

3.3 TestContainerのセットアップ

テストを書くための設定を行います。
ここでは、TestContainer.java、TestApplication.javaを設定します。

TestContainer.java と TestApplication.java
TestContainer.java
package com.example.adcal;

import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.test.context.support.TestPropertySourceUtils;
import org.testcontainers.containers.MySQLContainer;

@Component
public class TestContainer {
    private static MySQLContainer<?> mysqlContainer;

    public static MySQLContainer<?> getMySQLContainer() {
        return mysqlContainer;
    }

    public void init(String dockerImageName) {
        mysqlContainer = new MySQLContainer<>(dockerImageName)
                .withDatabaseName("TESTDB")
                .withUsername("root")
                .withPassword("")
                .withInitScript("com/example/adcal/create_table.sql");
        mysqlContainer.start();
    }

    public static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            String dockerImageName = applicationContext.getEnvironment().getProperty("mysql.docker.image.name", "mysql:8.0.28");
            new TestContainer().init(dockerImageName);
            MySQLContainer<?> mysqlContainer = TestContainer.getMySQLContainer();
            String driverUrl = String.format(
                    "jdbc:mysql://%s:%d/TESTDB?useUnicode=true&characterEncoding=UTF-8",
                    mysqlContainer.getHost(),
                    mysqlContainer.getFirstMappedPort()
            );
            TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
                    applicationContext,
                    "spring.datasource.url=" + driverUrl,
                    "spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver",
                    "spring.datasource.username=" + mysqlContainer.getUsername(),
                    "spring.datasource.password=" + mysqlContainer.getPassword(),
                    "spring.datasource.sql-script-encoding=utf-8"
            );
        }
    }
}

TestApplication.java
package com.example.adcal;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

大事なのはTestContainer.javaです。クラス内のinitメソッドで、テストで使用するデータベースや、テスト開始前に最初に実行するテーブル定義ファイルを指定しています。また、initializeメソッドでは、使用するDockerのイメージ(今回はMySQL8.0.28)やデータベースに接続するためのurlを指定しています。

4. テストの作成

先ほど考えたテストケースを元に実際にテストコードを書いていきましょう。まずはテストクラスの設定を見ていきます。

テストコード例
StockRepositoryTest.java

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(classes = TestApplication.class,
        initializers = TestContainer.Initializer.class)
@Sql(scripts = "/com/example/adcal/repository/StockRepositoryTest.sql")
@Sql(scripts = "/com/example/adcal/repository/StockRepositoryTest_rollback.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class StockRepositoryTest {

    @Autowired
    private StockRepository productsRepository;

    @Test
    @DisplayName("指定した範囲内の数量を在庫に持つ商品を取得")
    void Get_products_with_stock_within_a_specified_range() {
        // Arrange
        int minPrice = 3;
        int maxPrice = 3;

        //Act
        List<String> productNameList = productsRepository.findProductNameByQuantityRange(minPrice, maxPrice);

        // Assert
        assertAll(
                () -> assertEquals(1, productNameList.size()),
                () -> assertEquals("ブーツ", productNameList.get(0))
        );
    }

    // 境界値前後のテストのため、パラメータ化テストを使用
    @ParameterizedTest
    @CsvSource({
            "1, 2",
            "4, 5"
    })
    @DisplayName("指定した範囲内の数量を在庫に持つ商品が存在しない")
    void There_are_no_products_with_stock_within_the_specified_range(int minPrice, int maxPrice) {
        //Arrangeは省略

        //Act
        List<String> productNameList = productsRepository.findProductNameByQuantityRange(minPrice, maxPrice);

        //Assert
        assertTrue(productNameList.isEmpty());
    }

}

メソッドの中身を見る前に、テストクラスに複数のアノテーションが付与されています。ここでは、データアクセス層のテストで重要な@AutoConfigureTestDatabase、@ContextConfiguration、@Sqlのアノテーションに絞って紹介します。

  • @AutoConfigureTestDatabaseは、replace = AutoConfigureTestDatabase.Replace.NONE とすることで、組み込みデータベース以外を使用するようにします。今回はMySQLを使用したいため、この設定を記述します。
  • @ContextConfigurationでは、classes = TestApplication.classでTestApplication.javaを読み込み、initializers = TestContainer.Initializer.class で、事前にTestContainer.javaで設定したものが構築されます。
  • テスト開始時に、@Sql(scripts = "/com/example/adcal/repository/StockRepositoryTest.sql")
    が読み込まれ、3.2.2で作成したStockRepositoryTest.sqlが実行され、各テストメソッド実行前にデータの挿入が行われます。
  • テスト終了時に、@Sql(scripts = "/com/example/adcal/repository/StockRepositoryTest_rollback.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
    が読み込まれ、3.2.2で作成したStockRepositoryTest_rollback.sqlが実行され、各テストメソッド実行後に挿入されたデータを削除します。

5. テスト作成のGood Practices

実際にテストメソッドを記述する場合には、簡潔な命名・1つの検証単位のみ書くこと・AAAパターンに従うことで可読性に優れ、テストメソッドの役割を明確にすることができます。

5.1 簡潔な命名

テストの数が多くなると何のテストをしているかがわかりにくくなるため、厳格な命名規則を決めたくなりますが、メソッド名が長くなりかえって見づらくなってしまいます(経験談)。テストメソッドの命名は簡潔に、そのテストメソッドがどういう振る舞いをするかを1つの文に表しましょう。その際には、希望や要望などは含まず、事実のみを書くことに注意しましょう。テストコードの例を見てみます。

StockRepositoryTest.java(抜粋)

    @Test
    @DisplayName("指定した範囲内の数量を在庫に持つ商品を取得")
    void Get_products_with_stock_within_a_specified_range() {
        ...
    }

このテストは、「指定した範囲内の数量を在庫に持つ商品を取得」という振る舞いをするため、英語に変換してメソッド名としています。英語なので少し見にくいですが、JUnit5からは@DisplayNameアノテーションが使用でき、日本語で書くこともできるので、テストの可読性が向上します。次の例も見てみましょう。

StockRepositoryTest.java(抜粋)

    @ParameterizedTest
    @CsvSource({
            "1, 2",
            "4, 5"
    })
    @DisplayName("指定した範囲内の数量を在庫に持つ商品が存在しない")
    void There_are_no_products_with_stock_within_the_specified_range(int minPrice, int maxPrice) {
        ...
    }

このテストは「指定した範囲内の数量を在庫に持つ商品が存在しない」ことを検証するという振る舞いになります。名前の付け方が異なりますが、テストの振る舞いがわかるものであればそれほど問題はありません。

5.2 1つの振る舞いのみテストする

例えば、1つのテストメソッドで正常系の場合の「値を取得できないケース」全てを検証することは良くありません。可読性が悪くなるばかりでなく、何が原因で取得できないかが区別できなくなってしまうからです。なので、原則1つのテストメソッドでは1つの事前条件のみでテストを行いますが、複数の事前条件を1つのテストで検証したい場合もあります。その際にはパラメータ化テスト(JUnit5では@ParameterizedTest)を用いて、同じテスト結果で事前条件が異なる場合を検証するようにしましょう。実際にパラメータ化テストの実装を見てみます。

StockRepositoryTest.java(抜粋)

    // 境界値前後のテストのため、パラメータ化テストを使用
    @ParameterizedTest
    @CsvSource({
            "1, 2",
            "4, 5"
    })
    @DisplayName("指定した範囲内の数量を在庫に持つ商品が存在しない")
    void There_are_no_products_with_stock_within_the_specified_range(int minPrice, int maxPrice) {
        //Arrangeは省略

        //Act
        List<String> productNameList = productsRepository.findProductNameByQuantityRange(minPrice, maxPrice);

        //Assert
        assertTrue(productNameList.isEmpty());
    }

上のテストは、2.テストケースを考えるの例1で考えたテストです。(minQuantity, maxQuantity) = (1, 2), (4, 5)両方のケースともに境界値の外での検証のため、同一テストで検証を行うことができます。また、データの更新や追加の処理を検証したい場合には、その処理の前後で別のテストメソッドとして切り出してテストするようにしましょう。

5.3 AAAパターン

AAAパターンは、準備(Arrange)・実行(Act)・検証(Assert)の頭文字をとった名前で、これに従うことでテスト構造が統一され、テストの可読性が向上します。AAAのそれぞれの役割は以下のとおりです。

フェーズ 説明
Arrange テストケースの事前条件を設定する。
Act 対象リポジトリのメソッドを実行し、出力結果を取得する。取得したデータを一意にソートするなどの加工も行う。
Assert 出力された結果が想定通りかを検証する。

このパターンに従って作成した例を見てみます。

StockRepositoryTest.java(抜粋)

    @Test
    @DisplayName("指定した範囲内の数量を在庫に持つ商品を取得")
    void Get_products_with_stock_within_a_specified_range() {
        // Arrange
        int minPrice = 3;
        int maxPrice = 3;

        //Act
        List<String> productNameList = productsRepository.findProductNameByQuantityRange(minPrice, maxPrice);

        // Assert
        assertAll(
                () -> assertEquals(1, productNameList.size()),
                () -> assertEquals("ブーツ", productNameList.get(0))
        );
    }

Arrangeで事前条件である数量の範囲を設定しています。Actでその設定値を元にクエリの実行をして、その出力結果をAssertで確認しています。Assertに関しては、JUnit5からassertAllというメソッドが使用できます。assertAllを使用しない場合だと1つのassertメソッドが失敗するとそれ以降の検証ができませんでしたが、assertAllに含めることで、失敗してもそこで止まらずに全てのassertメソッドを検証できるようになり、便利です。

この準備・実行・検証の順番は守られるべきで、順番の手戻りは原則禁止です。また、準備と検証は1つのテストメソッドには大きすぎることもあるため、適宜別のメソッドとして切り出すことも必要です。

まとめ

私はリポジトリテストを通じて、テスト対象にとってどういったテストが必要なのかという視点が磨かれたと思います。境界値テストや同値分割テストなどのテスト観点を学ぶことで、効率の良いテストを実装できるようになったと感じています。それにより必要なテストデータ・テストケースが明確になり、テスト作成に費やす時間を削減することができました。

参考

単体テストの考え方/使い方 [著] Vladimir Khorikov
この一冊でよくわかるソフトウェアテストの教科書 [著] 布施昌弘、江添智之、永井努、三堀雅也

WealthNavi Engineering Blog

Discussion