【问题标题】:How to unit test a method calling a function that has a side-effect by changing an argument passed by reference?如何通过更改通过引用传递的参数来对调用具有副作用的函数的方法进行单元测试?
【发布时间】:2019-03-03 14:26:27
【问题描述】:

我有一个调用内置 PHP 函数的方法,openssl_random_pseudo_bytes

public function generateRandomBytes()
{
    $crypto_secure = TRUE;

    // $crypto_secure is passed by reference and will be set to FALSE by
    // openssl_random_pseudo_bytes if it uses an insecure algorithm
    $random_bytes = openssl_random_pseudo_bytes(16, $crypto_secure);
    if (!$crypto_secure)
    {
        throw new Security_Exception('Random bytes not generated by a cryptographically secure PRNG algorithm');
    }
    return $random_bytes;
}

我有一个 PHPUnit 测试用例来测试这个方法(它所做的只是验证随机生成的字符串是 16 字节长)。

public function testRandomBytesLength()
{
    $myclass = new MyClass();

    $this->assertEquals(16, strlen($myclass->generateRandomBytes()));
}

我的问题是,我如何测试 $crypto_secure 为 FALSE 并且必须抛出异常的情况?由于这个值是作为对openssl_random_pseudo_bytes 的引用传入和修改的,所以我不确定如何获得这个执行路径的测试覆盖率。我的第一个想法是,也许我可以使用 php.ini 配置来强制 openssl_random_pseudo_bytes 使用加密不安全的算法(在测试用例中通过 ini_set)。有什么建议吗?

【问题讨论】:

  • 单元测试是一个黑盒。你不应该关心一个类在内部做什么。使用 crypto_secure 是调用的配置,但您的 MyClass 正在抽象它的使用。你不测试配置/内部。您将 crypto_secure 设置为 true。为什么你会在你的情况下将它设置为 false ?只是为了一个人为的测试用例?你能解释一下你想测试什么吗?因为这读作 xy 问题。
  • @k0pernikus 我不确定你的意思? crypto_secure 不是调用的配置,它是 openssl_pseudo_random_bytes 用来通知调用者它是否使用安全算法生成随机字节的标志。如果用于生成随机数的算法不安全,openssl_pseudo_random_bytes 会将$crypto_secure 设置为FALSE
  • 我的错,我把它当作函数参数来读。现在我明白了为什么你将它作为变量传递,然后想要检查它。我仍然认为您不需要为您的检查创建单元测试。只需抛出异常就可以了。话虽如此,这也是为什么副作用不利于单元测试的典型例子。
  • @k0pernikus 这是一个容易犯的错误,因为它不是 PHP 中的常见模式(我的一位同事在代码审查期间也有同样的困惑 :) 是的,我同意这是一个非常糟糕的模式.希望未来版本的 PHP 将有一个对 TDD 更友好的替代方案

标签: php unit-testing random phpunit pass-by-reference


【解决方案1】:

一种选择是将你的代码抽象出来,这样你就可以模拟 openssl 方法的返回值:

public function generateRandomBytes()
{
    $crypto_secure = TRUE;
    $random_bytes = $this->randomPseudoBytes(16, $crypto_secure);
    if (!$crypto_secure)
    {
        throw new Security_Exception('Random bytes not generated by a cryptographically secure PRNG algorithm');
    }
    return $random_bytes;
}

protected function randomPseudoBytes($length, &$crypto_secure)
{
    return openssl_random_pseudo_bytes(16, $crypto_secure);
}

然后您可以控制核心函数周围的包装器,以测试您的代码如何对其变化做出反应:

/**
 * @expectedException Security_Exception
 * @expectedExceptionMessage Random bytes not generated by a cryptographically secure PRNG algorithm
 */
public function testCryptoIsNotSecure()
{
    $myclass = $this->getMockBuilder(MyClass::class)->setMethods(['randomPseudoBytes'])->getMock();

    $myclass->expects($this->once())
        ->method('randomPseudoBytes')
        ->will($this->returnCallback(function ($length, &$secure) {
            // Mock variable assignment via reference
            $secure = false;
        });

    $myclass->generateRandomBytes();
}

【讨论】:

  • 虽然这似乎正是 OP 想要的,但我认为:这并不能测试任何有意义的东西。单元测试不应模拟内部受保护的功能/分配。单元测试应该测试公共接口,内部的任何东西都应该被视为一个黑盒。
  • 是的,我同意,但它可以作为参考
  • 如果你添加一个不模拟randomPseudoBytes方法的测试,你会测试generateRandomBytes方法的正常行为吗?
  • 您也可以争辩说异常是您的公共 API 的一部分,因此值得测试
  • 我喜欢这种方法。使用返回回调来修改引用是有意义的。我同意@k0pernikus 所说的,所以我会做一个修改:与其将它设为 MyClass 的受保护方法,我认为将 generateRandomBytes 放入其自己的外部帮助器类并将其实例注入为对 MyClass 的依赖(我的一般规则是每当您发现自己需要模拟受保护的方法时,将其导出到它自己的辅助类)。谢谢大家的意见!
【解决方案2】:

下面的答案是在openssl-random-pseudo-bytes 采用两个不可变参数的假设下编写的。相反,第二个参数是通过引用传递的,如果随机字节是由强算法创建的,则会给出反馈。鉴于这些信息,Robbie Averill 提供的答案是一种有效的方法,因为必须处理基本上两个返回语句和一个副作用,这本质上会使单元测试复杂化。


您的情况不需要您的安全例外。

您希望将openssl_random_pseudo_bytes 包装到您自己的自定义函数中,并且希望将长度硬编码为16 个字符,并始终使用true 调用openssl_random_pseudo_bytes。因此,您可以将您的课程编写为:

class MyClass
{
    public function generateRandomBytes()
    {
        return openssl_random_pseudo_bytes(16, true);
    }
}

这里唯一有意义的测试是检查返回的字符串长度是否为 16 个字符。你已经报道了那个案例。


为了显示异常抛出的不必要性,您宁愿将标志注入构造函数或作为参数:

class MyClass
{
    /**
     * @var bool
     */
    private $beSecure;

    public function __construct(bool $beSecure)
    {
        $this->beSecure = $beSecure;
    }


    /**
     * @return string
     * @throws Exception
     */
    public function generateRandomBytes(): string
    {
        if (!$this->beSecure) {
            // will always throw if false is injected, why would we do that?
            throw new Exception("I AM NOT SECURE!");
        }

        return openssl_random_pseudo_bytes(16, true);
    }
}

在您的单元测试中,您现在可以创建两个测试,一个用于安全案例,一个用于不安全案例,但您为什么要向该类注入 false?然后它总是会失败。

【讨论】:

  • 但是openssl_random_pseudo_bytes 设置了第二个参数,你知道吗? (我的意思是,它通过引用来获取它并可能对其进行修改。)
  • @Don'tPanic 好点。那么检查一下可能实际上是有意义的,但还应该注意:It's rare for [that boolean] to be FALSE, but some systems may be broken or old.
  • 是的,当然。似乎问题更多是关于在测试中模拟一个旧的、损坏的系统以验证在这种情况下是否引发了异常的某种方式。不过,我当然不知道该怎么做。
猜你喜欢
  • 1970-01-01
  • 2015-02-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-02-18
  • 2023-03-28
  • 2018-04-21
  • 1970-01-01
相关资源
最近更新 更多