有人知道是否有断言或类似的东西可以测试被测试的代码中是否抛出了异常吗?


当前回答

TLDR;使用PHPUnit的数据提供程序

PHPUnit 9.5提供了以下方法来测试异常:

$this->expectException(string $exceptionClassName);
$this->expectExceptionCode(int|string $code);
$this->expectExceptionMessage(string $message);
$this->expectExceptionMessageMatches(string $regularExpression);
$this->expectExceptionObject(\Exception $exceptionObject);

然而,文档对测试代码中上述任何方法的顺序都含糊不清。

如果你习惯使用断言,例如:

<?php

class SimpleAssertionTest extends \PHPUnit\Framework\TestCase
{
    public function testSimpleAssertion(): void
    {
        $expected = 'bar';
        $actual = 'bar';
        $this->assertSame($expected, $actual);
    }
}

输出:

 ✔ Simple assertion
OK (1 test, 1 assertion)

你可能会对异常测试失败感到惊讶:

<?php

use PHPUnit\Framework\TestCase;

final class ExceptionTest extends TestCase
{
    public function testException(): void
    {
        throw new \InvalidArgumentException();
        $this->expectException(\InvalidArgumentException::class);
    }
}

输出:

 ✘ Exception
   ├ InvalidArgumentException:

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

这个错误是因为:

一旦抛出异常,PHP就不能返回到抛出异常的行之后的代码行。在这方面,捕获异常不会改变什么。抛出异常是一种单程票。

与错误不同,异常不具备从异常中恢复的能力,并使PHP继续代码执行,就像没有异常一样。

因此PHPUnit甚至没有到达:

$this->expectException(\InvalidArgumentException::class);

如果它前面有:

throw new \InvalidArgumentException();

而且,无论PHPUnit的异常捕获能力如何,它都永远无法到达这个位置。

因此,使用PHPUnit的任何异常测试方法:

$this->expectException(string $exceptionClassName);
$this->expectExceptionCode(int|string $code);
$this->expectExceptionMessage(string $message);
$this->expectExceptionMessageMatches(string $regularExpression);
$this->expectExceptionObject(\Exception $exceptionObject);

必须在预期抛出异常的代码之前,而在实际值设置之后放置断言。

使用异常测试的正确顺序:

<?php

use PHPUnit\Framework\TestCase;

final class ExceptionTest extends TestCase
{
    public function testException(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        throw new \InvalidArgumentException();
    }
}

因为调用PHPUnit内部方法来测试异常必须在抛出异常之前,所以与测试异常相关的PHPUnit方法从$this-> expect开始而不是$this->assert是有意义的。

已经知道:

一旦抛出异常,PHP就不能返回到抛出异常的行之后的代码行。

你应该能够很容易地在这个测试中发现一个bug:

<?php
namespace VendorName\PackageName;

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testThrowException(): void
    {
        # Should be OK
        $this->expectException(\RuntimeException::class);
        throw new \RuntimeException();

        # Should Fail
        $this->expectException(\RuntimeException::class);
        throw new \InvalidArgumentException();
    }
}

第一个$this->expectException()应该是OK的,它在预期抛出一个确切的异常类之前期望一个异常类,所以这里没有错误。

第二个应该失败的是在一个完全不同的异常抛出之前期望RuntimeException类,所以它应该失败,但PHPUnit执行是否会到达那个地方?

测试的输出是:

 ✔ Throw exception

OK (1 test, 1 assertion)

OK?

不,如果测试通过,它应该在第二个异常上失败,这是远远不够的。为什么呢?

注意输出有:

OK(1个测试,1个断言)

测试数是正确的,但只有1个断言。

应该有两个断言= OK和Fail,使测试不通过。

这只是因为PHPUnit在行后执行了testThrowException:

throw new \RuntimeException();

这是一个在testThrowException范围之外的单程票,PHPUnit在那里捕获\RuntimeException并做它需要做的事情,但无论它能做什么,我们知道它将不能跳回testThrowException,因此代码:

# Should Fail
$this->expectException(\RuntimeException::class);
throw new \InvalidArgumentException();

