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);
但是Documentation 对测试代码中上述任何方法的顺序含糊不清。
如果你习惯使用断言,例如:
<?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->excpect 而不是 $this->assert 开始是有意义的。
已经知道了:
一旦抛出异常,PHP 将无法返回到抛出异常行之后的代码行。
您应该能够轻松发现此测试中的错误:
<?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() 应该没问题,它期望在抛出预期的确切异常类之前有一个异常类,所以这里没有错。
应该失败的第二个期望 RuntimeException 类在抛出一个完全不同的异常之前,所以它应该失败但 PHPUnit 执行会到达那个地方吗?
测试的输出是:
✔ Throw exception
OK (1 test, 1 assertion)
OK?
不,如果测试通过,它与OK 相差甚远,第二个异常应该是Fail。这是为什么呢?
注意输出有:
OK(1 个测试,1 个断言)
测试计数正确但只有1 assertion。
应该有 2 个断言 = 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.
FAILURES 应该如此。
但是,此方法的基本方法有一个缺点 - 对于每个抛出的异常,您都需要单独的测试。这将产生大量的测试来检查异常。
捕获异常并用断言检查它
如果您在抛出异常后无法继续执行脚本,您可以简单地捕获预期的异常,然后使用异常提供的方法获取有关它的所有数据,并将其与预期值和断言的组合使用:
<?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.
FAILURES 应该是这样,但是哦,我的主,您是否阅读了以上所有内容?您需要注意清除变量unset($className); 以检测是否引发了异常,然后此生物$location = __FILE__ ... 具有异常的精确位置以防异常未引发,然后检查是否引发了异常if (empty($className)) { ... }并使用$this->fail($failMsg); 发出异常未引发的信号。
使用 PHPUnit 的数据提供者
PHPUnit 有一个有用的机制,称为Data Provider。数据提供者是一种返回带有数据集的数据(数组)的方法。当 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) {
到:
if ($data !== 1) {
public function 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
引发异常
使public function 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.
看起来使用数据提供者有几个优点:
- 输入数据和/或预期数据与实际测试方法分离。
- 每个数据集都可以有一个描述性名称,清楚地指出哪些数据集导致测试通过或失败。
- 如果测试失败,您会收到正确的失败消息,指出未引发异常或引发错误异常,而不是断言 x 不是 y。
- 只需要一个测试方法来测试一个可能引发多个异常的方法。
- 可以测试多个异常和/或多个异常的属性,例如类名、消息、代码。
- 不需要任何非必要的代码,如 try catch 块,而只需使用 PHPUnit 的内置功能。
测试异常陷阱
“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.
即使方法中有say:
if (!is_string($msg)) {
throw new \InvalidArgumentException('Message must be a string');
}
并且测试通过了一个数组而不是一个字符串:
$chat->say(array());
PHP 没有到达代码:
throw new \InvalidArgumentException('Message must be a string');
因为输入string的类型提前抛出了异常:
public function say(string $msg)
因此抛出TypeError 而不是InvalidArgumentException
再次出现“TypeError”类型异常
知道我们不需要if (!is_string($msg)) 来检查数据类型,因为如果我们在方法声明say(string $msg) 中指定数据类型,PHP 已经注意到了,如果消息太多,我们可能想要抛出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,因此我们有两种情况(测试方法),其中应该抛出Exception - 第一个testSayTooLong,当消息太长时,第二个testSayDataType,当消息类型错误。
在这两个测试中,我们期望通过使用而不是像 InvalidArgumentException 或 TypeError 这样的特定异常类,而只是使用通用 Exception 类
$this->expectException(\Exception::class);
测试结果是:
✔ 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() 期待一个通用的 Exception 并使用
$this->expectException(\Exception::class);
当InvalidArgumentException 被抛出时与OK 一起传递
但是
testSayDataType() 使用与描述相同的$this->expectException(\Exception::class); Fails:
未能断言“TypeError”类型的exception与预期的异常“Exception”匹配。
PHPUnit 抱怨 exception TypeError 不是 Exception 看起来令人困惑,否则在 testSayDataType() 中的 $this->expectException(\Exception::class); 不会有任何问题,因为它没有testSayTooLong() 抛出 InvalidArgumentException 并期待:$this->expectException(\Exception::class);
问题在于 PHPUnit 用上面的描述误导了你,因为TypeError 不是例外。 TypeError 不从 Exception 类或其任何其他子类扩展。
TypeError 实现Throwable 接口见documentation
而
InvalidArgumentException 扩展 LogicException documentation
和LogicException 扩展Exception documentation
因此InvalidArgumentException 也扩展了Exception。
这就是为什么抛出 InvalidArgumentException 通过 OK 和 $this->expectException(\Exception::class); 的测试但抛出 TypeError 不会(它不会扩展 Exception)
但是Exception 和TypeError 都实现了Throwable 接口。
因此在两个测试中都发生了变化
$this->expectException(\Exception::class);
到
$this->expectException(\Throwable::class);
使测试变为绿色:
✔ Say too long
✔ Say data type
OK (2 tests, 2 assertions)
See the list of Errors and Exception classes and how they are related to each other.
要明确一点:在单元测试中使用特定的异常或错误而不是通用的 Exception 或 Throwable 是一个很好的做法,但是如果你现在遇到关于异常的误导性评论,你就会知道为什么 PHPUnit 的异常TypeError或其他异常错误其实不是Exceptions而是Throwable