Laravel Testing with Pest, Part 2: HTTP and Feature Tests

9 min readUpdated on

Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5

Unit tests answer whether a small class behaves correctly. Feature tests answer the more useful question for a web application: can a real client use this endpoint safely? Laravel boots the application, matches a route, runs middleware, resolves the controller and writes to a test database. That is a larger slice than a unit test, but it is still fast enough to make part of the daily feedback loop.

This article builds an endpoint that lets an account manager create an order for their own account. We will test the public contract rather than controller internals: authentication, authorization, validation, the response, and the database effect. The example deliberately includes an action class so the HTTP boundary remains small without turning every controller test into a mock exercise.

Start with a contract a client could understand

An account manager posts a product SKU and quantity to an account-scoped URL. The route names the resource boundary clearly: an order belongs to an account, not to a browser session or a controller.

php
<?php

use App\Http\Controllers\AccountOrderController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function (): void {
    Route::post('/accounts/{account}/orders', AccountOrderController::class)
        ->name('accounts.orders.store');
});

Keep the URL and its expected response in the test before optimising the implementation. A client needs a 201 Created response and a stable body. It does not need to know whether the application used Order::create() or an action class internally.

For this example, the relevant relationships are intentionally conventional: an Account has many users and orders; an Order stores account_id, product_id, quantity, status, and created_by. A product exposes an SKU and a price in cents. Factories for those models let the test describe only the facts it needs.

Validate data at the HTTP boundary

A form request makes invalid input a first-class part of the endpoint contract. It also means the action only sees typed, validated data. Do not copy these rules into the controller and the test; test the externally visible validation errors instead.

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class StoreAccountOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('createOrder', $this->route('account')) ?? false;
    }

    /** @return array<string, list<string>> */
    public function rules(): array
    {
        return [
            'product_sku' => ['required', 'string', 'exists:products,sku'],
            'quantity' => ['required', 'integer', 'min:1', 'max:500'],
        ];
    }
}

The request is not a substitute for every domain invariant. A maximum of 500 is an input constraint. Whether an account is allowed to order a discontinued product or exceed its credit limit is business policy, and belongs closer to the order-creation operation where a queue job, CLI command, or API endpoint cannot bypass it.

Put the write and domain checks behind one operation

The action resolves the product, enforces a business rule, and creates the order. In a real checkout this may also reserve stock or create an outbox event; use a database transaction when several writes must succeed or fail together. Here the single create is enough, but the dependency boundary is already useful.

php
<?php

declare(strict_types=1);

namespace App\Actions\Orders;

use App\Models\Account;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Validation\ValidationException;

final class CreateAccountOrder
{
    public function handle(Account $account, User $creator, string $productSku, int $quantity): Order
    {
        $product = Product::query()->where('sku', $productSku)->firstOrFail();

        if (! $product->is_orderable) {
            throw ValidationException::withMessages([
                'product_sku' => 'This product is not available for ordering.',
            ]);
        }

        return Order::query()->create([
            'account_id' => $account->id,
            'product_id' => $product->id,
            'quantity' => $quantity,
            'status' => 'pending',
            'created_by' => $creator->id,
        ]);
    }
}

The exception is deliberately shaped like a validation failure. That gives an API client one predictable way to render a field-level rejection, whether the SKU was missing or became unavailable between page load and submit. For a rule that is not tied to a field—such as a credit hold—a 409 Conflict response or problem-details resource can be clearer. The important point is to decide and test the public outcome.

Keep the controller a translation layer

The invokable controller translates HTTP input into the application operation and the result back into an HTTP response. It should not repeat validation, policy decisions, or query details.

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\Orders\CreateAccountOrder;
use App\Http\Requests\StoreAccountOrderRequest;
use App\Models\Account;
use Illuminate\Http\JsonResponse;

final class AccountOrderController
{
    public function __invoke(
        StoreAccountOrderRequest $request,
        Account $account,
        CreateAccountOrder $createAccountOrder,
    ): JsonResponse {
        $order = $createAccountOrder->handle(
            $account,
            $request->user(),
            $request->string('product_sku')->toString(),
            $request->integer('quantity'),
        );

        return response()->json([
            'data' => [
                'id' => $order->id,
                'status' => $order->status,
                'quantity' => $order->quantity,
            ],
        ], 201);
    }
}

