请问是否有一种assert
或类似的东西可以测试正在测试的代码中是否抛出了异常?
<?php
require_once 'PHPUnit/Framework.php';
class ExceptionTest extends PHPUnit_Framework_TestCase
{
public function testException()
{
$this->expectException(InvalidArgumentException::class);
// or for PHPUnit < 5.2
// $this->setExpectedException(InvalidArgumentException::class);
//...and then add your test code that generates the exception
exampleMethod($anInvalidArgument);
}
}
expectException() PHPUnit documentation
PHPUnit author article 提供了关于测试异常最佳实践的详细解释。
$this->setExpectedException('\My\Name\Space\MyCustomException');
- AlcalynexpectException()
之后调用。虽然对于一些人来说可能很明显,但这对我来说是个坑。 - Jason McCrearyIncorrectPasswordException
就足够了--消息等于“bob@me.com的密码错误”是附带的。此外,如果您想尽可能地少花时间编写测试,那么简单测试变得非常重要。 - David Harkness另一种方法可以是以下方式:
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Expected Exception Message');
请确保您的测试类继承自\PHPUnit_Framework_TestCase
。
expectExceptionMessage
的行为类似于正则表达式。如果你的错误信息是 'Foo bar Baz',那么 $this->expectExceptionMessage('Foo');
将使测试通过。 - Lucas BustamanteexpectExceptionCode(401)
。 - darryn.tenexpectExceptionMessage($message)
只要实际的异常消息包含指定的消息,就可以正常工作。PHPUnit 内部使用 strpos
来评估条件。 - SébastienTLDR; 直接跳到:使用 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();
}
}
$this->expect
而不是$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');
}
}
测试结果出错:
通过了 (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有一个有用的机制称为数据提供程序。数据提供程序是一种返回数据(数组)及其数据集的方法。单个数据集用作测试方法-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.
使用数据提供程序似乎有几个优点:
使用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
。
我们知道,我们不需要使用if (!is_string($msg))
来检查数据类型,因为如果我们在方法声明say(string $msg)
中指定数据类型,PHP已经会处理这个操作。如果消息太长if (strlen($msg) > 3)
,我们可能希望抛出一个InvalidArgumentException
。
<?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(\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);
失败
,并显示:
失败:期望异常 "Exception",实际上捕获到的却是 "TypeError"。
PHPUnit 抱怨 异常 TypeError
不是一个 Exception
,否则在 testSayDataType()
中的 $this->expectException(\Exception::class);
就和 testSayTooLong()
中的 InvalidArgumentException
和期望值: $this->expectException(\Exception::class);
一样没问题了。
问题在于 PHPUnit 的描述会让你感到困惑,因为 TypeError
不是异常。 TypeError
没有扩展自 Exception
类也没有扩展自其他继承 Exception
的类。
TypeError
实现了 Throwable
接口,见 文档
而
InvalidArgumentException
扩展了 LogicException
文档
和 LogicException
扩展了 Exception
文档
因此 InvalidArgumentException
也扩展了 Exception
。
这就是为什么抛出 InvalidArgumentException
带着 $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)
需要明确的是,在进行单元测试时,使用特定的异常或错误而不是通用的Exception
或Throwable
是一个好习惯。但如果你遇到了这个具有误导性的关于异常的评论,现在你就知道为什么PHPUnit的异常TypeError
或其他异常错误实际上不是Exception
,而是Throwable
。
如果你在使用 PHP 5.5+,你可以使用 ::class
解析来获取类名,使用 expectException
/setExpectedException
。这提供了一些好处:
示例:
namespace \My\Cool\Package;
class AuthTest extends \PHPUnit_Framework_TestCase
{
public function testLoginFailsForWrongPassword()
{
$this->expectException(WrongPasswordException::class);
Auth::login('Bob', 'wrong');
}
}
PHP编译
WrongPasswordException::class
进入
"\My\Cool\Package\WrongPasswordException"
没有让 PHPUnit 感到困惑。
注意: PHPUnit 5.2引入了
expectException
来替换setExpectedException
。
以下代码将测试异常的消息和异常代码。
重要提示:如果未抛出预期异常,它将失败。
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());
}
$this->fail()
并不是用来这种方式的,至少目前不是(PHPUnit 3.6.11);它本身就像一个异常。以您的示例为例,如果调用 $this->fail("Expected exception not thrown")
,那么 catch
块将被触发,并且 $e->getMessage()
的值为“Expected exception not thrown”。 - kenfail
可能应该放在 catch 块之后,而不是 try 块内部。 - Frank Farmerfail
的调用不应该在try
块中。它本身会触发catch
块,从而产生错误的结果。 - Twiftycatch(Exception $e)
来捕获所有异常。当我尝试捕获特定的异常时,这种方法对我来说非常有效: try { throw new MySpecificException; $this->fail('MySpecificException not thrown'); } catch(MySpecificException $e){}
。 - spylepublic function testSomething()
{
$test = function() {
// some code that has to throw an exception
};
$this->assertException( $test, 'InvalidArgumentException', 100, 'expected message' );
}
我还为喜欢优美代码的人编写了一个特性。
assertException
未定义。我在PHPUnit手册中也找不到它。 - physicalattractionasertException
方法不是 PHPUnit 的原始方法。您必须继承 PHPUnit_Framework_TestCase
类并手动添加上面链接的方法。然后,您的测试用例将继承这个继承类。 - hejdavPHPUnit的expectException
方法非常不方便,因为它只允许在一个测试方法中测试一个异常。
我编写了这个辅助函数来断言某个函数是否抛出异常:
/**
* Asserts that the given callback throws the given exception.
*
* @param string $expectClass The name of the expected exception class
* @param callable $callback A callback which should throw the exception
*/
protected function assertException(string $expectClass, callable $callback)
{
try {
$callback();
} catch (\Throwable $exception) {
$this->assertInstanceOf($expectClass, $exception, 'An invalid exception was thrown');
return;
}
$this->fail('No exception was thrown');
}
将其添加到您的测试类中,并以以下方式调用:public function testSomething() {
$this->assertException(\PDOException::class, function() {
new \PDO('bad:param');
});
$this->assertException(\PDOException::class, function() {
new \PDO('foo:bar');
});
}
PHPUnit当前的"最佳实践"在异常测试方面似乎有些平淡无奇 (文档)。
由于我需要更多的功能,而不仅仅是当前的expectException
实现,所以我制作了一个特性用于我的测试案例。 它只有 ~50行代码。
assert
语法assertNotThrows
Throwable
错误我将AssertThrows
特性发布到了Github和Packagist,以便可以使用composer进行安装。
仅为说明语法背后的精神:
<?php
// Using simple callback
$this->assertThrows(MyException::class, [$obj, 'doSomethingBad']);
// Using anonymous function
$this->assertThrows(MyException::class, function() use ($obj) {
$obj->doSomethingBad();
});
很不错吧?
请参阅以下更全面的使用示例:
<?php
declare(strict_types=1);
use Jchook\AssertThrows\AssertThrows;
use PHPUnit\Framework\TestCase;
// These are just for illustration
use MyNamespace\MyException;
use MyNamespace\MyObject;
final class MyTest extends TestCase
{
use AssertThrows; // <--- adds the assertThrows method
public function testMyObject()
{
$obj = new MyObject();
// Test a basic exception is thrown
$this->assertThrows(MyException::class, function() use ($obj) {
$obj->doSomethingBad();
});
// Test custom aspects of a custom extension class
$this->assertThrows(MyException::class,
function() use ($obj) {
$obj->doSomethingBad();
},
function($exception) {
$this->assertEquals('Expected value', $exception->getCustomThing());
$this->assertEquals(123, $exception->getCode());
}
);
// Test that a specific exception is NOT thrown
$this->assertNotThrows(MyException::class, function() use ($obj) {
$obj->doSomethingGood();
});
}
}
?>
public function testException() {
try {
$this->methodThatThrowsException();
$this->fail("Expected Exception has not been raised.");
} catch (Exception $ex) {
$this->assertEquals("Exception message", $ex->getMessage());
}
}
assertEquals()
的签名是 assertEquals(mixed $expected, mixed $actual...)
,与您的示例相反,因此应该是 $this->assertEquals("Exception message", $ex->getMessage());
。 - Roger Campanera