Skip to content

Commit

Permalink
[9.x] Improve test failure output (#43943)
Browse files Browse the repository at this point in the history
* Append exceptions and errors on all test failures

* Move tests

* formatting

* Uses FQN

* Unsets `latestResponse` on setup

* Uses `is_null`

* formatting

Co-authored-by: Nuno Maduro <enunomaduro@gmail.com>
Co-authored-by: Taylor Otwell <taylor@laravel.com>
  • Loading branch information
3 people authored Sep 15, 2022
1 parent f955686 commit b7e096a
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 162 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ trait MakesHttpRequests
*/
protected $withCredentials = false;

/**
* The latest test response.
*
* @var \Illuminate\Testing\TestResponse|null
*/
public $latestResponse;

/**
* Define additional headers to be sent with the request.
*
Expand Down Expand Up @@ -544,7 +551,7 @@ public function call($method, $uri, $parameters = [], $cookies = [], $files = []
$response = $this->followRedirects($response);
}

return $this->createTestResponse($response);
return $this->latestResponse = $this->createTestResponse($response);
}

/**
Expand Down
123 changes: 123 additions & 0 deletions src/Illuminate/Foundation/Testing/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
use Illuminate\Console\Application as Artisan;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Bootstrap\HandleExceptions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Queue\Queue;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\Str;
use Illuminate\Testing\AssertableJsonString;
use Mockery;
use Mockery\Exception\InvalidCountException;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\TestCase as BaseTestCase;
use ReflectionProperty;
use Throwable;

abstract class TestCase extends BaseTestCase
Expand Down Expand Up @@ -81,6 +86,8 @@ abstract public function createApplication();
*/
protected function setUp(): void
{
$this->latestResponse = null;

Facade::clearResolvedInstances();

if (! $this->app) {
Expand Down Expand Up @@ -262,4 +269,120 @@ protected function callBeforeApplicationDestroyedCallbacks()
}
}
}

/**
* This method is called when a test method did not execute successfully.
*
* @param \Throwable $exception
* @return void
*/
protected function onNotSuccessfulTest(Throwable $exception): void
{
if (! $exception instanceof ExpectationFailedException || is_null($this->latestResponse)) {
parent::onNotSuccessfulTest($exception);
}

if ($lastException = $this->latestResponse->exceptions->last()) {
parent::onNotSuccessfulTest($this->appendExceptionToException($lastException, $exception));

return;
}

if ($this->latestResponse->baseResponse instanceof RedirectResponse) {
$session = $this->latestResponse->baseResponse->getSession();

if (! is_null($session) && $session->has('errors')) {
parent::onNotSuccessfulTest($this->appendErrorsToException($session->get('errors')->all(), $exception));

return;
}
}

if ($this->latestResponse->baseResponse->headers->get('Content-Type') === 'application/json') {
$testJson = new AssertableJsonString($this->latestResponse->getContent());

if (isset($testJson['errors'])) {
parent::onNotSuccessfulTest($this->appendErrorsToException($testJson->json(), $exception, true));

return;
}
}

parent::onNotSuccessfulTest($exception);
}

/**
* Append an exception to the message of another exception.
*
* @param \Throwable $exceptionToAppend
* @param \Throwable $exception
* @return \Throwable
*/
protected function appendExceptionToException($exceptionToAppend, $exception)
{
$exceptionMessage = $exceptionToAppend->getMessage();

$exceptionToAppend = (string) $exceptionToAppend;

$message = <<<"EOF"
The following exception occurred during the last request:
$exceptionToAppend
----------------------------------------------------------------------------------
$exceptionMessage
EOF;

return $this->appendMessageToException($message, $exception);
}

/**
* Append errors to an exception message.
*
* @param array $errors
* @param \Throwable $exception
* @param bool $json
* @return \Throwable
*/
protected function appendErrorsToException($errors, $exception, $json = false)
{
$errors = $json
? json_encode($errors, JSON_PRETTY_PRINT)
: implode(PHP_EOL, Arr::flatten($errors));

// JSON error messages may already contain the errors, so we shouldn't duplicate them...
if (str_contains($exception->getMessage(), $errors)) {
return $exception;
}

$message = <<<"EOF"
The following errors occurred during the last request:
$errors
EOF;

return $this->appendMessageToException($message, $exception);
}

/**
* Append a message to an exception.
*
* @param string $message
* @param \Throwable $exception
* @return \Throwable
*/
protected function appendMessageToException($message, $exception)
{
$property = new ReflectionProperty($exception, 'message');

$property->setAccessible(true);

$property->setValue(
$exception,
$exception->getMessage().PHP_EOL.PHP_EOL.$message.PHP_EOL
);

return $exception;
}
}
77 changes: 1 addition & 76 deletions src/Illuminate/Testing/TestResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Illuminate\Cookie\CookieValuePrefix;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
Expand Down Expand Up @@ -44,7 +43,7 @@ class TestResponse implements ArrayAccess
*
* @var \Illuminate\Support\Collection
*/
protected $exceptions;
public $exceptions;

