記事の概要
テストフィクスチャのセットアップに関するコードは冗長化しやすい。
よって、セットアップの実装はメンテナンス性、可読性を高める工夫が重要となる。
この記事では、テストフィクスチャのセットアップの可読性、
メンテナンス性を高めるための様々な手法についてまとめる。
テストフィクスチャとは
広義のテストフィクスチャ
テストを構成する要素全般をひとまとめに呼んだもの。
テストフィクスチャには、以下のようなものが含まれる。
- テスト対象
- 入力値(データ)
- テスト実行のための予備操作
- 外部のリソース
- 検証用データ(想定値など)
狭義のテストフィクスチャ
狭義には、入力値や検証用データなどのデータ群をテストフィクスチャと呼ぶ場合もある。
ユニットテストのテストフィクスチャ
ユニットテストのテストフィクスチャには以下のような要素が含まれる。
- テスト対象のオブジェクト(ふるまいを定義したもの)
- テストの実行に必要なオブジェクト(入力値)
- テストの検証に必要なオブジェクト(想定値)
- テスト実行に必要なオブジェクトを生成するための操作
- ファイルなどの外部リソース
- データベースサーバ、SVFサーバなどの外部システム
- 依存クラスや依存外部システムのモックオブジェクト
フィクスチャに関する用語
フレッシュフィクスチャ
ユニットテストでは、フィクスチャが
- テストケースごとに独立し
- テストの実行ごとに初期化され
- 終了時に全て解放される
ことが基本的な戦略。この戦略をフレッシュフィクスチャという。
JUnitにおいても、インスタンスやローカル変数に保持されたテストデータ(フィクスチャ)は
各テストケースの終了時にガベージコレクタによって破棄される。
フレッシュフィクスチャを守る理由
【テストケースごとに独立していない場合】
ケース:
テストフィクスチャを何でもかんでも共通化し、1つのUtilクラスで定義
発生する問題:
1つのケースの事前条件だけを変える時、その他のケースへの影響を考える必要が発生する。
考慮するケースが1つだけならまだ管理できるが、テストケース、変えたい条件が増えると
その影響範囲の管理はより複雑になっていく。
【テストの実行ごとに初期化されない場合】
ケース:
シングルトンパターンを採用し、複数の処理で状態を変える操作を行う
発生する問題:
テストの実行順序によってオブジェクトの状態が変わり、予期せぬテスト結果になる。
(テストを並列実行した場合、問題はより複雑になる)
【終了時に全て解放されない場合】
ケース:テストの実行前後でデータベースを初期化せず、続けてテストを行う
発生する問題:
データ状態が想定と異なり、予期せぬテスト結果になる。
スローテスト問題
スローテスト問題とは、データベースを扱うテストの実行時に発生する問題である。
原因としては、データベースのテストにはケース毎に対象テーブルの全データ削除、
必要なデータの挿入を行わなければならないことが挙げられる。
問題の解決方法としては、以下の3つが有効である。
- テストの並列実行
⇨ 同じ時間で実行できるケース数を増やす - 共有フィクスチャの利用
⇨ 同じデータ状態のテストをまとめてデータの削除と挿入をなくす - カテゴリ化テスト
⇨ 1度のテストで実行するケース数を減らす
スローテスト問題の解消はテストの実行時間が何十分に膨れ上がってから取り組む。
上記3つの手法はどれも、フレッシュフィクスチャの原則から外れているためである。
共有フィクスチャ
共有フィクスチャとは、スローテスト問題を解決するための手段のひとつ。
複数のテストケースでフィクスチャを共有、再利用することでセットアップコストを抑える。
問題点
- テストケースの独立性が弱くなる
⇨ テストの実行順序や他のケースの実行結果が共有のフィクスチャに影響を与える場合、
各テストケースが影響を与え合うようになる。 - テストコードのメンテナンス性が低下する
⇨ ケース数が増えると、フィクスチャを共有するケースの管理コストが上がる。
また、共有フィクスチャは適切な後処理を考慮するコストも生まれる。
共有フィクスチャが適しているケース
上記の問題点から、共有フィクスチャはできる限り使用しない方がよい。
だが、「共有フィクスチャを不変(イミュータブル)なオブジェクトとする」場合は例外となる。
フィクスチャが不変であれば、テストの実行順序や結果、並列処理を行なっても
オブジェクトの状態が変化することはないからである。
フィクスチャのセットアップパターン
インラインセットアップ
テストメソッドの中でセットアップを行う、最も基本的なパターン。
メソッド内でテストコードが完結するため、見通しが良くなる。
セットアップが単純な場合は積極的に利用するべきパターンである。
@Test
public void インスタンス化した結果がNULLでないこと() throws Exception {
// インラインセットアップ
List<String> actual = new ArrayList<>();
// 検証
assertThat(actual, is(nullValue()));
}
ただし、セットアップ内容が複雑になるとセットアップの記述が
テストの実行、検証のコードよりも長くなり可読性が低下する。
@Test
public void 在庫数が0以上の場合在庫があると判断できること() throws Exception {
ItemDto apple = new ItemDto();
apple.setItemId("001");
apple.setItemName("りんご");
apple.price(500);
apple.discountRate(3);
apple.setExpirationDate("2020/04/30");
apple.locationId("A01");
apple.stockNum(5);
apple.storeId("008");
WareHouse sut = new WareHouse();
assertThat(sut.hasStockOf(apple), is(true));
}
暗黙的セットアップ
BeforeアノテーションやBeforeClassアノテーションを付与したメソッド、
つまりセットアップ処理を抽出したメソッドはセットアップメソッドと呼ばれ、
各テストメソッド実行前に暗黙的に実行される。
セットアップメソッド内でフィクスチャのセットアップを行うパターンを
暗黙的セットアップと呼ぶ。
暗黙的セットアップはEnclosedテストランナーを利用したユニットテストで有効である。
構造化することで、そのテスト群が何を前提条件としているかが
分かりやすくなるなどのメリットがあるためである。
@RunWith(Enclosed.class)
public class WareHouseTest {
public static class 倉庫が空の場合 {
WareHouse sut;
@Before
public void setUp() throws Exception {
sut = new WareHouse();
}
@Test
public void 倉庫は空() throws Exception {
assertThat(sut.isEmpty(), is(true));
}
}
public static class 倉庫が空でない場合 {
WareHouse sut;
@Before
public void setUp() throws Exception {
sut = new WareHouse();
sut.store(createItemDto("りんご", 3));
}
@Test
public void 倉庫は空でない() throws Exception {
assertThat(sut.isEmpty(), is(false));
}
}
private ItemDto createItemDto(String itemName, int num) {
ItemDto item = new ItemDto();
item.setItemName(itemName);
item.setNum(num);
return item;
}
}
生成メソッドでのセットアップ
暗黙的セットアップは、1つのテストクラス内でしか共通処理を抽出できない。
複数のテストクラスで共通処理を抽出する場合には、生成メソッドのパターンを適用する。
通常、生成メソッドはstaticメソッドとして定義し、
XXXTestUtilsのようなUtilクラスにまとめる事が多い。
public class ItemTestUtils {
public static ItemDto 商品情報を作成する(String itemName, int num) {
ItemDto item = new ItemDto();
item.setItemName(itemName);
item.setNum(num);
return item;
}
}
import test.utils.ItemTestUtils.*;
〜省略〜
public class WareHouseTest {
@Test
public void りんごを追加すると倉庫が空でなくなる() {
WareHouse sut = new WareHouse();
sut.store(商品情報を作成する("りんご", 1));
assertThat(sut.isEmpty(), is(false));
}
@Test
public void りんごを5個追加すると倉庫にりんごが5個あると表示される() {
WareHouse sut = new WareHouse();
sut.store(商品情報を作成する("りんご", 5));
assertThat(sut.get("りんご").getNum(), is(5));
}
}
外部リソースからのセットアップ
根本的な問題として、Javaの言語仕様は宣言的なコードを記述しづらい。
よって、先ほどから行なっているオブジェクトの生成などによる
データ作成は冗長なコードになりやすい。
そこで、データ準備は宣言的なコーディングを得意とする
外部リソースに任せるパターンが有効となる。
YAMLを使ったセットアップ
YAML(YAML Ain't a Markup Language)は、
Rubyの設定ファイルなどによく使用されるファイル形式である。
YAMLの読み書きにはライブラリが必要であるため、Snake Yamlなどのライブラリを追加する。
外部リソースのセットアップでは、データ定義とテストケース定義が別ファイルとなるため、
相互参照をしづらくなるという難点がある。
(item_fixtures.yamlのコードを隠すとappleがどのような値を持っているか分からなくなる)
!!domain.ItemDto
itemName: りんご
num: 1
producer: !!domain.Producer
firstName: Yamada
lastName: Taro
※上記のyamlファイルはテストクラスと同じパッケージに配置する
@Test
public void 倉庫はりんごの在庫を持っている() throws Exception{
WareHouse sut = new WareHouse();
ItemDto apple = (ItemDto) new Yaml()
.load(getClass().getResourceAsStream("item_fixtures.yaml"));
sut.store(apple);
assertThat(sut.has("りんご"), is(true));
}
@Test
public void りんごの生産者は山田太郎である() throws Exception{
WareHouse sut = new WareHouse();
ItemDto apple = (ItemDto) new Yaml()
.load(getClass().getResourceAsStream("item_fixtures.yaml"));
sut.store(apple);
String producerName =
sut.get("りんご").getFirstName() + sut.get("りんご").getLastName();
assertThat(producerName, is("YamadaTaro"));
}
Groovyを使ったセットアップ
GroovyはJVM上で動作する「オブジェクト指向」で「スクリプト言語機能」を持った
「動的型付け」による型宣言を行う言語である。
簡単にいうと、Javaと同じ環境で動いて、
Javaが丁寧語だとしたら話し言葉的に宣言ができるよという言語。
テストコードにのみGroovyのライブラリを導入すればプロダクションコードに影響も与えず、
以下のようにUtilクラスを作成する事が可能である。
class ItemGroovyTestUtils {
static ItemDto ItemDtoの作成_山田太郎のりんご() {
new ItemDto(
itemName: "りんご",
num: 1,
producer: new Producer(
firstName: "Yamada",
lastName: "Taro",
),
)
}
}
参考文献
この記事は以下の情報を参考にして執筆しました。