出力が不確定な単体テストメソッド


37

ランダムな長さのランダムなパスワードを生成することを意図したクラスがありますが、定義された最小長と最大長の間に制限されています。

私は単体テストを作成していますが、このクラスで興味深い小さな障害に遭遇しました。単体テストの背後にある全体的な考え方は、繰り返し可能にする必要があるということです。テストを100回実行すると、同じ結果が100回得られるはずです。あなたが期待する初期状態にあるかもしれないし、そうでないかもしれないいくつかのリソースに依存しているなら、あなたのテストが本当に常に再現可能であることを保証するために問題のリソースをモックするつもりです。

しかし、SUTが不確定な出力を生成することになっている場合はどうでしょうか?

最小長と最大長を同じ値に修正すると、生成されたパスワードが予想された長さであることを簡単に確認できます。しかし、許容範囲(たとえば15-20文字)の範囲を指定すると、テストを100回実行して100パスを取得できるという問題がありますが、101回目の実行で9文字の文字列が返される可能性があります。

コアが非常に単純なパスワードクラスの場合、大きな問題を証明すべきではありません。しかし、一般的なケースについて考えさせられました。設計によって不確定な出力を生成しているSUTを処理するときに取るのに最適な方法として通常受け入れられている戦略は何ですか?


9
なぜ投票するのですか?それは完全に有効な質問だと思います。
マークベイカー

ええ、コメントありがとう。それにも気付かなかったが、今は同じことを考えている。私が考えることができるのは、特定のケースではなく一般的なケースについてですが、上記のパスワードクラスのソースを投稿し、「そのクラスをテストするにはどうすればよいですか?」「不確定クラスをテストするにはどうすればよいですか?」
GordonM

1
@MarkBakerユニットテストの質問のほとんどは、programmers.seにあります。これは移行を支持するものであり、質問を解決するものではありません。
-Ikke

回答:


20

「非決定的」出力には、単体テストの目的で決定的となる方法が必要です。ランダム性を処理する1つの方法は、ランダムエンジンの置換を許可することです。以下に例を示します(PHP 5.3以降):

function DoSomethingRandom($getRandomIntLessThan)
{
    if ($getRandomIntLessThan(2) == 0)
    {
        // Do action 1
    }
    else
    {
        // Do action 2
    }
}

// For testing purposes, always return 1
$alwaysReturnsOne = function($n) { return 1; };
DoSomethingRandom($alwaysReturnsOne);

テストが完全に反復可能であることを確認するために、必要な数字のシーケンスを返す関数の特別なテストバージョンを作成できます。実際のプログラムでは、オーバーライドされない場合はフォールバックになる可能性があるデフォルトの実装を使用できます。


1
与えられたすべての答えには、私が使用した良い提案がありましたが、これはコアの問題を解決し、受け入れられると思うものです。
GordonM

1
かなり頭に釘付けします。非決定的ですが、まだ境界があります。
surfasb

21

実際の出力パスワードは、メソッドが実行されるたびに確定されない場合がありますが、最小長、確定文字セット内の文字など、テスト可能な確定機能がまだあります。

パスワードジェネレータに毎回同じ値をシードすることで、毎回ルーチンが確定結果を返すことをテストすることもできます。


PWクラスは、基本的にパスワードの生成元となる文字のプールである定数を維持します。それをサブクラス化し、単一の文字で定数をオーバーライドすることで、テストの目的で非決定性の1つの領域を削除することに成功しました。ほんとありがと。
GordonM

14

「契約」に対してテストします。メソッドが「a〜zで長さ15〜20文字のパスワードを生成する」と定義されている場合、この方法でテストします。

$this->assertTrue ((bool) preg_match('^[a-z]{15,20}$', $password));

さらに、世代を抽出できるため、それに依存するすべてのものが別の「静的」ジェネレータークラスを使用してテストできます。

class RandomGenerator implements PasswordGenerator {
  public function create() {
    // Create $rndPwd
    return $rndPwd;
  }
}

class StaticGenerator implements PasswordGenerator {
  private $pwd;
  public function __construct ($pwd) { $this->pwd = $pwd; }
  public function create      ()     { return $this->pwd; }
}

あなたが与えた正規表現は有用であることがわかったので、テストに微調整バージョンを含めました。ありがとう。
GordonM

6

Password generatorあり、ランダムなソースが必要です。

質問で述べたように、a randomグローバル状態であるため、非決定的な出力を生成します。つまり、システム外部の何かにアクセスして値を生成します。

すべてのクラスでこのようなものを取り除くことはできませんが、ランダムな値を作成するためにパスワード生成を分離できます。

<?php
class PasswordGenerator {

    public function __construct(RandomSource $randomSource) {
        $this->randomSource = $randomSource
    }

