In Laravel HTTP tests, why does an invalid checkout sent with post() redirect while postJson() returns 422, and which assertions fit each?
answer
- the Accept header decides
- 302 back with errors in the session
- assertSessionHasErrors vs assertJsonValidationErrors
- assertInvalid handles both
- JSON requests drop cookies without withCredentials()
basics
~20 spostJson() sends Accept: application/json, so a validation failure renders as a 422 JSON response; post() sends a form request, so Laravel redirects back with errors flashed to the session. Use assertSessionHasErrors or assertJsonValidationErrors respectively, or assertInvalid for either.
solid answer
~40 sThe difference is the request, not the route. `post()` sends form data without a JSON `Accept` header, so a failed validation redirects back (302) and flashes the errors to the session: assert it with `assertRedirect()` and `assertSessionHasErrors(['size'])`. `postJson()` JSON-encodes the data and adds `Content-Type` and `Accept: application/json`, so the same failure comes back as 422 JSON: assert it with `assertUnprocessable()` and `assertJsonValidationErrors(['size'])`. `assertInvalid(['size'])` works on both, choosing by the response's content type. One more difference bites often: the JSON helpers send the cookies set with `withCookie()` only after `withCredentials()` is called. `withHeaders(['Accept' => 'application/json'])` on `post()` also gets JSON errors, though the body stays form-encoded.
code
php · 31 lines<?php
namespace Tests\Feature;
use Tests\TestCase;
class CheckoutValidationTest extends TestCase
{
public function test_form_checkout_redirects_back_with_errors(): void
{
$this->from('/checkout')
->post('/checkout', ['sku' => 'AM90-42'])
->assertRedirect('/checkout')
->assertSessionHasErrors(['size', 'address']);
}
public function test_api_checkout_returns_422(): void
{
$this->postJson('/checkout', ['sku' => 'AM90-42'])
->assertUnprocessable()
->assertJsonValidationErrors(['size', 'address']);
}
public function test_promo_cookie_reaches_json_checkout(): void
{
$this->withCookie('promo', 'SNKR10')
->withCredentials()
->postJson('/checkout', ['sku' => 'AM90-42', 'size' => 42, 'address' => 'Main St 1'])
->assertValid();
}
}go deeper
Know that post() failures redirect with session errors and postJson() failures return 422, and name one assertion for each.
Explain that the Accept header drives the rendering, that assertInvalid picks session or JSON by content type, and how withHeaders changes it.
Catch the cookie trap: JSON helpers send cookies only after withCredentials(), and withCookie() values are encrypted like real ones.
Decide whether shared endpoints are tested in both styles and standardize on assertInvalid/assertValid so tests survive a move from Blade forms to an API.
## Same route, two kinds of request A sneaker shop's `POST /checkout` validates `sku`, `size` and `address`. In a feature test it can be called two ways: ```php $this->post('/checkout', ['sku' => 'AM90-42']); // form-style $this->postJson('/checkout', ['sku' => 'AM90-42']); // API-style ``` The route, controller and validation rules are identical. What differs is what the test client sends: | | `post()` | `postJson()` | |---|---|---| | Body | form parameters | JSON-encoded string | | `Content-Type` | not set to JSON | `application/json` | | `Accept` | not set | `application/json` | | Cookies from `withCookie()` | sent, encrypted | sent only after `withCredentials()` | Laravel decides how to render a validation failure from whether the request **expects JSON**, which the `Accept` header drives. A form request gets a redirect back with the errors and old input flashed to the session; a JSON request gets a 422 response with the errors in the body. The exact JSON shape is a validation topic; for testing, the status and the assertion are what matter. ## Assertions for the form-style request - `assertRedirect()` or, after `from('/checkout')`, `assertRedirect('/checkout')`. - `assertSessionHasErrors(['size', 'address'])` checks those keys have errors in the default error bag. - `assertSessionHasErrors(['size' => 'The size field is required.'])` checks the exact message. - `assertSessionHasNoErrors()` for the happy path. ## Assertions for the JSON request - `assertUnprocessable()` or `assertStatus(422)`. - `assertJsonValidationErrors(['size'])` checks the keys under `errors` in the body. - `assertJsonMissingValidationErrors(['sku'])` checks a field passed. ## One assertion for both `assertInvalid(['size'])` inspects the response: if its `Content-Type` is `application/json` it delegates to `assertJsonValidationErrors`; otherwise it reads the session's error bag. Its partner `assertValid()` checks there are no errors. With a message, `assertInvalid(['size' => 'required'])` matches when the error **contains** the text, while `assertSessionHasErrors` with a message needs the whole message. That makes `assertInvalid` the most portable choice for tests that should survive a switch between a Blade form and an API. ## Headers and cookies Two helpers shape the request further: 1. **`withHeaders([...])`** adds headers for the next requests. `withHeaders(['Accept' => 'application/json'])->post(...)` makes Laravel render JSON errors even though the body is form-encoded, which mimics a JavaScript client posting a form. 2. **`withCookie('promo', 'SNKR10')`** sets a cookie. By default Laravel **encrypts** test cookies the way the `EncryptCookies` middleware expects, so the application decrypts and reads them normally; `withUnencryptedCookie()` sends a raw value for cookies the app excludes from encryption. The trap is combining them with the JSON helpers. `postJson()`, `getJson()` and `json()` send cookies **only if `withCredentials()` was called**, mirroring a browser API call that omits credentials. A test that sets a promo cookie and then calls `postJson()` without `withCredentials()` sees the checkout ignore the discount, and the bug is in the test. ## What the controller sees Inside the application, both requests look the same where input is concerned: `$request->input('size')` reads the value whether it arrived as form parameters or as a JSON body, because Laravel reads a JSON payload as request input. What differs is everything around the input: - `$request->expectsJson()` is true for `postJson()` and false for `post()`, and exception rendering, validation included, branches on it. - An unauthenticated JSON request gets a 401 JSON response, while a form request is redirected to the login route when one is configured, so guest tests need different assertions too. - Old input flashed by a failed form post is available through `old('size')` on the next page; a JSON failure flashes nothing. Knowing that one header changes rendering this widely explains why the same controller can need both styles of test. ## Choosing in practice - Test a Blade form the way the browser submits it: `post()` plus session assertions. - Test an API endpoint the way clients call it: `postJson()` plus JSON assertions. - Use `assertInvalid()` / `assertValid()` when the same test logic should apply to both. - Name the failing field, not just the status, so the test fails for the right reason. Interviewers use this question to see whether a candidate knows the `Accept` header drives the response, rather than believing the route or the controller decides.
- Why does assertSessionHasErrors fail on a postJson() request even though validation clearly failed?A JSON request's validation failure is rendered as a 422 response with the errors in the body; nothing is flashed to the session. `assertSessionHasErrors` then finds no `errors` key. Use `assertJsonValidationErrors`, or `assertInvalid`, which switches to the JSON check when the response is `application/json`.
- When would you add withHeaders(['Accept' => 'application/json']) to a plain post() call?When the real client posts form-encoded data but expects JSON back, such as a JavaScript fetch of a classic form endpoint. The `Accept` header makes Laravel render a 422 JSON response while the body stays form parameters, so the test matches how that client actually calls the route.
saying these in an interview costs you the question
- The controller decides whether validation errors redirect or return 422.
- postJson() sends cookies set with withCookie() automatically.
- assertSessionHasErrors works on JSON 422 responses too.
- withCookie() sends raw values, so the app cannot decrypt them.
- postJson() only changes the body encoding, not any headers.