Notice what the HTTP test should not assert: that CreateAccountOrder was called once. That would couple the test to a private collaboration and make a safe refactor look like a regression. Test the observable order instead.

Test the successful request from the client side

Pest's HTTP helpers make intent compact. RefreshDatabase gives every test a known database state; factories make the authorization relationship explicit.

php
<?php

use App\Models\Account;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('creates a pending order for an account manager', function (): void {
    $account = Account::factory()->create();
    $manager = User::factory()->for($account)->create();
    $product = Product::factory()->create([
        'sku' => 'SUPPORT-20H',
        'is_orderable' => true,
    ]);

    $response = $this->actingAs($manager)->postJson(
        route('accounts.orders.store', $account),
        ['product_sku' => $product->sku, 'quantity' => 3],
    );

    $response
        ->assertCreated()
        ->assertJsonPath('data.status', 'pending')
        ->assertJsonPath('data.quantity', 3);

    $this->assertDatabaseHas(Order::class, [
        'account_id' => $account->id,
        'product_id' => $product->id,
        'quantity' => 3,
        'status' => 'pending',
        'created_by' => $manager->id,
    ]);
});

The database assertion is essential. A 201 and JSON body can be returned even when an implementation accidentally omits a write, writes the wrong foreign key, or queues work that never persists the order. Conversely, avoid asserting every column: timestamps and implementation-only fields make tests needlessly brittle.

Treat authorization and validation as separate behaviours

One passing happy path does not demonstrate that the account boundary is safe. An authenticated manager must not be able to create an order for another account simply by changing a URL segment. Test that directly, then test invalid data with a user who is allowed to reach validation.

php
<?php

use App\Models\Account;
use App\Models\Order;
use App\Models\User;

it('forbids a manager from ordering for another account', function (): void {
    $ownAccount = Account::factory()->create();
    $otherAccount = Account::factory()->create();
    $manager = User::factory()->for($ownAccount)->create();

    $this->actingAs($manager)
        ->postJson(route('accounts.orders.store', $otherAccount), [
            'product_sku' => 'SUPPORT-20H',
            'quantity' => 3,
        ])
        ->assertForbidden();

    $this->assertDatabaseCount(Order::class, 0);
});

it('returns field errors and creates no order for invalid input', function (): void {
    $account = Account::factory()->create();
    $manager = User::factory()->for($account)->create();

    $this->actingAs($manager)
        ->postJson(route('accounts.orders.store', $account), [
            'product_sku' => 'UNKNOWN',
            'quantity' => 0,
        ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['product_sku', 'quantity']);

    $this->assertDatabaseCount(Order::class, 0);
});

The ordering matters. Laravel runs authorization before validation in a form request. If the forbidden test also expects validation errors, it teaches the wrong contract and can leak which product SKUs exist. Give each test one reason to fail.

Common feature-test traps

The first trap is testing a policy through only a model unit test. Policy tests are useful, but the route can still omit auth, bind a different parameter, or call the wrong ability. A focused forbidden HTTP test catches that wiring.

The second is relying on seed data, IDs, or a globally authenticated user. Tests then pass locally because a database happened to contain the expected records and fail in parallel runs. Create the account, user, and product in the test; make the relationship that grants access visible in the arrange section.

The third is mocking Eloquent or the action for a feature test. Mocks are right when an external HTTP gateway or clock must be isolated, but replacing the write path here removes exactly the database and serialization integration this test is intended to cover. Keep the action real and write its separate unit tests only when its branching logic grows meaningful.

Finally, do not turn one feature test into an end-to-end suite. Browser tests should cover JavaScript behaviour, form focus, and an Inertia or Livewire interaction. A feature test is faster and more precise for the server-side HTTP contract. Use both layers when the risk justifies them.

When not to use a feature test

Do not use this layer to prove every arithmetic branch of a pure price calculator, every date edge case in a value object, or all permutations of a complex credit policy. Those cases are cheaper and clearer as unit tests. Do not use it to verify that a third-party payment provider accepts a request either; use a contract or sandbox test with explicit credentials and a separate execution group.

Use a feature test when routing, middleware, request validation, authorization, model binding, persistence, and response shape have to work together. A small set of representative HTTP tests gives confidence at that seam. Pair it with many focused unit tests below it and a few browser tests above it, rather than asking one layer to carry every kind of confidence.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.