読者です 読者をやめる 読者になる 読者になる

三流プログラマが脱三流するために書くブログ

PHP, オブジェクト指向プログラミング, デザインパターン, リファクタリング, DDD, 関数プログラミング, etc.

5.「知識ゼロから学ぶソフトウェアテスト 【改訂版】」を読んだ (1) 〜 ホワイトボックステストとブラックボックステスト

読書 テスト

動機

ソフトウェアテストに関する知識や技術が足りてないなぁというのが積年の課題でしたが、基本的にテストが嫌いでなんとなくで済ませていたところがありました。 昨今はテストの自動化が当たり前になってきていて、単体テストを書くのは楽しいし、実際不具合も減らせているので、徐々にテストの量は増えてきてはいるんですが、まだまだ自信を持って書けていないのが正直なところです。

脱三流を目指すからには基礎的な知識を押さえておかないといけないな、ということで読んでみました。

この本はテストエンジニアのために書かれたものですが、テストエンジニアの視点でテストコードを書けるようになれば、プログラマーの自分が見つけられなかった不具合を、テストエンジニアの自分が見つけてくれる、なんていうことも起こるかもしれません。

ホワイトボックステストブラックボックステスト

ホワイトボックステスト

「2章 ソフトウェアテストの基本 ーホワイトボックステストー」に以下の定義が載っています。

ホワイトボックステストとは、プログラムの論理構造が正しいかを解析するテストです。

なんのこっちゃ、というかんじですが、少し後に、

ホワイトボックステストは論理構造の正しさのみをテストするため、ソフトウェアの仕様が間違っていることから起こるバグは発見できないのです。

という記述があって、ますます意味が分からなくなってきました。

なので、いったんもうひとつの大きな分類である「ブラックボックステスト」について、本書でどのように記述されているか見てみることにします。

ブラックボックステスト

「3章 エンジニアがもっともよく使う手法 ーブラックボックステストー」に下記の記載がありました。

ブラックボックステストはプログラムを一種のブラックボックスと見立てて、さまざまな入力を行うことによって、ソースコードを利用せず(見ずに)テストを行う手法です。

なるほど、ブラックボックステストソースコードを見ずに行うテストであるのに対し、ホワイトボックステストソースコードを知った上で行うテストである、ということですね、なんとなく分かってきた気がします。

ホワイトボックステストの手法

制御パステスト

ソースコード内にある if / unless / else などの条件分岐によって処理が枝分かれしていく場合、すべてのパターンをテストする必要があります。

本書では、ステートメントカバレッジとブランチカバレッジという手法が紹介されています。

ステートメントカバレッジとブランチカバレッジの違いを見てみます。

例えば、以下のルールで商品の割引をする場合、

  1. 消費期限の3日前から 50 円引きとなる
  2. セール対象商品は 10% 引きとなる (1円未満は四捨五入)
  3. 1 のルールは 2 のルールの前に適用される

どのようなデータを用意すればすべてのパスを通るテストになるでしょうか。

PHP で実装してみます。

<?php
namespace App\Service;

class PriceCalculator
{
    /**
    * @var App\SalesPlan
    */
    private $salesPlan;

    /**
    * @param App\SalesPlan
    */
    public function __construct(SalesPlan $salesPlan)
    {
        $this->salesPlan = $salesPlan;
    }

    /**
    * @param App\Item
    * @return int
    */
    public function discount(Item $item)
    {
        $price = $item->getPrice();
        // C-1
        if ($item->isCloseToUseByDate()) {
            // S-1
            $price -= 50;
        }
        // C-2
        if ($this->salesPlan->isSalesItem($item)) {
            // S-2
            $price = round($price - $price * 0.1);
        }

        return $price;
    }
}

discount メソッドのステートメントカバレッジでは、C-1, C-2 がそれぞれ真のときの S-1, S-2 と書かれた箇所のテストを行います。よって、テストケースは 2 つになります。C-1 = true, C-2 = true の 2 パターンです。

一方、ブランチカバレッジでは、C-1, C-2 それぞれにおいて、真偽の結果を少なくとも一回はテストを行います。よって、テストケースは 4 つになります。(C-1, C-2) = (true, true), (true, false), (false, true), (false, false) の 4 パターンですね。

著者は、できるだけブランチカバレッジを使うべきと書いています。ただし、

ブランチカバレッジをプログラム全体に対して使用しなくとも、十分な成果が得られることが証明されているからです。(中略) プログラムのバグは全体から平均的に出るのではなく、ある特定の部分で発生し易い傾向があります。バグの発生状況を見てから、バグがたくさん出る部分に対しブランチカバレッジを実行するのも悪くないアイディアです。

とのことで必ずしもすべてのパターンをテストする必要はないそうです。

一般の商用ソフトウェアなら60 〜 90%程度で十分と考えます。

ソフトウェアの用途、予算、納期等を考慮して、都度判断するべきということのようです。

ポイントは、カバレッジを 100% にしても不具合が 0 になるわけではない、という点で、盲目的にカバレッジが高い = 品質が高い、と考えるのではなく、適切なカバレッジ (測定箇所と率) を見極めるようにしたいです。

