skip to content

In Laravel HTTP tests, why does an invalid checkout sent with post() redirect while postJson() returns 422, and which assertions fit each?

level: middleimportance: should knowfreq 50%

answer

  1. the Accept header decides
  2. 302 back with errors in the session
  3. assertSessionHasErrors vs assertJsonValidationErrors
  4. assertInvalid handles both
  5. JSON requests drop cookies without withCredentials()

basics

~20 s

postJson() 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 s

The 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
<?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

for a junior

Know that post() failures redirect with session errors and postJson() failures return 422, and name one assertion for each.

for a middle

Explain that the Accept header drives the rendering, that assertInvalid picks session or JSON by content type, and how withHeaders changes it.

for a senior

Catch the cookie trap: JSON helpers send cookies only after withCredentials(), and withCookie() values are encrypted like real ones.

for a principal

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.