Skip to content

Commit b7e096a

Browse files
jessarchernunomadurotaylorotwell
authored
[9.x] Improve test failure output (#43943)
* 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 <[email protected]> Co-authored-by: Taylor Otwell <[email protected]>
1 parent f955686 commit b7e096a

File tree

5 files changed

+224
-162
lines changed

5 files changed

+224
-162
lines changed

src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ trait MakesHttpRequests
6363
*/
6464
protected $withCredentials = false;
6565

66+
/**
67+
* The latest test response.
68+
*
69+
* @var \Illuminate\Testing\TestResponse|null
70+
*/
71+
public $latestResponse;
72+
6673
/**
6774
* Define additional headers to be sent with the request.
6875
*
@@ -544,7 +551,7 @@ public function call($method, $uri, $parameters = [], $cookies = [], $files = []
544551
$response = $this->followRedirects($response);
545552
}
546553

547-
return $this->createTestResponse($response);
554+
return $this->latestResponse = $this->createTestResponse($response);
548555
}
549556

550557
/**

src/Illuminate/Foundation/Testing/TestCase.php

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
use Illuminate\Console\Application as Artisan;
77
use Illuminate\Database\Eloquent\Model;
88
use Illuminate\Foundation\Bootstrap\HandleExceptions;
9+
use Illuminate\Http\RedirectResponse;
910
use Illuminate\Queue\Queue;
11+
use Illuminate\Support\Arr;
1012
use Illuminate\Support\Carbon;
1113
use Illuminate\Support\Facades\Facade;
1214
use Illuminate\Support\Facades\ParallelTesting;
1315
use Illuminate\Support\Str;
16+
use Illuminate\Testing\AssertableJsonString;
1417
use Mockery;
1518
use Mockery\Exception\InvalidCountException;
19+
use PHPUnit\Framework\ExpectationFailedException;
1620
use PHPUnit\Framework\TestCase as BaseTestCase;
21+
use ReflectionProperty;
1722
use Throwable;
1823

1924
abstract class TestCase extends BaseTestCase
@@ -81,6 +86,8 @@ abstract public function createApplication();
8186
*/
8287
protected function setUp(): void
8388
{
89+
$this->latestResponse = null;
90+
8491
Facade::clearResolvedInstances();
8592

8693
if (! $this->app) {
@@ -262,4 +269,120 @@ protected function callBeforeApplicationDestroyedCallbacks()
262269
}
263270
}
264271
}
272+
273+
/**
274+
* This method is called when a test method did not execute successfully.
275+
*
276+
* @param \Throwable $exception
277+
* @return void
278+
*/
279+
protected function onNotSuccessfulTest(Throwable $exception): void
280+
{
281+
if (! $exception instanceof ExpectationFailedException || is_null($this->latestResponse)) {
282+
parent::onNotSuccessfulTest($exception);
283+
}
284+
285+
if ($lastException = $this->latestResponse->exceptions->last()) {
286+
parent::onNotSuccessfulTest($this->appendExceptionToException($lastException, $exception));
287+
288+
return;
289+
}
290+
291+
if ($this->latestResponse->baseResponse instanceof RedirectResponse) {
292+
$session = $this->latestResponse->baseResponse->getSession();
293+
294+
if (! is_null($session) && $session->has('errors')) {
295+
parent::onNotSuccessfulTest($this->appendErrorsToException($session->get('errors')->all(), $exception));
296+
297+
return;
298+
}
299+
}
300+
301+
if ($this->latestResponse->baseResponse->headers->get('Content-Type') === 'application/json') {
302+
$testJson = new AssertableJsonString($this->latestResponse->getContent());
303+
304+
if (isset($testJson['errors'])) {
305+
parent::onNotSuccessfulTest($this->appendErrorsToException($testJson->json(), $exception, true));
306+
307+
return;
308+
}
309+
}
310+
311+
parent::onNotSuccessfulTest($exception);
312+
}
313+
314+
/**
315+
* Append an exception to the message of another exception.
316+
*
317+
* @param \Throwable $exceptionToAppend
318+
* @param \Throwable $exception
319+
* @return \Throwable
320+
*/
321+
protected function appendExceptionToException($exceptionToAppend, $exception)
322+
{
323+
$exceptionMessage = $exceptionToAppend->getMessage();
324+
325+
$exceptionToAppend = (string) $exceptionToAppend;
326+
327+
$message = <<<"EOF"
328+
The following exception occurred during the last request:
329+
330+
$exceptionToAppend
331+
332+
----------------------------------------------------------------------------------
333+
334+
$exceptionMessage
335+
EOF;
336+
337+
return $this->appendMessageToException($message, $exception);
338+
}
339+
340+
/**
341+
* Append errors to an exception message.
342+
*
343+
* @param array $errors
344+
* @param \Throwable $exception
345+
* @param bool $json
346+
* @return \Throwable
347+
*/
348+
protected function appendErrorsToException($errors, $exception, $json = false)
349+
{
350+
$errors = $json
351+
? json_encode($errors, JSON_PRETTY_PRINT)
352+
: implode(PHP_EOL, Arr::flatten($errors));
353+
354+
// JSON error messages may already contain the errors, so we shouldn't duplicate them...
355+
if (str_contains($exception->getMessage(), $errors)) {
356+
return $exception;
357+
}
358+
359+
$message = <<<"EOF"
360+
The following errors occurred during the last request:
361+
362+
$errors
363+
EOF;
364+
365+
return $this->appendMessageToException($message, $exception);
366+
}
367+
368+
/**
369+
* Append a message to an exception.
370+
*
371+
* @param string $message
372+
* @param \Throwable $exception
373+
* @return \Throwable
374+
*/
375+
protected function appendMessageToException($message, $exception)
376+
{
377+
$property = new ReflectionProperty($exception, 'message');
378+
379+
$property->setAccessible(true);
380+
381+
$property->setValue(
382+
$exception,
383+
$exception->getMessage().PHP_EOL.PHP_EOL.$message.PHP_EOL
384+
);
385+
386+
return $exception;
387+
}
265388
}