将永远不会被执行,这就是为什么从PHPUnit的角度来看,测试结果是OK而不是Fail。

如果你想在同一个测试方法中使用多个$this->expectException()或$this->expectException()和$this->expectExceptionMessage()的混合调用,这不是一个好消息:

<?php
namespace VendorName\PackageName;

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testThrowException(): void
    {
        # OK
        $this->expectException(\RuntimeException::class);
        throw new \RuntimeException('Something went wrong');

        # Fail
        $this->expectExceptionMessage('This code will never be executed');
        throw new \RuntimeException('Something went wrong');
    }
}

给错了:

OK(1个测试,1个断言)

因为一旦抛出异常,所有其他$this->expect…与测试异常相关的调用将不会被执行,PHPUnit测试用例结果将只包含第一个预期异常的结果。

如何测试多个异常?

将多个异常拆分到单独的测试中:

<?php
namespace VendorName\PackageName;

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testThrowExceptionBar(): void
    {
        # OK
        $this->expectException(\RuntimeException::class);
        throw new \RuntimeException();
    }

    public function testThrowExceptionFoo(): void
    {
        # Fail
        $this->expectException(\RuntimeException::class);
        throw new \InvalidArgumentException();
    }
}

给:

 ✔ Throw exception bar
 ✘ Throw exception foo
   ┐
   ├ Failed asserting that exception of type "InvalidArgumentException" matches expected exception "RuntimeException". Message was: "" at

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

它应该失败。

然而,这种方法在其基本方法上有一个缺点——对于每个抛出的异常,您都需要单独的测试。这将产生大量的测试,只是为了检查异常。

捕获异常并使用断言检查它

如果在抛出异常后你不能继续执行脚本,你可以简单地捕获一个预期异常,然后用异常提供的方法获得关于它的所有数据,并将其与预期值和断言结合使用:

<?php
namespace VendorName\PackageName;

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testThrowException(): void
    {
        # OK
        unset($className);
        try {
            $location = __FILE__ . ':' . (string) (__LINE__ + 1);
            throw new \RuntimeException('Something went wrong'); 

        } catch (\Exception $e) {
            $className = get_class($e);
            $msg = $e->getMessage();
            $code = $e->getCode();
        }

        $expectedClass = \RuntimeException::class;
        $expectedMsg = 'Something went wrong';
        $expectedCode = 0;

        if (empty($className)) {
            $failMsg = 'Exception: ' . $expectedClass;
            $failMsg .= ' with msg: ' . $expectedMsg;
            $failMsg .= ' and code: ' . $expectedCode;
            $failMsg .= ' at: ' . $location;
            $failMsg .= ' Not Thrown!';
            $this->fail($failMsg);
        }

        $this->assertSame($expectedClass, $className);
        $this->assertSame($expectedMsg, $msg);
        $this->assertSame($expectedCode, $code);

        # ------------------------------------------

        # Fail
        unset($className);
        try {
            $location = __FILE__ . ':' . (string) (__LINE__ + 1);
            throw new \InvalidArgumentException('I MUST FAIL !'); 

        } catch (\Exception $e) {
            $className = get_class($e);
            $msg = $e->getMessage();
            $code = $e->getCode();
        }

        $expectedClass = \InvalidArgumentException::class;
        $expectedMsg = 'Something went wrong';
        $expectedCode = 0;

        if (empty($className)) {
            $failMsg = 'Exception: ' . $expectedClass;
            $failMsg .= ' with msg: ' . $expectedMsg;
            $failMsg .= ' and code: ' . $expectedCode;
            $failMsg .= ' at: ' . $location;
            $failMsg .= ' Not Thrown!';
            $this->fail($failMsg);
        }

        $this->assertSame($expectedClass, $className);
        $this->assertSame($expectedMsg, $msg);
        $this->assertSame($expectedCode, $code);
    }
}

给:

 ✘ Throw exception
   ┐
   ├ Failed asserting that two strings are identical.
   ┊ ---·Expected
   ┊ +++·Actual
   ┊ @@ @@
   ┊ -'Something·went·wrong'
   ┊ +'I·MUST·FAIL·!'

