Course outline

Laravel / Application boundaries and reliable workflows / Lesson 12

Feature tests that prove application boundaries

Exercise routing, authentication, validation, policies, persistence, and response shape through isolated synthetic workflows.

About 5 min readContent reviewed
On this page

Lesson goals

  • Choose an observable promise
  • Prove denial and preserved state
  • Control external boundaries and understand omissions

Choose an observable promise

A feature test exercises a meaningful application boundary through Laravel’s testing facilities rather than calling every private method directly. State the expected behavior in ordinary language first: an author can update their post, another authenticated user cannot, or an invalid title causes no insert. The assertion should follow that promise. A 200 or redirect alone may coexist with a wrong owner, leaked field, or partial mutation. Check the response and the relevant stored or emitted effect. Derive expectations from the business rule instead of copying the controller’s implementation into the test, which can reproduce the same defect on both sides.

Establish a genuinely isolated test environment

Before running database-backed tests, verify that the test configuration selects a disposable database and synthetic fixtures, not the application’s configured working database. RefreshDatabase coordinates database state under its documented behavior and can involve migrations or transactions; the trait is not a safety boundary that proves the target is disposable. Use factories whose defaults satisfy the training schema and provide meaningful relationships. SQLite can be useful for many tests but does not reproduce every production engine’s locking, collation, JSON, or constraint behavior. Important engine-specific contracts need their own approved isolated integration evidence. These examples remain non-runnable fragments because that environment is not provisioned here.

Prove denial and preserved state

The first test class assumes registered PATCH /posts/{post} routing to the update action, an enforced PostPolicy, model factories, migrations, and the posts.show route. It creates two users and a post owned by the first. The other user receives forbidden, and the database assertion proves the original title and owner remain. A separate owner test proves the implementation is not simply rejecting every request. actingAs supplies the chosen web identity for these downstream authorization tests; it does not test password authentication. Both tests use their own fixtures and one primary request, keeping a failure tied to a small, reproducible scenario.

php Contextual Laravel 12 application fragment
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostAuthorizationTest extends TestCase
{
    use RefreshDatabase;
    public function test_other_user_cannot_change_a_post(): void
    {
        $owner = User::factory()->create();
        $other = User::factory()->create();
        $post = Post::factory()->for($owner, "author")->create(["title" => "Original"]);
        $this->actingAs($other, "web")->patch("/posts/{$post->getRouteKey()}", [
            "title" => "Changed", "body" => "Synthetic body",
        ])->assertForbidden();
        $this->assertDatabaseHas("posts", ["id" => $post->id, "user_id" => $owner->id, "title" => "Original"]);
    }
    public function test_author_can_change_a_post(): void
    {
        $owner = User::factory()->create();
        $post = Post::factory()->for($owner, "author")->create(["title" => "Original"]);
        $this->actingAs($owner, "web")->patch("/posts/{$post->getRouteKey()}", [
            "title" => "Changed", "body" => "Synthetic body",
        ])->assertRedirect(route("posts.show", $post));
        $this->assertDatabaseHas("posts", ["id" => $post->id, "user_id" => $owner->id, "title" => "Changed"]);
    }
}
Expected output
In the isolated intended app: non-owner gets 403 and no change; owner gets redirect and changed title.

Requires a separately configured disposable test DB, valid User/Post factories, stated routes, and the registered policy. The shown tests are not executed against the repository application.

Test invalid input without accidental persistence

The second test posts JSON with an empty required title as an authenticated permitted creator. Under the declared Form Request and endpoint, it expects an unprocessable validation response with a title error and zero posts. It does not assert an exact translated sentence, because localization and custom messages can legitimately change wording. The empty database count is meaningful only because the isolated fixture starts with no posts. Add separate valid-input tests for the server-controlled author and publication fields; a validation-only case cannot detect an action that trusts a malicious user_id on successful input. Keep each test’s prerequisites explicit.

php Contextual Laravel 12 application fragment
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PostValidationTest extends TestCase
{
    use RefreshDatabase;
    public function test_missing_title_does_not_create_a_post(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user, "web")->postJson("/posts", [
            "title" => "", "body" => "Synthetic body",
        ])->assertUnprocessable()->assertJsonValidationErrors(["title"]);
        $this->assertDatabaseCount("posts", 0);
    }
}
Expected output
Expected isolated assertions: 422, title validation error, posts count 0.

Assumes an empty isolated posts fixture, permitted create policy, and StorePostRequest routing. It verifies JSON validation semantics and no inserted post, not browser CSRF or rendering.

Control external boundaries and understand omissions

Fake mail, queue dispatch, storage, or HTTP collaborators when the feature contract concerns whether the application requests that effect, then retain focused integration evidence for the actual adapter where required. Avoid real payment, email, or remote-service operations in ordinary feature tests. Laravel’s HTTP testing path is not a browser and does not establish JavaScript interaction, cookie transport through a proxy, or visual accessibility. CSRF middleware is commonly bypassed in the framework’s testing environment, so a passing feature test does not prove browser CSRF wiring. Combine unit, feature, browser, and engine-specific tests according to the actual risk, and rerun only the relevant checks after a focused change.

Common errors and fixes

A test passes despite an unauthorized write

Why it happens
It checks only response status.
How to fix it
Also assert unchanged persisted state.
Prevent it next time
Include denial and valid-owner cases.

RefreshDatabase is assumed to make any target safe

Why it happens
The selected connection was never verified.
How to fix it
Use an explicitly disposable isolated test configuration.
Prevent it next time
Check test environment before database-backed execution.

A feature test is presented as browser coverage

Why it happens
The in-process HTTP path omits browser behavior.
How to fix it
Add appropriate browser evidence for those contracts.
Prevent it next time
State what each test level actually exercised.

Check your understanding

1. Design a valid-create test that detects a forged author id.

Show worked solution

Authenticate user A, submit valid title/body plus user_id=B, and assert the stored post belongs to A with the required server publication state. Assert the intended success response too. This proves that successful input cannot override the ownership boundary, which a missing-title test never reaches.

2. What additional evidence is needed if the production query depends on engine-specific JSON or locking?

Show worked solution

Run a focused integration test against a separately provisioned instance of the intended engine under an approved isolated configuration. Keep synthetic data and explicit cleanup. A portable feature test remains useful for application behavior, but cannot prove semantics its substitute engine does not implement.

Key takeaways

  • Assert outcomes and invariants, not only status.
  • Isolation must be configured and verified.
  • Different test levels provide different evidence.

Official references