src/Illuminate/Testing/TestResponse.php

Lines changed: 1 addition & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
use Illuminate\Cookie\CookieValuePrefix;
99
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
1010
use Illuminate\Database\Eloquent\Model;
11-
use Illuminate\Http\RedirectResponse;
1211
use Illuminate\Http\Request;
1312
use Illuminate\Support\Arr;
1413
use Illuminate\Support\Carbon;
@@ -44,7 +43,7 @@ class TestResponse implements ArrayAccess
4443
*
4544
* @var \Illuminate\Support\Collection
4645
*/
47-
protected $exceptions;
46+
public $exceptions;
4847

4948
/**
5049
* The streamed content of the response.
@@ -190,83 +189,9 @@ public function assertStatus($status)
190189
*/
191190
protected function statusMessageWithDetails($expected, $actual)
192191
{
193-
$lastException = $this->exceptions->last();
194-
195-
if ($lastException) {
196-
return $this->statusMessageWithException($expected, $actual, $lastException);
197-
}
198-
199-
if ($this->baseResponse instanceof RedirectResponse) {
200-
$session = $this->baseResponse->getSession();
201-
202-
if (! is_null($session) && $session->has('errors')) {
203-
return $this->statusMessageWithErrors($expected, $actual, $session->get('errors')->all());
204-
}
205-
}
206-
207-
if ($this->baseResponse->headers->get('Content-Type') === 'application/json') {
208-
$testJson = new AssertableJsonString($this->getContent());
209-
210-
if (isset($testJson['errors'])) {
211-
return $this->statusMessageWithErrors($expected, $actual, $testJson->json());
212-
}
213-
}
214-
215192
return "Expected response status code [{$expected}] but received {$actual}.";
216193
}
217194