FAILURES!
Tests: 1, Assertions: 5, Failures: 1.

虽然失败了,但我的天哪,你读了上面所有的东西吗?你需要注意清除未设置的变量($className);来检测是否抛出异常,那么这个生物$location = __FILE__…有一个异常的精确位置,以防它没有被抛出,然后检查异常是否被抛出if (empty($className)){…}和使用$this->fail($failMsg);在未抛出异常时发出信号。

使用PHPUnit的数据提供程序

PHPUnit有一个叫做数据提供者的有用机制。数据提供程序是返回带有数据集的数据(数组)的方法。当PHPUnit调用测试方法testThrowException时,使用单个数据集作为参数。

如果数据提供者返回多个数据集,则测试方法将运行多次,每次使用另一个数据集。当测试多个异常或/和多个异常的属性(如类名、消息、代码)时,这是很有帮助的,因为即使:

一旦抛出异常,PHP就不能返回到抛出异常的行之后的代码行。

PHPUnit将多次运行测试方法,每次使用不同的数据集,这样就不会在单个测试方法中运行多个异常(这会失败)。

这就是为什么我们可以让一个测试方法一次只负责测试一个异常,但通过使用PHPUnit的数据提供程序,使用不同的输入数据和预期异常多次运行该测试方法。

数据提供程序方法的定义可以通过对测试方法做@dataProvider注释来完成,该测试方法应该由数据提供程序提供一个数据集。

<?php

class ExceptionCheck
{
    public function throwE($data)
    {
        if ($data === 1) {
            throw new \RuntimeException;
        } else {
            throw new \InvalidArgumentException;
        }
    }
}

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function ExceptionTestProvider() : array
    {
        $data = [
            \RuntimeException::class =>
            [
                [
                    'input' => 1,
                    'className' => \RuntimeException::class
                ]
            ],

            \InvalidArgumentException::class =>
            [
                [
                    'input' => 2,
                    'className' => \InvalidArgumentException::class
                ]
            ]
        ];
        return $data;
    }

    /**
     * @dataProvider ExceptionTestProvider
     */
    public function testThrowException($data): void
    {
        $this->expectException($data['className']);
        $exceptionCheck = new ExceptionCheck;

        $exceptionCheck->throwE($data['input']);
    }
}

给出结果:

 ✔ Throw exception with RuntimeException
 ✔ Throw exception with InvalidArgumentException

OK (2 tests, 2 assertions)

注意,即使在整个ExceptionTest中只有一个测试方法,PHPUnit的输出是:

OK(2个测试,2个断言)

所以即使是这一行

$exceptionCheck->throwE($data['input']);

在第一次抛出异常时,使用相同的测试方法测试另一个异常是没有问题的,因为由于数据提供程序,PHPUnit使用不同的数据集再次运行它。

数据提供程序返回的每个数据集都可以命名,您只需要使用一个字符串作为存储数据集的键。因此,预期的异常类名被使用了两次。作为数据集数组的键和值(在'className'键下),稍后用作$this->expectException()的参数。

使用字符串作为数据集的键名可以做出漂亮且不言自明的总结:

✔使用RuntimeException抛出异常 ✔使用InvalidArgumentException抛出异常

如果你改变这一行:

