输出不确定的单元测试方法


37

我有一个用于生成随机密码的类,该密码的长度也是随机的,但仅限于定义的最小和最大长度之间。

我正在构建单元测试,并且在此类中遇到了一个有趣的小问题。单元测试背后的整个想法是,它应该是可重复的。如果您运行测试一百次,它应该给出一百次相同的结果。如果您依赖某些可能会或可能不存在的资源,或者可能会或可能不会处于初始状态,那么您就应该模拟有问题的资源,以确保您的测试确实总是可重复的。

但是,如果应该SUT产生不确定的输出呢?

如果我将最小和最大长度固定为相同的值,那么我可以轻松地检查所生成的密码是否具有预期的长度。但是,如果我指定了可接受的长度范围(例如15至20个字符),那么您现在遇到的问题是,您可以运行一百次测试并获得100次通过,但是在第101次运行时,您可能会得到9个字符串。

就密码类而言,它的核心是相当简单的,它不应证明是一个大问题。但这让我想到了一般情况。在处理通过设计产生不确定输出的SUT时,通常被认为是最好的策略是什么?


9
为什么要收票?我认为这是一个完全正确的问题。
马克·贝克

嗯,谢谢你的评论。甚至没有注意到,但是现在我想知道同样的事情。我唯一能想到的是那是一种一般情况,而不是特定情况,但是我可以发布上述密码类的源,然后问“我如何测试该类?” 而不是“如何测试任何不确定的课程?”
GordonM 2012年

1
@MarkBaker因为大多数单元测试问题都在programmers.se上。这是对迁移的投票,而不是为了解决问题。
Ikke 2012年

Answers:


20

为了单元测试的目的,“非确定性”输出应具有确定性的方式。处理随机性的一种方法是允许替换随机引擎。这是一个示例(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 2012年

1
几乎把它钉在头上。尽管不确定,但仍然存在边界。
surfasb 2012年

21

实际的输出密码可能不会在每次执行该方法时都确定,但是它仍然具有可以测试的确定功能,例如最小长度,确定字符集中的字符等。

您还可以通过每次给密码生成器填充相同的值来测试例程是否每次都返回确定的结果。


PW类维护一个常量,该常量本质上是应该从中生成密码的字符池。通过对其进行子类化并使用单个字符覆盖常量,我设法消除了一个不确定性区域,以进行测试。那谢谢啦。
GordonM 2012年

14

测试“合同”。当方法定义为“使用az生成长度为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 2012年

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

我必须不同意接受的答案,原因有两个:

  1. 过度拟合
  2. 不切实际

(请注意,在许多情况下,这可能是一个很好的答案,但并非在所有情况下,在大多数情况下都不是。)

那我是什么意思呢?好吧,通过过度拟合,我的意思是统计测试中的一个典型问题:过度拟合是在您针对过于受限的数据集测试随机算法时发生的。如果您随后回过头来优化算法,则会隐式地使其很好地适合训练数据(您不小心使算法适合于测试数据),但是所有其他数据可能根本不适合(因为您从未针对它进行测试) 。

(顺便说一句,这始终是潜伏在单元测试中的问题。这就是为什么良好的测试是完整的,或者至少对于给定的单元具有代表性,并且通常很难做到这一点。)

如果通过使随机数生成器可插入来使测试具有确定性,则始终会针对相同的非常小且(通常)无代表性的数据集进行测试。这会使您的数据产生偏差,并可能导致功能偏差。

第二点,不切实际,是当您无法控制随机变量时出现的。随机数生成器通常不会发生这种情况(除非您需要“真实的”随机数源),但是当随机性以其他方式潜入您的问题时,这种情况就可能发生。例如,当测试并发代码时:竞争条件总是随机的,您不能(轻松地)使它们具有确定性。

在这些情况下提高信心的唯一方法是进行大量测试。泡沫,冲洗,重复。这将信心提高到一定水平(此时,可以忽略其他测试运行的权衡)。


2

您实际上在这里有多个职责。单元测试(尤其是TDD)非常适合强调此类情况。

职责是:

1)随机数发生器。2)密码格式化程序。

密码格式化程序使用随机数生成器。通过生成器的构造器作为接口将生成器注入到格式化程序中。现在,您可以完全测试您的随机数生成器(统计测试),并且可以通过注入模拟的随机数生成器来测试格式化程序。

您不仅可以获得更好的代码,而且得到了更好的测试。


2

正如其他人已经提到的,您可以通过消除随机性来对该代码进行单元测试

您可能还需要进行更高级别的测试,以保留随机数生成器,仅测试合同(密码长度,允许的字符等),并且在失败时转储足够的信息以允许您重现系统随机测试失败的一种情况下的状态。

测试本身是不可重复的,这没关系-只要您能找到一次失败的原因即可。


2

当重构代码以切断依赖关系时,许多单元测试困难变得微不足道。数据库,文件系统,用户或您的情况(随机来源)。

另一种看待方式是单元测试应该回答以下问题:“此代码是否按照我的意图进行?”。在您的情况下,您不知道代码打算做什么,因为它是不确定的。

带着这种思想,将您的逻辑分解为一些易于理解的,易于测试的隔离小部件。具体来说,您创建一个独特的方法(或类!),该方法将随机性源作为其输入,并生成密码作为输出。该代码显然是确定性的。

在单元测试中,每次都将相同的非随机输入作为输入。对于很小的随机流,只需对测试中的值进行硬编码即可。否则,请在测试中为RNG提供恒定的种子。

在更高级别的测试(称为“接受”或“集成”或其他)上,您将让代码使用真正的随机源运行。


这个答案对我很重要:我确实有两个功能:随机数生成器和对该随机数执行某些操作的函数。我简单地进行了重构,现在可以轻松地测试代码的不确定部分,并将其提供给随机部分生成的参数。不错的是,然后我可以在单元测试中提供它(不同的固定参数集)(我使用的是标准库中的随机数生成器,因此无论如何都不能进行单元测试)。
Neuronet

1

上面的大多数答案都表明,模拟随机数生成器是可行的方法,但是我只是使用内置的mt_rand函数。允许模拟将意味着重写类以要求在构造时注入随机数生成器。

还是我想!

添加名称空间的后果之一是,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内部函数是对我完全没有想到的命名空间的另一种用法。感谢大家的帮助。


0

在这种情况下,您还应该包含另一项测试,该测试可以确保重复调用密码生成器实际上会生成不同的密码。如果需要线程安全密码生成器,则还应该使用多个线程测试同时调用。

基本上,这可以确保您正确使用随机函数,并且不会在每次调用时重新播种。


实际上,该类的设计使得在第一次调用getPassword()时生成密码,然后将其锁存,因此在对象的整个生命周期中它始终返回相同的密码。我的测试套件已经检查了在同一密码实例上多次调用getPassword()总是返回相同的密码字符串。至于线程安全,这在PHP中并不是真正的问题:)
GordonM 2012年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.