この記事は、NTTテクノクロス Advent Calendar 2023 シリーズ1の7日目の記事です。
こんにちは。NTTテクノクロスの際田です。普段は社内の開発プロセス効率化、テスト自動化周りの支援に携わっています。
最近、ラムダノート株式会社の『実践プロパティベーステスト -PropErとErlang/Elixirではじめよう-』という本を読んで、プロパティベーステストという手法を知りました。
せっかく読んだしやってみよう、と思ったのですが、この本の例はErlang/Elixirという通好み(?)な言語なので、お仕事でも使えそうな言語でできないかと考えました。調べたところ、fast-checkというJavaScript/TypeScriptのライブラリがあるようなので、こちらを使ってプロパティベーステストをやってみたいと思います。
プロパティベーステストとは
プロパティベーステストとは、自動テストの手法の一つで、「システムのあるべき挙動を満たす条件」をプロパティと呼び、その条件を満たすであろう入力をランダムに自動生成し実行することで、想定していない挙動をしないかどうか検証する手法です。
従来のユニットテストのように明示的に入力例を与えて挙動を検証する手法は、「事例ベースのテスト」と呼ばれますが、そのような手法では、テストデータが肥大化したり、テストケースをカバーしきれなくて取りこぼしが発生したりしがちですが、プロパティベーステストでは、プロパティとしてコードで表現された仕様に対して大量の自動生成されたデータを投入して検証することにより、その問題を解消しています。
シンプルな例
fast-checkの公式チュートリアルで紹介されている例です。
(※コメントの日本語化と、3つめのテストは元が間違ってる気がするので改変しました)
JavaScriptのsort関数を使ったコードで、ユニットテストがきちんと用意されています。
/**
* 数値の要素を昇順にソートする
* @param {number[]} numbers - ソート対象の数列
* @returns {number[]} - 最小値から最大値まで昇順にソートされた数列
*/
function sortNumbersAscending(numbers) {
const sortedNumbers = numbers.slice(0, numbers.length).sort();
return sortedNumbers;
}
export { sortNumbersAscending };
test('ソート済みの数列はそのまま', () => {
expect(sortNumbersAscending([1, 2, 3])).toEqual([1, 2, 3]);
});
test('バラバラに並んだ数列は昇順に並ぶ', () => {
expect(sortNumbersAscending([3, 1, 2])).toEqual([1, 2, 3]);
});
test('降順に並んだ数列は昇順に並ぶ', () => {
expect(sortNumbersAscending([3, 2, 1])).toEqual([1, 2, 3]);
});
いくつかの例を与えて、ソートされていることを検証し、オールグリーン、バッチリだ!と思えますが、このコードにはバグがあります。この問題を炙り出すために、プロパティベーステストを書きましょう。(fcがfast-checkのモジュールです)
test('数列を与えたら昇順に並ぶ', () => {
fc.assert( // プロパティが失敗したら報告する
// プロパティの本体。ランダムに整数の配列を生成する
fc.property(fc.array(fc.integer()), (data) => {
const sortedData = sortNumbersAscending(data); // テスト対象の実行
for (let i = 1; i < data.length; ++i) {
// 事後条件の検証。「ソートされている」ということは、次の値は直前の値より常に大きい
expect(sortedData[i - 1]).toBeLessThanOrEqual(sortedData[i]);
}
}),
);
});
好みのテストランナーを使い、テストケースを記述します。fc.assert()でプロパティの結果を検証します。fc.propertyにはデータを生成する関数と、そのデータを使ってテストを実行する関数を渡します。関数の中で結果を検証し、それが失敗したらfc.assertが検出してレポートを出力します。
上の例のようなテストを書くと、ランダムな整数の配列を大量に生成して、それらが全て条件を満たしてパスするかを検証してくれます。
テストを実行すると、下記のようなメッセージで失敗します。
FAIL sort.test.mjs > 数列を与えたら昇順に並ぶ
Error: Property failed after 1 tests
{ seed: -174627523, path: "0:1:0:5:2:2:1:1:3:5:1:1:2:3:3:2:4:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3", endOnFailure: true }
Counterexample: [[1000000000,2]]
Shrunk 44 time(s)
Got AssertionError: expected 1000000000 to be less than or equal to 2
少しこみいっていますが、Counterexmaple
が失敗したときのデータです。[1000000000,2]
をソートしたとき、結果が[1000000000,2]
のままだったようです。
1000000000はあきらかに2より大きいので、この結果はおかしいです。なぜこうなってしまったのでしょうか。
実はJavaScriptのsort関数は、配列の要素を文字列に変換して比較します。文字列比較では"2"のほうが"1000000000"より大きくなるため、想定した結果になりませんでした。
正しい結果を得るためには、プロダクトコードを下記のようにする必要がありました。
/**
* 数値の要素を昇順にソートする
* @param {number[]} numbers - ソート対象の数列
* @returns {number[]} - 最小値から最大値まで昇順にソートされた数列
*/
function sortNumbersAscending(numbers) {
const sortedNumbers = numbers.slice(0, numbers.length).sort((a, b) => {
return a < b ? -1 : 1 // 比較関数を設定し、そのまま比較する
});
return sortedNumbers;
}
export { sortNumbersAscending };
これで、プロパティベーステストも成功するようになります。
✓ sort.test.mjs (4)
✓ 数列を与えたら昇順に並ぶ
✓ ソート済みの数列はそのまま
✓ バラバラに並んだ数列は昇順に並ぶ
✓ 降順に並んだ数列は昇順に並ぶ
Test Files 1 passed (1)
Tests 4 passed (4)
Start at 11:58:24
Duration 485ms (transform 35ms, setup 0ms, collect 127ms, tests 15ms, environment 0ms, prepare 89ms)
このような問題を事例ベースのユニットテストで拾おうとすると、テストケースの中に「数値としては大きいが先頭桁で比較したとき小さくなる」データが含まれている必要があります。JavaScriptに詳しい人ならあるあるとして意識的にそういうものを含めたり、レビューで指摘したりできるかもしれないですが、そうでない人が簡単な例だけですますと漏れが発生します。
プロパティベーステストでは、「入力として受け入れられる大量のランダムデータ」と、「それを受けとったときのあるべき挙動」を両方定義することでこのような漏れをカバーすることができます。
(補足)テストの再現性について
プロパティベーステストでは、ランダムな入力データを生成してテストする都合上、実行ごとの結果の再現性がありません。上記の例も、実行した場合ごとに失敗する具体的な値は異なる可能性があります。
同じテストを実行するためには、テストレポートに含まれるseedをテストに渡してやる必要があります。
{ seed: -174627523, path: "0:1:0:5:2:2:1:1:3:5:1:1:2:3:3:2:4:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3", endOnFailure: true }
これを下記のようにテストに渡して実行することで、同じデータでテストすることができます。
test('数列を与えたら昇順に並ぶ', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
/* code of the predicate */
}),
// 追加
{ seed: -174627523, path: "0:1:0:5:2:2:1:1:3:5:1:1:2:3:3:2:4:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3:3", endOnFailure: true }
);
});
複雑な例(状態に依存したテスト)
プロパティベーステストは、これまで紹介したような、単体レベルのコードの検証にも使えますが、複数のプロセスにまたがる結合テストや、その中での状態に依存したテストにも利用できます。従来のテストで状態に依存したテストをやろうとすると、テストのsetupでその状態を作り出すテストデータを流し込んで、テストを実行して、teardownで元に戻して…といったようになるかと思います。このやり方は、適切な状態を作り出すためのテストデータが大きくなりすぎたり、setupの手順が複雑化したりと、なにかと大変です。
プロパティベーステストでは、下記の要素を準備することにより、状態を作るための準備や複雑なテストデータを用意することなく、状態に依存したテストを行うことができます。
- 検証対象のシステムの状態/挙動を簡素に表現した「モデル」
- 検証対象の状態が成立しているかどうかを判定する「事前条件」
- 事前条件を満しているとき、想定する挙動をしたかどうかを判定する「事後条件」
ちなみに、このような形で行うテストは、『実践プロパティベーステスト』では「ステートフルプロパティテスト」、fast-checkのドキュメントでは「モデルベーステスト」と呼ばれています。
次の節では、そのような複数のプロセスと状態に依存したテストを扱う例を見てみます。
ケーススタディ: 書籍の貸出システム
ここでは、『実践プロパティベーステスト』で取り上げられている、書籍の貸出システムを例にとり、同じ内容をTypeScriptとfast-checkで実装しながらステートフルプロパティテストをやってみたいと思います。
ここで扱うシステムは、PostgreSQLを使って書籍を管理する、いわゆるリポジトリ層のコードです。アプリケーションとデータベースが連携する結合テストであり、状態を扱う必要があります。
システムの概要は下記の通りです。
データ 書籍(books)
項目 | 説明 |
---|---|
isbn | ISBN(国際標準図書番号)。各書籍に付与されたユニークな番号 |
title | 書籍のタイトル |
author | 書籍の著者 |
owned | 在庫数 |
available | 貸し出し可能数 |
機能
関数名 | 説明 |
---|---|
addBook | 書籍の新規登録 |
addCopy | 在庫追加 |
borrowCopy | 在庫貸し出し |
returnCopy | 貸し出し本返却 |
findBookByIsbn | ISBNによる書籍検索 |
findBookByTitle | タイトルによる書籍検索(中間一致、複数結果) |
findBookByAuthor | 著者による書籍検索(中間一致、複数結果) |
大変シンプルです。書籍を登録すると在庫貸出や検索ができるようになり、在庫貸し出しすると貸し出し可能数が1減り、返却すると1増えます。
貸し出し可能な在庫が尽きていたら貸し出しできません。在庫数を越えて返却されても受け付けられません。
コードはこんな感じになりました。
書籍貸出リポジトリのコード
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { like, eq, lt, gt, and, sql } from "drizzle-orm";
import postgres from "postgres";
import { books } from "./schema";
type ISBN = string;
type Book = {
isbn: ISBN;
title: string;
author: string;
owned: number;
available: number;
};
const connection = postgres("postgres://0.0.0.0:5432/bookstore_db");
const db = drizzle(connection);
const teardown = async () => {
await db.execute(sql`TRUNCATE TABLE books;`);
await connection.end();
};
const addBook = async (
isbn: string,
title: string,
author: string,
owned: number,
avail: number
): Promise<void> => {
await db.insert(books).values({
isbn: isbn,
title: title,
author: author,
owned: owned,
available: avail,
});
};
const addCopy = async (isbn: string): Promise<void> => {
const updated = await db
.update(books)
.set({
owned: sql`books.owned + 1`,
available: sql`books.available + 1`,
})
.where(eq(books.isbn, isbn))
.returning({ isbn: books.isbn });
if (updated.length === 0) {
throw new Error(`Not Found: ${isbn}`);
}
};
const borrowCopy = async (isbn: string): Promise<void> => {
const copy = await db.select().from(books).where(eq(books.isbn, isbn));
if (copy.length === 0) {
throw new Error(`Not Found: ${isbn}`);
} else if (copy[0].available === 0) {
throw new Error(`Unavailable: ${isbn}`);
}
const updated = await db
.update(books)
.set({
available: sql`books.available - 1`,
})
.where(eq(books.isbn, isbn))
.returning({ isbn: books.isbn });
};
const returnCopy = async (isbn: string): Promise<void> => {
const updated = await db
.update(books)
.set({
available: sql`books.available + 1`,
})
.where(and(eq(books.isbn, isbn), lt(books.available, books.owned)))
.returning({ isbn: books.isbn });
if (updated.length === 0) {
throw new Error(`Not Found: ${isbn}`);
}
};
const findBookByIsbn = async (isbn: string): Promise<Book | null> => {
const res = await db.select().from(books).where(eq(books.isbn, isbn));
if (res.length === 0) {
return null;
}
return res[0];
};
const findBookByTitle = async (title: string): Promise<Book[]> => {
return await db
.select()
.from(books)
.where(like(books.title, `%${title}%`));
};
const findBookByAuthor = async (author: string): Promise<Book[]> => {
return await db
.select()
.from(books)
.where(like(books.author, `%${author}%`));
};
export {
ISBN,
Book,
addBook,
addCopy,
borrowCopy,
returnCopy,
findBookByIsbn,
findBookByTitle,
findBookByAuthor,
teardown,
};
これをどのようにテストすべきでしょうか。在庫が十分にある状態や枯渇した状態を作り出すテストデータを用意して、テストケースごとにデータベースに登録し、各関数をテストするのは事例ベースのやり方です。
プロパティベーステストでは、「状態にもとづいた操作」を「コマンド」として定義し、ランダムにデータを投入しながら、コマンドの事前条件を満している場合にだけテストを実行し、結果が事後条件を満しているかを検証します。
コマンドになりうるシステムの操作と事後条件は、下記のようなものがあります。(『実践プロパティベーステスト』p260から抜粋)
- 「まだシステムに登録されていない本を追加する」に期待されるのは「成功」
- 「すでにシステムに登録されている本を追加する」に期待されるのは「失敗」
- 「すでにシステムに登録されている本の在庫を1冊追加する」に期待されるのは「成功」(すぐに在庫が1冊増える)
- 「まだシステムに登録されていない本の在庫を1冊追加する」に期待されるのは「失敗」
... etc
このような操作を全て棚卸しして、「モデル」「事前条件」「事後条件」を考慮しながらコマンドとして定義していきます。
コマンドを定義するやり方として、『実践プロパティベーステスト』ではErlang/Elixirを使っているので、「シム」と呼ばれるヘルパ関数を定義して、それに対してシンボリックコールを使い、事前条件/事後条件を紐付けて呼びだすようにしています。同じようなことをやりたいけどTypeScriptだとどうすればいいかなぁと思ったら、fast-checkではCommandというクラスがあり、それを使えばよさそうです。
「まだシステムに登録されていない本を追加する(addNewBook)」を表現するコマンドは下記のようになります。
class AddBookNewCommand implements fc.Command<Model, BookRepository> {
constructor(readonly value: Book) {}
check = (m: Readonly<Model>) => {
return !hasIsbn(m, this.value.isbn);
};
async run(m: Model, r: BookRepository): Promise<void> {
await r.addBook(
this.value.isbn,
this.value.title,
this.value.author,
this.value.owned,
this.value.available
);
m.set(this.value.isbn, this.value);
}
toString = () => `AddBookNew (${this.value.isbn})`;
}
fc.Commandでは、コンストラクタで検証を実行するためのデータを受けとり、checkメソッドでモデルが事前条件を満しているか検証します。
事前条件を満していればrunメソッドが実行され、そこで実システムへの操作の実行と、モデルの状態の更新を行います。
モデルとは、システムに期待する状態を表現できるデータ構造と、その状態の変更を反映することができる操作をもった正確で簡潔なオブジェクトです。ここでは、ModelはISBNをキー、書籍を値に持ったMapにしています。
type Model = Map<ISBN, Book>;
type ISBN = string;
type Book = {
isbn: ISBN;
title: string;
author: string;
owned: number;
available: number;
};
検証対象の実システムBookRepositoryはデータベースとやりとりする複雑なオブジェクトですが、「書籍を登録し、検索や更新ができる」という挙動を表すためには単なるMapで十分です。
プロパティベーステストでは、このように実システムを正確で簡潔な形にモデル化し、そのモデルの挙動と実システムの挙動を比較することによって検証を行います。モデルとシステムの状態や挙動が食い違ったら、システムにバグがあるとみまします。(もしくはモデル化かモデル操作が間違っている)
AddBookNewCommandクラスに戻ると、コンストラクタで登録のための書籍を受け取り、checkメソッドで事前条件(同じISBNが登録されていないこと)を検査しています。事前条件を満しているなら、runメソッドが実行され、実システムrのaddBookメソッドを呼び出します。通常ここで成否判定のアサーションが入りますが、addBookは例外を投げなければ成功なのでとくにチェックしていません。その後、システムに期待するのと同じ状態の変化(新しい本が追加される)をモデルに適用します。
次に、「すでにシステムに登録されている本を追加する(addBookExisting)」を表現するコマンドを定義します。
class AddBookExistingCommand implements fc.Command<Model, BookRepository> {
constructor(readonly value: Book) {}
check = (m: Readonly<Model>) => {
return hasIsbn(m, this.value.isbn);
};
async run(m: Model, r: BookRepository): Promise<void> {
await expect(
r.addBook(
this.value.isbn,
this.value.title,
this.value.author,
this.value.owned,
this.value.available
)
).rejects.toThrowError();
}
toString = () => `AddBookExisting (${this.value})`;
}
Bookを受けとるのは同じですが、事前条件が「受け取ったBookと同じISBNがすでにモデルに含まれていること」になっています。なお、各コマンドに渡されるモデルは、それまで実行されたコマンドで行なわれた状態操作を引き継いでいます。この状態でaddBookに期待されるのは例外を発生することです。例外が発生せずに、登録されてしまったらテストは失敗し、レポートが出力されます。
このような形で、システムの状態と期待する結果を全て棚卸しし、コマンドとして定義したら、テストを作ります。
// ... 省略 型の定義やヘルパ関数が書かれている
// Model for property
const state: Model = new Map<ISBN, Book>();
// Implementation of BookRepository
const bookRepository = {
addBook: addBook,
addCopy: addCopy,
borrowCopy: borrowCopy,
returnCopy: returnCopy,
findBookByIsbn: findBookByIsbn,
findBookByTitle: findBookByTitle,
findBookByAuthor: findBookByAuthor,
};
// define the possible commands and their inputs
const allCommands = [
// always possible
book().map((v) => new AddBookNewCommand(v)),
isbn().map((v) => new AddCopyNewCommand(v)),
isbn().map((v) => new BorrowCopyUnknownCommand(v)),
isbn().map((v) => new ReturnCopyUnknownCommand(v)),
isbn().map((v) => new FindByISBNUnknownCommand(v)),
title().map((v) => new FindByTitleUnknownCommand(v)),l
author().map((v) => new FindByAuthorUnknownCommand(v)),
// relies on state
fc.gen().map((gen) => {
return new AddBookExistingCommand(bookInState(gen));
}),
fc.gen().map((gen) => new AddCopyExistingCommand(isbnInState(gen))),
fc.gen().map((gen) => new BorrowCopyAvailableCommand(isbnInState(gen))),
fc.gen().map((gen) => new BorrowCopyUnavailableCommand(isbnInState(gen))),
fc.gen().map((gen) => new ReturnCopyExistingCommand(isbnInState(gen))),
fc.gen().map((gen) => new ReturnCopyFullCommand(isbnInState(gen))),
fc.gen().map((gen) => new FindByISBNExistsCommand(isbnInState(gen))),
fc.gen().map((gen) => new FindByTitleMatchingCommand(titleInState(gen))),
fc.gen().map((gen) => new FindByAuthorMatchingCommand(authorInState(gen))),
];
describe("property based test example", () => {
afterAll(async () => {
await teardown();
});
test("model based test for book repository", async () => {
// run everything
await fc.assert(
fc.asyncProperty(fc.commands(allCommands), async (cmds) => {
const s = () => {
return {
model: state,
real: bookRepository,
};
};
await fc.asyncModelRun(s, cmds);
}),
{ verbose: true }
);
});
});
前半、allCommandsに16個のコマンドが登録されています。後半のテストではそれらのコマンドを実行するasyncPropertyが実行されています。コマンドの実行では、初期状態と実システムを生成する関数sとコマンドをfc.asyncModelRunに渡しています。
book(), isbn(), title(), author()といった関数はジェネレータと呼ばれる、ランダムなデータを生成するヘルパ関数です。isbn(), title(), author()はランダムな文字列を生成し、book()はそれらを組合せた書籍データを生成します。
book().map((v) => new AddBookNewCommand(v))のように書くことで、book()で大量のランダムな書籍データを生成し、AddBookNewCommandを呼び出します。呼びだされたコマンドは、事前条件が満されているなら、操作を実行しモデルの状態を更新します。
fc.gen()はテストの実行中に任意の値を生成するための関数です。AddCopyExistingCommandの以降のコマンドはそれまで実行されたModelの状態に依存して値を生成しなければいけないので、gen()を使いテスト実行時にModelに登録されているISBNなどのデータを取得し、コマンドに渡しています。
その他のコマンドも同じように実行されていくなかで、事後条件を満たさないものがあればテストは失敗します。
このようにして、ステートフルプロパティテスト/モデルベーステストをfast-checkで実行できました。
このような形でテストを行うことは、下記のようなメリットがあると考えます。
- モデルとしての状態・事前条件・事後条件をコンパクトにまとめたコマンド群と、それらのリストを定義するだけで、見通しよく大量のテストを実行できる
- 状態を作り出すためのデータや前処理を頑張って書く必要がない。システムにランダムデータを投入する中で状態は勝手に作られていくので、そのときだけ対象のテストが実行される
- 機械的に生成されたデータで大量にテストするので、人手ではみつけにくいバグを踏んでくれる。たとえば文字列なら、null文字、エスケープシーケンス、マルチバイト文字列などを組み合わせたものが生成されるので、思いがけないエラーが発生したりする
状態を扱うテストは難しいことが多いので、プロパティベーステストを上手く活用できる場面もありそうです。
まとめ
この記事では、プロパティベーステストについて紹介し、JavaScript/TypeScriptのプロパティベーステストライブラリ、fast-checkを用いてプロパティベーステストの例題をやってみました。
それを通してプロパティベーステストには下記のようなメリットがあることが分かりました。
- プロパティベーステストは、単体レベルの検証にも、システムの結合レベルの検証にも使える
- システムに期待する挙動をモデル化し、実際のシステムの挙動と比較することでシステムがやるべきことが明確になる
- 機械的に生成された大量のテストデータで実行することで、人手では作りにくい状態やテストケースをカバーすることができる
参考にした『実践プロパティベーステスト』はErlang/Elixirですが、fast-checkを使うことで、TypeScriptでも概ね同じようなことが可能だということが分かったので、お仕事にも活用できそうです。
一方で、次のような点では難しさがあると感じました。
- プロパティは書くのも、失敗したときのレポートを読み解くのも結構大変
- システムに期待する挙動、事前条件、事後条件を整理した形で形式化すること自体がそもそも難しいので、経験が必要
自動生成されたヒューマンリーダブルとは言いがたいデータで大量実行した結果のエラーが表示されるので、なにが起きて失敗したのかさっぱり分からないことがあります。
この記事では分量の都合上、プロパティで検出されたエラーレポートの見かたや、バグを修正していく過程にはあまり触れられませんでしたが、慣れないとレポートの解析とテストの修正でかなり時間を取られそうです。
また、システムに期待される挙動を正確で簡潔に表現する、というモデル化も、今回の例のデータベース<=>Mapのような分かりやすい対応でモデル化できればよいですが、現実の複雑なシステムになると、簡潔なモデルを見つけるのは難しそうだと思いました。
モデル自体もそこそこの量のコードになってしまい、バグを含み、システムをテストしているのかモデルのテストをしているのか分からなくなりそうな感じがします。そうならないように腕を磨く必要があると感じました。
参考
この記事は下記を参考に書かれました。プロパティベーステストについて、より詳しく知りたい方は是非こちらも読んでみてください。
-
fast-check チュートリアル
- 「はじめてのプロパティベーステスト」として、ソートの例が載っています。fast-checkの詳しい使い方については本家のチュートリアル、ドキュメントを参照してください。
-
fast-check Model based testing
- アドバンストなトピックとしてモデルベーステストについて説明されています。
-
実践プロパティベーステスト
- この記事では紹介しきれなかったプロパティベーステストの詳しい解説・プロパティやプロダクトコード、テストを改善するための実践的なテクニックが多数書かれています。プロパティベーステストやErlang/Elixirについてこんなに詳しい内容が日本語で読めるのはラムダノート株式会社の本だけ!
-
プロパティベーステスト (Property Based Testing) を Ruby で書き雰囲気を味わう
- Rubyでプロパティベーステストを実践した記事。Rubyの方が得意だよ、という方はこちらも参考になさってください
今回利用した書籍貸出システムのサンプルコードはこちらに置いています。
もしもサンプルコードや説明に間違いあったら、ご指摘いただけると幸いです。
明日は、@inoue-mnさんの「アドカレ一人完走を書籍化してみた」です!