    public function generatePassword() {
        $password = '';
        for($length = rand(10, 16); $length; $length--) {
            $password .= $this-toChar($this->randomSource->rand(1,26));
        }
    }

}

このようにコードを構成するRandomSourceと、テスト用にモックアウトできます。

100%をテストすることはできませんがRandomSource、この質問の値をテストするために得た提案はそれに適用できます(rand->(1,26);常に1から26の数値を返すテストのように)。


それは素晴らしい答えです。
ニックホッジス

3

素粒子物理学モンテカルロの場合、事前に設定されたランダムシードを使用して非決定的ルーチンを呼び出し、統計的な回数を実行して制約違反(エネルギーレベル)をチェックする「ユニットテスト」{*}を記述しまし入力エネルギーを超えた場合はアクセスできず、すべてのパスで何らかのレベルを選択する必要があります)、以前に記録された結果に対する回帰。


{*}このようなテストは、単体テストの「テストを高速化する」原則に違反しているため、受け入れテストや回帰テストなど、他の方法でそれらをよりよく特徴付けることができます。それでも、ユニットテストフレームワークを使用しました。


3

私は 2つの理由で、受け入れられた答えに反対しなければなりません

  1. オーバーフィット
  2. 実行不可能性

(多くの状況では良い答えかもしれませんが、すべてではなく、おそらくほとんどではないことに注意してください。)

それで私はどういう意味ですか?まあ、によって過剰適合私は統計的検定の典型的な問題を意味する:あなたは、データの過度に制約セットに対して確率的アルゴリズムをテストする際に過学習が起こります。その後、戻ってアルゴリズムを改良すると、暗黙的にトレーニングデータに非常によく適合します(誤ってアルゴリズムをテストデータに適合させます)が、他のすべてのデータはまったく適合しない可能性があります(テストしないため) 。

(ちなみに、これは常に単体テストに潜む問題です。これが、優れたテストが完全である、または少なくとも特定のユニットの代表である理由であり、これは一般に困難です。)

乱数ジェネレーターをプラガブルにすることでテストを確定的にする場合、常に同じ非常に小さい(通常)非代表的なデータセットに対してテストします。これによりデータがゆがみ、機能に偏りが生じる可能性があります。

2番目のポイントである実行不可能性は、確率変数を制御できない場合に発生します。これは通常、乱数ジェネレーターでは発生しません(「実際の」乱数のソースが必要な場合を除く)が、確率論が他の方法で問題に侵入したときに発生する可能性があります。たとえば、並行コードをテストする場合、競合状態は常に確率論的であるため、それらを決定論的に(簡単に)することはできません

それらの場合に自信を高める唯一の方法は多く試すことです。泡立て、すすぎ、繰り返します。これにより、一定のレベルまで自信が生まれます(この時点で、追加のテスト実行のトレードオフは無視できるようになります)。


2

ここには実際に複数の責任があります。単体テスト、特にTDDは、このようなことを強調するのに最適です。

責任は次のとおりです。

1)乱数ジェネレータ。2)パスワードフォーマッタ。

パスワードフォーマッタは、乱数ジェネレータを使用します。コンストラクターをインターフェイスとして、ジェネレーターをフォーマッターに挿入します。これで、乱数ジェネレーターを完全にテスト(統計テスト)でき、模擬乱数ジェネレーターを挿入してフォーマッターをテストできます。

より良いコードを取得できるだけでなく、より良いテストを取得できます。


2

他の人がすでに述べたように、ランダム性を削除することでこのコードを単体テストします。

また、乱数ジェネレーターを所定の場所に残し、契約(パスワードの長さ、許可される文字など)のみをテストし、失敗した場合はシステムを再現できる十分な情報をダンプする高レベルのテストが必要な場合がありますランダムテストが失敗した1つのインスタンスの状態。

テストが繰り返しできないことは問題ではありません-一度失敗した理由を見つけることができる限り。


2

コードをリファクタリングして依存関係を切断すると、ユニットテストの多くの問題が簡単になります。データベース、ファイルシステム、ユーザー、または場合によってはランダム性のソース。

別の見方をすると、ユニットテストは「このコードは私が意図したことをしますか?」という質問に答えることになっています。あなたの場合、コードは非決定的であるため、コードが何をするつもりなのかわかりません。

このことを念頭に置いて、ロジックを小さく、理解しやすく、簡単にテストできる分離パーツに分離します。具体的には、ランダムなソースを入力として受け取り、出力としてパスワードを生成する個別のメソッド(またはクラス!)を作成します。そのコードは明らかに決定論的です。

単体テストでは、毎回同じランダムでない入力をフィードします。非常に小さなランダムストリームの場合は、テストで値をハードコーディングするだけです。それ以外の場合は、テストでRNGに一定のシードを提供します。