218-
/**
219-
* Get an assertion message for a status assertion that has an unexpected exception.
220-
*
221-
* @param string|int $expected
222-
* @param string|int $actual
223-
* @param \Throwable $exception
224-
* @return string
225-
*/
226-
protected function statusMessageWithException($expected, $actual, $exception)
227-
{
228-
$message = $exception->getMessage();
229-
230-
$exception = (string) $exception;
231-
232-
return <<<EOF
233-
Expected response status code [$expected] but received $actual.
234-
235-
The following exception occurred during the request:
236-
237-
$exception
238-
239-
----------------------------------------------------------------------------------
240-
241-
$message
242-
243-
EOF;
244-
}
245-
246-
/**
247-
* Get an assertion message for a status assertion that contained errors.
248-
*
249-
* @param string|int $expected
250-
* @param string|int $actual
251-
* @param array $errors
252-
* @return string
253-
*/
254-
protected function statusMessageWithErrors($expected, $actual, $errors)
255-
{
256-
$errors = $this->baseResponse->headers->get('Content-Type') === 'application/json'
257-
? json_encode($errors, JSON_PRETTY_PRINT)
258-
: implode(PHP_EOL, Arr::flatten($errors));
259-
260-
return <<<EOF
261-
Expected response status code [$expected] but received $actual.
262-
263-
The following errors occurred during the request:
264-
265-
$errors
266-
267-
EOF;
268-
}
269-
270195
/**
271196
* Assert whether the response is redirecting to a given URI.
272197
*
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace Tests\Foundation\Testing;
4+
5+
use Exception;
6+
use Illuminate\Foundation\Testing\TestCase;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Response;
9+
use Illuminate\Session\NullSessionHandler;
10+
use Illuminate\Session\Store;
11+
use Illuminate\Testing\TestResponse;
12+
use PHPUnit\Framework\ExpectationFailedException;
13+
use PHPUnit\Framework\TestCase as BaseTestCase;
14+
15+
class TestCaseTest extends BaseTestCase
16+
{
17+
public function test_it_includes_response_exceptions_on_test_failures()
18+
{
19+
$testCase = new ExampleTestCase();
20+
$testCase->latestResponse = TestResponse::fromBaseResponse(new Response())
21+
->withExceptions(collect([new Exception('Unexpected exception.')]));
22+
23+
$this->expectException(ExpectationFailedException::class);
24+
$this->expectExceptionMessageMatches('/Assertion message.*Unexpected exception/s');
25+
26+
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
27+
}
28+
29+
public function test_it_includes_validation_errors_on_test_failures()
30+
{
31+
$testCase = new ExampleTestCase();
32+
$testCase->latestResponse = TestResponse::fromBaseResponse(
33+
tap(new RedirectResponse('/'))
34+
->setSession(new Store('test-session', new NullSessionHandler()))
35+
->withErrors([
36+
'first_name' => 'The first name field is required.',
37+
])
38+
);
39+
40+
$this->expectException(ExpectationFailedException::class);
41+
$this->expectExceptionMessageMatches('/Assertion message.*The first name field is required/s');
42+
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
43+
}
44+
45+
public function test_it_includes_json_validation_errors_on_test_failures()
46+
{
47+
$testCase = new ExampleTestCase();
48+
$testCase->latestResponse = TestResponse::fromBaseResponse(
49+
new Response(['errors' => ['first_name' => 'The first name field is required.']])
50+
);
51+
52+
$this->expectException(ExpectationFailedException::class);
53+
$this->expectExceptionMessageMatches('/Assertion message.*The first name field is required/s');
54+
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
55+
}
56+
57+
public function test_it_doesnt_fail_with_false_json()
58+
{
59+
$testCase = new ExampleTestCase();
60+
$testCase->latestResponse = TestResponse::fromBaseResponse(
61+
new Response(false, 200, ['Content-Type' => 'application/json'])
62+
);
63+
64+
$this->expectException(ExpectationFailedException::class);
65+
$this->expectExceptionMessageMatches('/Assertion message/s');
66+
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
67+
}
68+
69+
public function test_it_doesnt_fail_with_encoded_json()
70+
{
71+
$testCase = new ExampleTestCase();
72+
$testCase->latestResponse = TestResponse::fromBaseResponse(
73+
tap(new Response, function ($response) {
74+
$response->header('Content-Type', 'application/json');
75+
$response->header('Content-Encoding', 'gzip');
76+
$response->setContent('b"x£½V*.I,)-V▓R╩¤V¬\x05\x00+ü\x059"');
77+
})
78+
);
79+
80+
$this->expectException(ExpectationFailedException::class);
81+
$this->expectExceptionMessageMatches('/Assertion message/s');
82+
$testCase->onNotSuccessfulTest(new ExpectationFailedException('Assertion message.'));
83+
}
84+
}
85+
86+
class ExampleTestCase extends TestCase
87+
{
88+
public function createApplication()
89+
{
90+
//
91+
}
92+
}

0 commit comments

Comments
 (0)