if ($data === 1) {

to:

if ($data !== 1) {

公共函数throwE($data)

抛出错误的异常,再次运行PHPUnit,你会看到:

 ✘ Throw exception with RuntimeException
   ├ Failed asserting that exception of type "InvalidArgumentException" matches expected exception "RuntimeException". Message was: "" at (...)

 ✘ Throw exception with InvalidArgumentException
   ├ Failed asserting that exception of type "RuntimeException" matches expected exception "InvalidArgumentException". Message was: "" at (...)

FAILURES!
Tests: 2, Assertions: 2, Failures: 2.

像预期的那样:

失败! 测试:2,断言:2,失败:2。

准确地指出了造成一些问题的数据集名称:

所以符合英语习惯的是抛出RuntimeException异常 所以符合英语习惯的是抛出InvalidArgumentException异常

使公共函数throwE($data)不抛出任何异常:

public function throwE($data)
{
}

再次运行PHPUnit得到:

 ✘ Throw exception with RuntimeException
   ├ Failed asserting that exception of type "RuntimeException" is thrown.

 ✘ Throw exception with InvalidArgumentException
   ├ Failed asserting that exception of type "InvalidArgumentException" is thrown.

FAILURES!
Tests: 2, Assertions: 2, Failures: 2.

看起来使用数据提供程序有几个优点:

The Input data and/or expected data is separated from the actual test method. Every data set can have a descriptive name that clearly points out what data set caused test to pass or fail. In case of a test fail you get a proper failure message mentioning that an exception was not thrown or a wrong exception was thrown instead of an assertion that x is not y. There is only a single test method needed for testing a single method that may throw multiple exceptions. It is possible to test multiple exceptions and/or multiple exception's properties like class name, message, code. No need for any non-essential code like try catch block, instead just using the built in PHPUnit's feature.

测试异常陷阱

类型为"TypeError"的异常

与PHP7数据类型支持这个测试:

<?php
declare(strict_types=1);

class DatatypeChat
{
    public function say(string $msg)
    {
        if (!is_string($msg)) {
            throw new \InvalidArgumentException('Message must be a string');
        }
        return "Hello $msg";
    }
}

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testSay(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $chat = new DatatypeChat;
        $chat->say(array());
    }
}

输出失败:

 ✘ Say
   ├ Failed asserting that exception of type "TypeError" matches expected exception "InvalidArgumentException". Message was: "Argument 1 passed to DatatypeChat::say() must be of the type string, array given (..)

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

即使在方法中说:

if (!is_string($msg)) {
   throw new \InvalidArgumentException('Message must be a string');
}

测试传递的是一个数组而不是字符串:

$chat->say(array());

PHP没有达到代码:

throw new \InvalidArgumentException('Message must be a string');

因为先前由于类型类型字符串而引发异常:

public function say(string $msg)

因此抛出TypeError而不是InvalidArgumentException

类型"TypeError"的异常

知道我们不需要if (!is_string($msg))来检查数据类型,因为PHP已经关心了这一点,如果我们在方法声明say(string $msg)中指定了数据类型,我们可能会在消息太长时抛出InvalidArgumentException if (strlen($msg) > 3)。

<?php
declare(strict_types=1);

class DatatypeChat
{
    public function say(string $msg)
    {
        if (strlen($msg) > 3) {
            throw new \InvalidArgumentException('Message is too long');
        }
        return "Hello $msg";
    }
}

class ExceptionTest extends \PHPUnit\Framework\TestCase
{
    public function testSayTooLong(): void
    {
        $this->expectException(\Exception::class);
        $chat = new DatatypeChat;
        $chat->say('I have more than 3 chars');
    }

    public function testSayDataType(): void
    {
        $this->expectException(\Exception::class);
        $chat = new DatatypeChat;
        $chat->say(array());
    }
}

同时修改ExceptionTest,这样我们就有了两个抛出异常的情况(测试方法)——当消息太长时,第一个是testSayTooLong,当消息类型错误时,第二个是testSayDataType。

在这两个测试中,我们希望使用泛型exception类,而不是像InvalidArgumentException或TypeError这样的特定异常类

$ this - > expectException(\例外::类);

测试结果为:

 ✔ Say too long
 ✘ Say data type
   ├ Failed asserting that exception of type "TypeError" matches expected exception "Exception". Message was: "Argument 1 passed to DatatypeChat::say() must be of the type string, array given (..)

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

testSayTooLong()期望一个通用异常,并使用

$ this - > expectException(\例外::类);

当抛出InvalidArgumentException时,传入OK

but

testSayDataType()使用相同的$this->expectException(\Exception::class);描述不符合:

断言类型为“TypeError”的异常与预期异常“exception”匹配失败。

它看起来令人困惑的PHPUnit抱怨异常TypeError不是一个异常,否则它不会有任何问题$this->expectException(\ exception::class);在testSayDataType()内部,因为它没有任何问题与testSayTooLong()抛出InvalidArgumentException和期望:$this->expectException(\Exception::class);

问题是PHPUnit用上面的描述误导了您,因为TypeError不是一个异常。TypeError既不能从Exception类扩展,也不能从它的任何其他子类扩展。

TypeError实现Throwable接口,参见文档

InvalidArgumentException扩展了LogicException文档

和LogicException扩展了异常文档

因此InvalidArgumentException也扩展了Exception。

这就是为什么抛出InvalidArgumentException通过OK测试和$this->expectException(\Exception::class);但抛出TypeError将不会(它不扩展Exception)

但是Exception和TypeError都实现了Throwable接口。

因此在两个测试中都有变化

$ this - > expectException(\例外::类);

to

$ this - > expectException (\ Throwable::类);

使测试变为绿色:

 ✔ Say too long
 ✔ Say data type

OK (2 tests, 2 assertions)

请参阅错误和异常类列表以及它们之间的关系。

明确一点:在单元测试中使用特定的异常或错误而不是通用的exception或Throwable是一个很好的实践,但如果你曾经遇到过关于异常的误导性评论,现在你就会知道为什么PHPUnit的异常TypeError或其他异常错误实际上不是异常,而是Throwable

其他回答

您可以使用assertException扩展在一次测试执行期间断言多个异常。

插入方法到您的TestCase并使用:

public function testSomething()
{
    $test = function() {
        // some code that has to throw an exception
    };
    $this->assertException( $test, 'InvalidArgumentException', 100, 'expected message' );
}

我还为喜欢漂亮代码的人做了一个trait ..

下面的代码将测试异常消息和异常代码。

重要提示:如果没有抛出预期的异常,它将失败。

try{
    $test->methodWhichWillThrowException();//if this method not throw exception it must be fail too.
    $this->fail("Expected exception 1162011 not thrown");
}catch(MySpecificException $e){ //Not catching a generic Exception or the fail function is also catched
    $this->assertEquals(1162011, $e->getCode());
    $this->assertEquals("Exception Message", $e->getMessage());
}

如果你在PHP 5.5+上运行,你可以使用::class解析通过expectException/setExpectedException获取类名。这有几个好处:

名称将完全限定其名称空间(如果有的话)。 它将解析为一个字符串,因此它将适用于任何版本的PHPUnit。 在IDE中实现代码完成。 如果键入错误的类名,PHP编译器将发出一个错误。

例子:

namespace \My\Cool\Package;

class AuthTest extends \PHPUnit_Framework_TestCase
{
    public function testLoginFailsForWrongPassword()
    {
        $this->expectException(WrongPasswordException::class);
        Auth::login('Bob', 'wrong');
    }
}

PHP编译

WrongPasswordException::class

into

"\My\Cool\Package\WrongPasswordException"

没有PHPUnit是明智的。

注意:PHPUnit 5.2引入了expectException作为setExpectedException的替换。

你也可以在PHPUnit 9发布之前使用文档块注释:

class ExceptionTest extends PHPUnit_Framework_TestCase
{
    /**
     * @expectedException InvalidArgumentException
     */
    public function testException()
    {
        ...
    }
}

对于PHP 5.5+(特别是带有命名空间的代码),我现在更喜欢使用::class

PhpUnit是一个很棒的库,但这一点有点令人沮丧。这就是为什么我们可以使用turbotesting-php开源库,它有一个非常方便的断言方法来帮助我们测试异常。在这里可以找到:

https://github.com/edertone/TurboTesting/blob/master/TurboTesting-Php/src/main/php/utils/AssertUtils.php

要使用它,我们只需执行以下操作:

AssertUtils::throwsException(function(){

    // Some code that must throw an exception here

}, '/expected error message/');

如果我们在匿名函数中键入的代码没有抛出异常,则会抛出异常。

如果我们在匿名函数中键入的代码抛出异常,但其消息与预期的regexp不匹配,则也将抛出异常。