より高いレベルのテスト(「受け入れ」または「統合」など)で、真のランダムソースを使用してコードを実行します。


この答えは私にそれを打ち付けました:私は実際に2つの関数を1つに持っていました:乱数ジェネレーター、およびその乱数で何かをする関数。単純にリファクタリングし、コードの非決定的な部分を簡単にテストし、ランダムな部分によって生成されたパラメーターをフィードできるようになりました。良い点は、ユニットテストで固定パラメーター(の異なるセット)を提供できることです(とにかくユニットテストではなく、標準ライブラリの乱数ジェネレーターを使用しています)。
ニューロネット

1

上記の回答のほとんどは、乱数ジェネレーターをモックすることが道であると示していますが、組み込みのmt_rand関数を単に使用していました。モックを許可することは、クラスを書き換えて、構築時に乱数ジェネレーターを挿入することを要求することを意味します。

かと思った!

名前空間の追加の結果の1つは、PHP関数に組み込まれたモックが信じられないほど難しいものから簡単なものになったことです。SUTが指定された名前空間にある場合、その名前空間の単体テストで独自のmt_rand関数を定義するだけで、テスト期間中は組み込みのPHP関数の代わりに使用されます。

これが最終的なテストスイートです。

namespace gordian\reefknot\util;

/**
 * The following function will take the place of mt_rand for the duration of 
 * the test.  It always returns the number exactly half way between the min 
 * and the max.
 */
function mt_rand ($min = 42, $max = NULL)
{
    $min    = intval ($min);
    $max    = intval ($max);

    $max    = $max < $min? $min: $max;
    $ret    = round (($max - $min) / 2) + $min;

    //fwrite (STDOUT, PHP_EOL . PHP_EOL . $ret . PHP_EOL . PHP_EOL);
    return ($ret);
}

/**
 * Override the password character pool for the test 
 */
class PasswordSubclass extends Password
{
    const CHARLIST  = 'AAAAAAAAAA';
}

/**
 * Test class for Password.
 * Generated by PHPUnit on 2011-12-17 at 18:10:33.
 */
class PasswordTest extends \PHPUnit_Framework_TestCase
{

    /**
     * @var gordian\reefknot\util\Password
     */
    protected $object;

    const PWMIN = 15;
    const PWMAX = 20;

    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     */
    protected function setUp ()
    {
    }

    /**
     * Tears down the fixture, for example, closes a network connection.
     * This method is called after a test is executed.
     */
    protected function tearDown ()
    {

    }

    public function testGetPassword ()
    {
        $this -> object = new PasswordSubclass (self::PWMIN, self::PWMAX);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ((bool) preg_match ('/^A{' . self::PWMIN . ',' . self::PWMAX . '}$/', $pw));
        $this -> assertTrue (strlen ($pw) >= self::PWMIN);
        $this -> assertTrue (strlen ($pw) <= self::PWMAX);
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testGetPasswordFixedLen ()
    {
        $this -> object = new PasswordSubclass (self::PWMIN, self::PWMIN);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ($pw === 'AAAAAAAAAAAAAAA');
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testGetPasswordFixedLen2 ()
    {
        $this -> object = new PasswordSubclass (self::PWMAX, self::PWMAX);
        $pw = $this -> object -> getPassword ();
        $this -> assertTrue ($pw === 'AAAAAAAAAAAAAAAAAAAA');
        $this -> assertTrue ($pw === $this -> object -> getPassword ());
    }

    public function testInvalidLenThrowsException ()
    {
        $exception  = NULL;
        try
        {
            $this -> object = new PasswordSubclass (self::PWMAX, self::PWMIN);
        }
        catch (\Exception $e)
        {
            $exception  = $e;
        }
        $this -> assertTrue ($exception instanceof \InvalidArgumentException);
    }
}

PHP内部関数をオーバーライドすることは、私にはまったく発生していなかった名前空間のもう1つの用途であるため、これについて言及したいと思いました。これを助けてくれたみんなに感謝します。


0

この状況に含める必要がある追加のテストがあります。これは、パスワードジェネレーターを繰り返し呼び出して実際に異なるパスワードが生成されることを確認するためのものです。スレッドセーフなパスワードジェネレーターが必要な場合は、複数のスレッドを使用した同時呼び出しもテストする必要があります。

これにより、基本的にランダム関数を適切に使用し、呼び出しごとに再シードを行わないようにします。


実際、クラスはgetPassword()の最初の呼び出しでパスワードが生成され、その後ラッチされるように設計されているため、オブジェクトの存続期間中常に同じパスワードを返します。私のテストスイートでは、同じパスワードインスタンスでgetPassword()を複数回呼び出すと、常に同じパスワード文字列が常に返されることを確認しています。スレッドの安全性に関しては、PHPでは
それほど
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.