/**
* The streamed content of the response.
Expand Down Expand Up @@ -190,83 +189,9 @@ public function assertStatus($status)
*/
protected function statusMessageWithDetails($expected, $actual)
{
$lastException = $this->exceptions->last();

if ($lastException) {
return $this->statusMessageWithException($expected, $actual, $lastException);
}

if ($this->baseResponse instanceof RedirectResponse) {
$session = $this->baseResponse->getSession();

if (! is_null($session) && $session->has('errors')) {
return $this->statusMessageWithErrors($expected, $actual, $session->get('errors')->all());
}
}

if ($this->baseResponse->headers->get('Content-Type') === 'application/json') {
$testJson = new AssertableJsonString($this->getContent());

if (isset($testJson['errors'])) {
return $this->statusMessageWithErrors($expected, $actual, $testJson->json());
}
}

return "Expected response status code [{$expected}] but received {$actual}.";
}

/**
* Get an assertion message for a status assertion that has an unexpected exception.
*
* @param string|int $expected
* @param string|int $actual
* @param \Throwable $exception
* @return string
*/
protected function statusMessageWithException($expected, $actual, $exception)
{
$message = $exception->getMessage();

$exception = (string) $exception;

return <<<EOF
Expected response status code [$expected] but received $actual.
The following exception occurred during the request:
$exception
----------------------------------------------------------------------------------
$message
EOF;
}

/**
* Get an assertion message for a status assertion that contained errors.
*
* @param string|int $expected
* @param string|int $actual
* @param array $errors
* @return string
*/
protected function statusMessageWithErrors($expected, $actual, $errors)
{
$errors = $this->baseResponse->headers->get('Content-Type') === 'application/json'
? json_encode($errors, JSON_PRETTY_PRINT)
: implode(PHP_EOL, Arr::flatten($errors));

return <<<EOF
Expected response status code [$expected] but received $actual.
The following errors occurred during the request:
$errors
EOF;
}

/**
* Assert whether the response is redirecting to a given URI.
*
Expand Down
92 changes: 92 additions & 0 deletions tests/Foundation/Testing/TestCaseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Tests\Foundation\Testing;

use Exception;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Session\NullSessionHandler;
use Illuminate\Session\Store;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\TestCase as BaseTestCase;

class TestCaseTest extends BaseTestCase
{
public function test_it_includes_response_exceptions_on_test_failures()
{
$testCase = new ExampleTestCase();
$testCase->latestResponse = TestResponse::fromBaseResponse(new Response())
->withExceptions(collect([new Exception('Unexpected exception.')]));

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessageMatches('/Assertion message.*Unexpected exception/s');

$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
}

public function test_it_includes_validation_errors_on_test_failures()
{
$testCase = new ExampleTestCase();
$testCase->latestResponse = TestResponse::fromBaseResponse(
tap(new RedirectResponse('/'))
->setSession(new Store('test-session', new NullSessionHandler()))
->withErrors([
'first_name' => 'The first name field is required.',
])
);

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessageMatches('/Assertion message.*The first name field is required/s');
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
}

public function test_it_includes_json_validation_errors_on_test_failures()
{
$testCase = new ExampleTestCase();
$testCase->latestResponse = TestResponse::fromBaseResponse(
new Response(['errors' => ['first_name' => 'The first name field is required.']])
);

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessageMatches('/Assertion message.*The first name field is required/s');
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
}

public function test_it_doesnt_fail_with_false_json()
{
$testCase = new ExampleTestCase();
$testCase->latestResponse = TestResponse::fromBaseResponse(
new Response(false, 200, ['Content-Type' => 'application/json'])
);

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessageMatches('/Assertion message/s');
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
}

public function test_it_doesnt_fail_with_encoded_json()
{
$testCase = new ExampleTestCase();
$testCase->latestResponse = TestResponse::fromBaseResponse(
tap(new Response, function ($response) {
$response->header('Content-Type', 'application/json');
$response->header('Content-Encoding', 'gzip');
$response->setContent('b"x£½V*.I,)-V▓R╩¤V¬\x05\x00+ü\x059"');
})
);

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessageMatches('/Assertion message/s');
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
}
}

class ExampleTestCase extends TestCase
{
public function createApplication()
{
//
}
}
Loading

0 comments on commit b7e096a

Please sign in to comment.