ちなみに、カバレッジテストで見つけられない不具合の例が載っていて、

  • プログラムのループに関するバグ
  • 要求仕様自体が間違っていたり、機能が備わっていないバグ
  • データに関するバグ
  • タイミングに関するバグ

だそうで、詳しい説明の引用は省きますが、念頭に置いておいた方がよさそうです。

インクリメンタル開発と TDD

TDD (Test-Driven Development: テスト駆動開発) についての記述もありました。が、この領域については別途「テスト駆動開発入門」(ケント・ベック著) などを読むといいでしょう。

以下の興味深い一文を引用するにとどめます。

品質を上げるために、単体テストand/orコードレビューは必須の作業です。しかしその活動をしようとすると必ず出くわす問題が、「単体テストなんてやってる暇ありません」と言い始めるできの悪い開発者が出てくることです。そこで「おめーはなんでも時間がないって言うじゃねーか、できる奴はそんなこと言わないんだよ!」と怒りをぶつける前に、「Kent Beckが推奨するTDDをやりましょう」といったほうがスマートです。

ときどきこういうぶっちゃけた意見が載ってるのもこの本のいいところです(笑)。

個人的にはちょっと過激な意見にも思えますが、言いたいことは概ね理解できます。

テストコードを書く/書かない、という問題は、どこに時間を費やすかという問題で、テストに時間を割きたがらない人は、テストなしで品質の高いソフトウェアをつくることができるスーパーエンジニアか、目の前の事象に対してしかものごとの重要性を判断できない短期的思考の人かどちらかなんじゃないかと思っています。

自動テストがないためにリファクタリングができず、何度も変更を重ねるたびに品質が少しずつ下がっていく、という負のスパイラルに陥ってしまうと、最初の実装で時間を節約した結果、後により多くのコストを払う羽目になってしまうので、最低限、変更が多く入りそうな箇所については単体テストを書いておくのがいいのではないかと思います。

私は自分がスーパーエンジニアでないことは知っているので、テストコードを書いていますし (必ずしもテスト駆動ではないですが)、仮に自分がスーパーエンジニアであっても、チームメンバーの全員がそうでないなら、後々のために書いておくのがいいとも思っています。

(ちょっと私見を挟みすぎたので閑話休題)

ブラックボックステスト

前述の通り、ブラックボックステストソースコードを見ずに (プロダクションコードを書いた人物がブラックボックステストをするときはロジックについて知らないふりをする必要があります) 要求仕様に従って入力値を設定する必要があります。

本書ではいくつかのブラックボックステストの手法を紹介していますので、それらについて軽くまとめていきます。

同値分割法と境界値分析法

これらは、テストデータのパターンをどのように作成すればいいか、という指標となります。

次のような要求仕様があるとき、

  • 入力A: 1 から 999 の整数
  • 入力B: 1 から 999 の整数
  • 出力C: A × B

まず、同値分割法で有効な組み合わせ (有効同値) と無効な組み合わせ (無効同値) に分けます。網羅的なパターンであれば次の 10 パターンになります (数値は該当する範囲内から任意に選んでおり、0 を特別な値として含めています)。

  • (A, B) = (500, 500) ー A,B 共に有効な範囲の値
  • (A, B) = (-20, -20) ー A,B 共に有効な範囲より小さい値
  • (A, B) = (-5, 1100) ー A: 有効な範囲より小さい値、B: 有効な範囲より大きい値
  • (A, B) = (0, 500) ー A: 0、B: 有効な範囲の値
  • (A, B) = (500, 0) ー A: 有効な範囲の値、B: 0
  • (A, B) = (550, 1100) ー A: 有効な範囲の値、B: 有効な範囲より大きい値
  • (A, B) = (1100, 600) ー A: 有効な範囲より大きい値、B: 有効な範囲の値
  • (A, B) = (1100, 1100) ー A,B 共に有効な範囲より大きい値
  • (A, B) = (1500, -5) ー A: 有効な範囲より大きい値、B: 有効な範囲より小さい値
  • (A, B) = (0, 0) ー A,B 共に 0

これだとテストケースが多すぎるため、有効同値のケースと無効同値のケースのうち必要最低限のもののみ抜き出します。

  • (A, B) = (500, 500) ー A,B 共に有効な範囲の値
  • (A, B) = (-20, -20) ー A,B 共に有効な範囲より小さい値
  • (A, B) = (1100, 1100) ー A,B 共に有効な範囲より大きい値
  • (A, B) = (0, 0) ー A,B 共に 0

これが同値分割法によって入力値のパターンを絞り込む方法です。

今度は、境界値分析法で入力値を設定してみます。

境界値分析法は、境界値 (例では 1 および 999) が条件分岐に使われていることが多いことを考慮し、条件判定文の記述に間違いがあった場合にそれを見つけるために使われる手法です。

if (a < 1 || a > 999) // invalid parameter

と書くべきところを

if (a <= 1 || a >= 999) // invalid parameter

と書いてあったとすると、(A, B) = (1, 有効値), (A, B) = (有効値, 999) のケースで仕様通りの動作にならないことになります。

境界値は有効な値の範囲の下限、下限 - 1、 上限、上限 + 1 となるので、上の例では 0, 1, 999, 1000 となります。 0 は境界であるか否かとは無関係にテストケースに含めますので、

  • (A, B) = (499, 499) ー A,B 共に有効な範囲の値 (有効な値の範囲の中央)
  • (A, B) = (1, 1) ー A,B 共に有効な範囲の値 (境界値下限)
  • (A, B) = (999, 999) ー A,B 共に有効な範囲の値 (境界値上限)
  • (A, B) = (1000, 1000) ー A,B 共に有効な範囲より大きい値
  • (A, B) = (0, 0) ー A,B 共に 0 かつ A,B 共に有効な範囲より小さい値

の 5 パターンをテストすればいい、ということになります。

これらに加え、非常に小さなデータ、非常に大きなデータ、長いデータ、無効なデータを入力値としてテストすることも大事だそうです。

デシジョンテーブル

デシジョンテーブルでは、すべての入力の組み合わせを表にし、その入力に対する動作もしくは出力を明記します。

上記の例でいうと、

入力出力
A = 有効, B = 有効計算結果
A = 有効, B = 無効入力エラー
A = 無効, B = 有効入力エラー
A = 無効, B = 無効入力エラー

という 4 パターンがあることが分かります。

上記のような単純計算の例ではあまり効果がありませんが (計算結果を出力するのが 1 パターンのみなので)、入力値の組み合わせによって出力 (あるいは動作) が変わってくるようなケースでは有用そうです。

カバレッジのところで書いた例でもデシジョンテーブルをつくってみます。

ルールは以下の通りでした。

  1. 消費期限の3日前から 50 円引きとなる
  2. セール対象商品は 10% 引きとなる (1円未満は四捨五入)
  3. 1 のルールは 2 のルールの前に適用される

デシジョンテーブルは以下のようになります。

入力出力
消費期限3日前である
セール対象品である
価格 - 50円 - 10%
消費期限3日前である
セール対象品でない
価格 - 50円
消費期限3日前でない
セール対象品である
価格 - 10%
消費期限3日前でない
セール対象品でない
価格

状態遷移テスト

状態遷移テストとは、要は「状態」をモデル化してテストを行う手法といえます。まず、状態遷移は、大きく分けて状態 (state) と遷移 (transition) の二つによって表現されます。(中略) ある状態からほかの状態に移るには入力Xによる遷移が必要です。

状態が変わるためには入力またはイベントが必要です。 オブジェクト指向プログラミングでは、状態はオブジェクト内部に持っていることが多いでしょうから、クラスの単体テストで威力を発揮しそうです。

ECサイトにおける決済に関する状態遷移を考えてみます。

状態

  • 決済不能 (購入手続きが完了しておらず決済できない状態)
  • 決済待ち (購入手続きが完了し、決済を待っている状態)
  • 決済済み (決済が完了し、未入金の状態)
  • キャンセル済み (決済がキャンセルされた状態)
  • 入金済み (決済に対し入金が行われた状態)

他にもあるかもしれませんが、とりあえずこんなもので。

遷移 (括弧内はイベント)

  • 決済不能 → (購入手続き) → 決済待ち
  • 決済待ち → (決済手続き) → 決済済み
  • 決済済み → (キャンセル手続き) → キャンセル済み
  • 決済済み → (入金手続き) → 入金済み
  • 入金済み → (キャンセル手続き) → キャンセル済み

Payment クラスが状態を持つとすると、決済不能と決済待ちについてはオブジェクトが生成されていない状態とすると不要になるので、下の3つだけについて考えます。

仮に、ショッピングカートに商品が入っている状態で銀行振込による決済を行ったとき、入金前ならキャンセルできるとします。

  • 入金済みになった決済はキャンセルできない
  • キャンセル済みになった決済は再度決済することはできない

という要件があったとしたら、

状態決済済み入金済みキャンセル済み
イベント
決済手続き-OKOK
入金手続きNG-NG
キャンセル手続きNGNG-

というような遷移になるので、OKとなっているところが正常に遷移できること、NGとなっているところが遷移できないことを確認することになります。

一方、GUI のテストにおいては、遷移できない状態は、画面上でボタンを非表示にしたり無効にしたりして押せないようにすることで防いでいることが多いと思いますが、ブラウザでHTMLやCSSを書き換えることで実行できてしまうこともあるので、何らかの手段でテストする必要があります (本書では Windows アプリケーションを例にしているため、ウェブアプリケーションの場合とは若干テストの仕方が異なるとは思いますが、GUI において状態遷移できること / できないことを確認するのは実際に動かしてみるしかないのかもしれません)。

このあたりの操作をヘッドレスブラウザで自動実行できるのか、試してみる必要がありそうです (いずれ試します)。

長くなったので続きは次回 (探索的テスト、非機能要件のテスト) に持ち越します。

ソフトウェアテストは、会社の規模によっては専門の部署があるくらいの大事な役割なので、引き続き体系的に学んでいきたいと思います。

まとめ