Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 12 – Testing Laravel applications

HTTP tests and database tests

Test a Laravel 13 fees feature through real requests and a clean database: factories, RefreshDatabase, JSON, CSRF and policy paths, and mutants.

  • Intermediate
  • 20 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: An HTTP test sends a real request without a server · Database tests start every test from empty tables. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Write HTTP tests with get, post, getJson and actingAs that check the response, the session and the database
  • Isolate every test's data with RefreshDatabase and build exactly the rows it needs with factories
  • Check a JSON answer field by field and a stored row in the form the database holds it
  • Test the CSRF, authorization and token paths that a test run skips by default
  • Choose a database reset for a suite and measure the suite with hand-made mutants

Before you start

What you’ll learn · Before you start

What you will learn

  • Write HTTP tests with get, post, getJson and actingAs that check the response, the session and the database
  • Isolate every test's data with RefreshDatabase and build exactly the rows it needs with factories
  • Check a JSON answer field by field and a stored row in the form the database holds it
  • Test the CSRF, authorization and token paths that a test run skips by default
  • Choose a database reset for a suite and measure the suite with hand-made mutants

Before you start

On this page

Staff at a Pathshala centre add fee instalments in paise, and each guardian reads their own child’s list on a phone. The seven tests of the first two sections check its main paths with 22 assertions, without a browser or a server. Five are HTTP test: A feature test that sends a request through the whole application, its routes, middleware and controller, without a server, and then checks the response, the session or the database., which send a request through the whole application and read its answer, and two are database tests, which count the rows a test writes and prove that the next test starts from empty tables.

The feature is the fees module of the Pathshala project from Factories, seeders and realistic test data, with a policy, a controller and four routes added. Each session below ran on the course’s pinned project, Laravel 13.34.0 on PHP 8.5.11, with PHPUnit 12.5.40 as the test runner.

An HTTP test sends a real request without a server

The first HTTP test below makes a student and a staff member of the student’s centre with factories, signs the staff member in with actingAs() and posts the form’s two fields, then checks what came back:

The tests, the controller, the policy and the routes

tests/Feature/InstalmentFormTest.php · fees-app/tests/Feature/InstalmentFormTest.php

<?php

namespace Tests\Feature;

use App\Models\Instalment;
use App\Models\Student;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

// Five HTTP tests of the instalments feature: each sends one request through the whole application and reads the
// response, the session and the database.
class InstalmentFormTest extends TestCase
{
    use RefreshDatabase;

    public function test_staff_add_an_instalment(): void
    {
        $student = Student::factory()->create();
        $staff = User::factory()->create(['centre_id' => $student->batch->centre_id]);

        $response = $this->actingAs($staff)->post("/students/{$student->id}/instalments", [
            'due_on' => '2026-02-15',
            'amount_paise' => 118000,
        ]);

        $response->assertRedirect("/students/{$student->id}/instalments");
        $response->assertSessionHas('status', 'Instalment added.');
        $this->assertDatabaseHas('instalments', [
            'student_id' => $student->id,
            'amount_paise' => 118000,
            'status' => 'pending',
        ]);
    }

    public function test_an_amount_under_one_rupee_is_refused(): void
    {
        $student = Student::factory()->create();
        $staff = User::factory()->create(['centre_id' => $student->batch->centre_id]);

        $response = $this->actingAs($staff)->post("/students/{$student->id}/instalments", [
            'due_on' => '2026-02-15',
            'amount_paise' => 50,
        ]);

        $response->assertSessionHasErrors('amount_paise');
        $this->assertDatabaseCount('instalments', 0);
    }

    public function test_a_guest_is_sent_to_sign_in(): void
    {
        $student = Student::factory()->create();

        $this->get("/students/{$student->id}/instalments")->assertRedirect('/login');
    }

    public function test_the_guardian_sees_the_list(): void
    {
        $student = Student::factory()->withGuardian()->create();
        Instalment::factory()->for($student)->create(['amount_paise' => 118000]);

        $this->actingAs($student->guardian->user)
            ->get("/students/{$student->id}/instalments")
            ->assertOk()
            ->assertSeeText('1,180.00');
    }

    public function test_the_list_answers_in_json(): void
    {
        $student = Student::factory()->withGuardian()->create();
        Instalment::factory()->for($student)->create(['due_on' => '2026-02-15']);
        Instalment::factory()->for($student)->paid()->create(['due_on' => '2026-01-15']);

        $this->actingAs($student->guardian->user)
            ->getJson("/students/{$student->id}/instalments")
            ->assertOk()
            ->assertJsonPath('meta.count', 2)
            ->assertJsonPath('meta.pending_paise', 118000)
            ->assertJsonPath('data.0.status', 'paid');
    }
}

fees-app/app/Http/Controllers/InstalmentController.php

<?php

namespace App\Http\Controllers;

use App\Models\Instalment;
use App\Models\Student;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

// A student's fee instalments: the list (a page, or JSON when the request asks for it), adding one, marking one
// paid and cancelling one that is still pending. Every action asks StudentPolicy first.
class InstalmentController extends Controller
{
    public function index(Request $request, Student $student)
    {
        Gate::authorize('viewFees', $student);

        $instalments = $student->instalments()->orderBy('due_on')->get();

        if ($request->wantsJson()) {
            return response()->json([
                'data' => $instalments->map(fn (Instalment $instalment) => [
                    'id' => $instalment->id,
                    'due_on' => $instalment->due_on->toDateString(),
                    'amount_paise' => $instalment->amount_paise,
                    'status' => $instalment->status,
                ]),
                'meta' => [
                    'count' => $instalments->count(),
                    'pending_paise' => $instalments->where('status', 'pending')->sum('amount_paise'),
                ],
            ]);
        }

        return view('instalments.index', ['student' => $student, 'instalments' => $instalments]);
    }

    public function store(Request $request, Student $student)
    {
        Gate::authorize('manageFees', $student);

        $data = $request->validate([
            'due_on' => ['required', 'date'],
            'amount_paise' => ['required', 'integer', 'min:100', 'max:10000000'],
        ]);

        $student->instalments()->create($data + ['status' => 'pending']);

        return redirect("/students/{$student->id}/instalments")->with('status', 'Instalment added.');
    }

    public function markPaid(Student $student, Instalment $instalment)
    {
        Gate::authorize('manageFees', $student);

        if ($instalment->status === 'paid') {
            return redirect("/students/{$student->id}/instalments")
                ->withErrors(['instalment' => 'This instalment is already paid.']);
        }

        $instalment->update(['status' => 'paid', 'paid_on' => now()->toDateString()]);

        return redirect("/students/{$student->id}/instalments")->with('status', 'Marked as paid.');
    }

    public function destroy(Student $student, Instalment $instalment)
    {
        Gate::authorize('manageFees', $student);

        if ($instalment->status === 'paid') {
            return redirect("/students/{$student->id}/instalments")
                ->withErrors(['instalment' => 'A paid instalment cannot be cancelled.']);
        }

        $instalment->delete();

        return redirect("/students/{$student->id}/instalments")->with('status', 'Instalment cancelled.');
    }
}

fees-app/app/Policies/StudentPolicy.php

<?php

namespace App\Policies;

use App\Models\Student;
use App\Models\User;

// Who may see and change a student's fee instalments. Laravel finds this policy for App\Models\Student by its name.
class StudentPolicy
{
    /** Staff of the student's centre, or the student's own guardian, may see the instalments. */
    public function viewFees(User $user, Student $student): bool
    {
        return $this->isStaffOf($user, $student) || $student->guardian?->user_id === $user->id;
    }

    /** Only staff of the student's centre may add, cancel or mark instalments paid. */
    public function manageFees(User $user, Student $student): bool
    {
        return $this->isStaffOf($user, $student);
    }

    private function isStaffOf(User $user, Student $student): bool
    {
        return $user->centre_id !== null && $user->centre_id === $student->batch->centre_id;
    }
}

routes/web.php · fees-app/routes/web.php

<?php

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

Route::get('/', function () {
    return view('welcome');
});

// The auth middleware sends a guest to the route named login; this project's sign-in page is a placeholder.
Route::get('/login', fn () => 'Sign in to Pathshala')->name('login');

// scopeBindings(): the {instalment} in a URL must belong to the {student} in the same URL, or the answer is 404.
Route::middleware('auth')->scopeBindings()->group(function () {
    Route::get('/students/{student}/instalments', [InstalmentController::class, 'index']);
    Route::post('/students/{student}/instalments', [InstalmentController::class, 'store']);
    Route::patch('/students/{student}/instalments/{instalment}/paid', [InstalmentController::class, 'markPaid']);
    Route::delete('/students/{student}/instalments/{instalment}', [InstalmentController::class, 'destroy']);
});

post() opens no connection: it hands a request to the HTTP kernel of the application the test booted, so routes, middleware, validation, the policy and the controller run in the test’s own PHP process, as for a browser. Step through that one test:

Interactive · message by message One HTTP test, from the request to the assertions Step through the test that adds an instalment and see what the application does with its request.
One HTTP test, from the request to the assertions, step 6 of 6. Messages so far: The test to The app: POST instalments; The app to SQLite: insert row; The app to The test: 302 and a flash.The testPHPUnitThe appbootedSQLitein memoryPOST instalmentsinsert row302 and a flash

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The test signs a user in. A factory made a staff member of the student's centre a line earlier. actingAs($staff) tells the auth guard who is signed in, so the test never fills in a login form or sends a password.
  2. post() sends a request. post() builds a POST request with the two form fields and hands it straight to the HTTP kernel of the booted application, as a browser's form submission would arrive. No server and no network take part.
  3. Middleware, then the policy. The auth middleware finds the signed-in user, the CSRF check stands aside because tests are running, and StudentPolicy lets staff of the student's own centre through.
  4. Validation, then a new row. The controller validates the due day and the amount, and 118000 paise clears the minimum of 100. It saves the instalment in the in-memory database.
  5. A redirect comes back. The answer is a 302 redirect to the list, with Instalment added. flashed into the session. The test does not follow it, so no page is rendered.
  6. Three assertions read it. assertRedirect reads the Location header, assertSessionHas the flashed message and assertDatabaseHas the saved row. The five tests of the file made 15 assertions in all.

Here is the run of all five:

Five HTTP tests of the instalments feature Python · http_tests_session.py
from session import run

# The five HTTP tests of the instalments feature (fees-app/tests/Feature/InstalmentFormTest.php) on the fees app:
# staff add an instalment, an amount under one rupee is refused, a guest is redirected, the guardian sees the page
# and the list answers in JSON. COLLISION_IGNORE_DURATION=true: no per-test times, so two runs print the same lines.
run(
    """
export COLLISION_IGNORE_DURATION=true
php artisan test --filter=InstalmentFormTest
""",
    app=True,
    overlay=("fees-app",),
)

Output

$ export COLLISION_IGNORE_DURATION=true
$ php artisan test --filter=InstalmentFormTest
   PASS  Tests\Feature\InstalmentFormTest
  ✓ staff add an instalment
  ✓ an amount under one rupee is refused
  ✓ a guest is sent to sign in
  ✓ the guardian sees the list
  ✓ the list answers in json

  Tests:    5 passed (15 assertions)
  Duration: …s

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 http_tests_session.py

The tests made 15 assertions about three things. The response: assertOk(), assertRedirect('/login') for the guest, whom the auth middleware sends to the route named login, and assertSeeText('1,180.00') for the amount the page prints. The session: assertSessionHas() for the flashed message and assertSessionHasErrors('amount_paise') for an amount under 100 paise. The database: assertDatabaseHas() and assertDatabaseCount(). The last test called getJson(), which asks for JSON in its Accept header, so the same route answered with data instead of a page.

Two requests in one test

Wrong: post the form, then get() the list in the same test to look for the new row. Laravel’s testing guide warns that several requests in one test can behave unexpectedly, since they share one application. Right: one request per test, and check the effect where it is stored, with assertDatabaseHas().

Checkpoint

Check: HTTP tests and what they assert

  1. Question 1 of 2 A test requests a student's instalments page without signing anyone in. The route group uses the auth middleware, and the project has a route named login. What does the response hold?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A redirect to /login

    An HTTP test meets the same middleware a browser does. A guest asking for a page is redirected to sign in, which assertRedirect('/login') checks; a guest asking for JSON would get 401 instead.

  2. Question 2 of 2 The same route answered one test with an HTML page and another with JSON data. The two tests differ only in calling get() or getJson(). What made the difference?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: getJson() asks for JSON in its Accept header, and the controller answers JSON when the request wants it

    One action can serve a page and data. The test chooses which by the request it builds: getJson() and postJson() send Accept application/json, and the controller's wantsJson() branch answers with data.

Database tests start every test from empty tables

A database test proves something only from known rows, and a factory writes them. The one call Student::factory()->withGuardian()->create() wrote five rows, because a student needs a batch, a batch needs a centre, and the guardian factory gives the parent a user account. The first database test counts those rows, and the second checks that none is left:

tests/Feature/FactoryRowsTest.php PHP · fees-app/tests/Feature/FactoryRowsTest.php
<?php

namespace Tests\Feature;

use App\Models\Student;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

// Two database tests: what one factory call writes, and proof that the next test starts with empty tables.
class FactoryRowsTest extends TestCase
{
    use RefreshDatabase;

    public function test_one_factory_call_writes_five_rows(): void
    {
        Student::factory()->withGuardian()->create();

        $this->assertDatabaseCount('centres', 1);
        $this->assertDatabaseCount('batches', 1);
        $this->assertDatabaseCount('students', 1);
        $this->assertDatabaseCount('guardians', 1);
        $this->assertDatabaseCount('users', 1);
    }

    public function test_the_next_test_starts_empty_again(): void
    {
        $this->assertDatabaseCount('students', 0);

        Student::factory()->create();

        $this->assertDatabaseCount('students', 1);
    }
}
Five rows from one call, none left for the next test Python · database_tests_session.py
from session import run

# Database tests: one factory call fills five tables, the next test starts empty again (RefreshDatabase), and the
# same kind of test without a reset trait finds no tables in the in-memory SQLite database. Masked: the random
# number in a factory's centre name ("Pathshala 863"), shown as "Pathshala …".
run(
    """
export COLLISION_IGNORE_DURATION=true
php artisan test --filter=FactoryRowsTest
! php artisan test --filter=NoResetTest
""",
    app=True,
    overlay=("fees-app", "no-reset"),
    masks=[(r"Pathshala \d+", "Pathshala …")],
)

Output

$ export COLLISION_IGNORE_DURATION=true
$ php artisan test --filter=FactoryRowsTest
   PASS  Tests\Feature\FactoryRowsTest
  ✓ one factory call writes five rows
  ✓ the next test starts empty again

  Tests:    2 passed (7 assertions)
  Duration: …s
$ php artisan test --filter=NoResetTest
   FAIL  Tests\Feature\NoResetTest
  ⨯ without a reset trait
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\NoResetTest > without a reset trait   QueryException
  SQLSTATE[HY000]: General error: 1 no such table: centres (Connection: sqlite, Database: :memory:, SQL: insert into "centres" ("name", "updated_at", "created_at") values (Pathshala …, …, …))

  at vendor/laravel/framework/src/Illuminate/Database/Connection.php:857
    853▕             $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e))
    854▕                 ? UniqueConstraintViolationException::class
    855▕                 : QueryException::class;
    856▕
  ➜ 857▕             $exception = new $exceptionType(
    858▕                 $this->getNameWithReadWriteType(),
    859▕                 $query,
    860▕                 $this->prepareBindings($bindings),
    861▕                 $e,

      +37 vendor frames
  38  tests/Feature/NoResetTest.php:13


  Tests:    1 failed (0 assertions)
  Duration: …s
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 database_tests_session.py

Both database tests passed. The same kind of test without a reset trait failed before its first assertion with no such table: centres. phpunit.xml points the tests at SQLite in memory, a database that lives only as long as its connection, and nothing had built its tables. RefreshDatabase: Laravel's test trait that builds the test database's tables once per run with migrate:fresh and runs each test inside a transaction that is rolled back when the test ends. builds the tables and clears the rows:

Interactive · stage by stage What RefreshDatabase does around each test Step through the first and the second test of one run and see which work happens once and which happens every time.
What RefreshDatabase does around each test, step 6 of 6. Showing A test starts, Schema built yet?, migrate:fresh runs, A transaction opens, The test runs (0 students at first), Rolled back. In focus: Schema built yet?, A transaction opens.already builtA test startsuses RefreshDatabaseSchema built yet?once per processmigrate:fresh runsevery migrationA transaction openson the connectionThe test runs0 students at firstRolled backits rows are gone

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first test starts. RefreshDatabase is a trait on the test class. Before each test it asks one question first, whether the tables already exist in this PHP process.
  2. Only the first test migrates. No tables exist yet, so the trait runs migrate:fresh, which builds every table from the migrations. With SQLite in memory it also keeps the open connection, because that database lives only as long as its connection.
  3. A transaction around the test. The trait opens a database transaction on the connection, and only then does the test body run.
  4. The test writes rows. One factory call wrote five rows, a centre, a batch, a student, a user and a guardian, and the test counted each table.
  5. Rolled back, not saved. When the test ends, the transaction is rolled back instead of committed, so the five rows disappear while the tables stay.
  6. The second test skips ahead. The schema is already built, so the second test goes straight to a new transaction. It found 0 students before writing its own, and its own row went the same way.

The first test of a run builds every table with migrate:fresh, and each test then runs inside a database transaction: A group of database writes that succeed or fail together: either all of them are committed, or a rollback undoes every one. Work outside the database is not undone. More in “Events, listeners and model observers” that is rolled back when it ends. Its rows vanish and the tables stay. That gives test isolation: The property that no test depends on data or state another test left behind, so each test passes alone, together with the others and in any order.: no test depends on what another left behind, so the tests pass alone, together and in any order. Laravel has three other reset traits. LazilyRefreshDatabase does the same, but only once a test first queries the database. DatabaseMigrations runs the migrations before every test and rolls them back after it. DatabaseTruncation: Laravel's test trait that migrates the test database once and then empties its tables between tests, instead of rolling back a transaction. migrates once, then empties the tables between tests.

Key idea

A database test needs known rows and nothing else: factories write exactly the rows it uses, and RefreshDatabase hands every test empty tables.

Checkpoint

Check: Factories and RefreshDatabase

  1. Question 1 of 2 In the fees app, a student belongs to a batch, a batch to a centre, and the guardian factory gives the parent a user account. How many rows does Student::factory()->withGuardian()->create() write?

    Type a number.

    A hint

    Count the student, every parent record the factories create for it, and the guardian with its account.

    Show the answer: Question 1 of 2

    Answer: 5 rows

    A centre, a batch, the student, a user and the guardian make five rows. The recorded test counted one row in each of the five tables.

  2. Question 2 of 2 A feature test with no reset trait creates a student and fails at once with no such table: centres. The tests use SQLite in memory, as phpunit.xml sets. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An in-memory database starts empty, and nothing ran the migrations for it

    With SQLite in memory, every connection starts with an empty database. RefreshDatabase builds the tables before the first test and keeps that connection, which is why tests with the trait find their tables.

JSON answers and stored rows, checked field by field Pro: opens with a Pro, Premium or Ultimate pass

A test that reads one JSON path stays green when an endpoint starts publishing a field nobody chose, and a row check can fail on a date that is right. This part makes JSON assertions strict, checks validation errors by their message, and compares stored values in the form the database holds them.

In this part:

  • A JSON assertion that fails when a new field appears
  • Validation errors as JSON, checked by their message
  • Rows that changed, rows that went and rows that must stay

2 runnable examples (InstalmentChangesTest.php, PHP, StoredDateTest.php, php artisan test) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Tests for the CSRF, refusal and token paths a run skips Pro: opens with a Pro, Premium or Ultimate pass

A green suite says nothing about requests it never makes. This part makes a test meet the CSRF check, writes a refusal test for every person the policy must turn away, and tests token abilities without issuing a token.

In this part:

  • A test that meets the CSRF check and gets its 419
  • One refusal test for each person the policy must turn away
  • Token abilities, tested without issuing a token

1 diagram · 3 runnable examples (ApiInstalmentTest.php, Python, php artisan test, routes/api.php, tests/Feature/CsrfTest.php, tests/Support/CsrfEvenInTests.php) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

What each database reset costs, and how many bugs the suite catches Pro: opens with a Pro, Premium or Ultimate pass

Two numbers decide whether a suite earns its run time: what it spends resetting the database, and how many mistakes it notices. This part measures both on the fees app, with a statement counter and ten deliberate bugs.

In this part:

  • Statements counted for the same four tests under each reset
  • Ten deliberate bugs, and the three the suite let through
  • An exercise with a rubric: twelve tests, graded by ten mutants
  • What to keep from the three parts

5 runnable examples (MutantKillersTest.php, PHP, Python, SQLite in memory, SqlCountServiceProvider.php, count.php, php mutate.php) · about 10 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise

Rebuild the five HTTP tests and the two database tests in your own Pathshala project. Then make each one fail once on purpose: change the flashed message, lower the minimum to 40 paise, and remove RefreshDatabase from the class. The self-check below begins with the idea you have answered least well so far:

Practice that adapts to you

Practise this lesson

6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 6 In a feature test, what does $this->post() do with the form fields it is given?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: It builds a request and hands it to the HTTP kernel of the booted app, in the same PHP process

    An HTTP test exercises the application as a browser would, from the kernel to the response, without leaving the PHP process that runs the test.

  2. Question 2 of 6 A form post of 50 paise is refused and redirected back with an error for amount_paise. Which assertion checks that refusal?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: assertSessionHasErrors('amount_paise')

    For a page request, validation errors go to the session and the response is a redirect back. The recorded test asserted the error and counted 0 instalments.

  3. Question 3 of 6 A new test signs in by posting an email and a password to the login route before calling the instalments page, and it fails in odd ways. What would you change?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Use actingAs($user) and send only the one request the test is about

    Laravel's testing guide advises one request per test. actingAs() supplies the signed-in user, so the test sends only the request it means to check.

  4. Question 4 of 6 How does RefreshDatabase remove the rows a test wrote?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: It runs each test inside a transaction and rolls it back when the test ends

    RefreshDatabase builds the schema with migrate:fresh once per run, then wraps each test in a transaction that is rolled back, which is why the second recorded test found 0 students.

  5. Question 5 of 6 Most tests of a suite never touch the database, and the team wants the schema built only for those that do. Which reset trait matches that wish?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: LazilyRefreshDatabase

    LazilyRefreshDatabase is RefreshDatabase that waits: a test that never queries skips the reset entirely.

  6. Question 6 of 6 A team points its tests at a MySQL test database and drops the reset trait to save time. A test that counts students passes alone and fails in the full run. What is the most likely cause?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: Rows written by earlier tests are still in the tables, so the count includes them

    Test isolation means no test depends on what another left behind. A reset trait gives that back, so the test passes alone, together and in any order.

Interview questions

Warm-up (fresher to 2–5 years): why does the second test of a class find the students table empty after the first test created a student?

The class uses RefreshDatabase. The first test of the run builds every table with migrate:fresh, and each test then runs inside a database transaction that is rolled back when the test ends, so the first test’s student is gone while the tables stay. The second test therefore starts from empty tables, and that is the point: tests that cannot see each other’s rows work alone, together and in any order. Without a reset trait the student would stay, and a count in a later test would depend on which tests ran before it.

Three harder questions build on the fees feature; sketch your first step for each, then compare:

  1. Mid to senior

    A code review finds that a Laravel endpoint listing each family's fee instalments has returned the guardian's phone number in every item for weeks, while all of its tests stayed green. Explain how the tests missed it and what you would change in the tests and in the code.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    After a redesign of the instalment form, staff get a 419 Page Expired page every time they save, yet every feature test of the form stays green. Explain why the tests could not catch it, and which test you would add so that it cannot happen again.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A Laravel project has 600 passing tests and 85 % line coverage, yet two bugs in fee calculations reached production within a few months. How would you measure how good the suite really is, and how would you turn the result into better tests?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Does $this->post() in a Laravel feature test open a network connection?

    No. It hands a request to the HTTP kernel of the application the test booted, so routes, middleware, validation, the policy and the controller all run in the test's own PHP process, as they would for a browser.

  2. Which three things did the fee tests' assertions check?

    The response (assertOk, assertRedirect('/login') for a guest, assertSeeText), the session (assertSessionHas for the flash, assertSessionHasErrors for a bad field) and the database (assertDatabaseHas, assertDatabaseCount). getJson() asks for JSON in its Accept header.

  3. Why not post a form and then get() the list in the same test?

    Several requests in one test share one application and can behave unexpectedly, as Laravel's testing guide warns. Send one request per test and check the effect where it is stored, with assertDatabaseHas().

  4. Why did Student::factory()->withGuardian()->create() write five rows?

    A student needs a batch, a batch needs a centre, and the guardian factory gives the parent a user account, so the factories wrote every row the student depends on.

  5. What does RefreshDatabase do, and why is it needed with SQLite in memory?

    Without a reset trait the test failed with no such table, because nothing had built the in-memory database. RefreshDatabase runs migrate:fresh before the first test, then wraps each test in a transaction rolled back at its end, so rows vanish and tables stay.

  6. Which other database reset traits does Laravel offer?

    LazilyRefreshDatabase, which resets only once a test first queries the database; DatabaseMigrations, which migrates before every test and rolls back after it; and DatabaseTruncation, which migrates once and empties the tables between tests.

Key takeaways

  • HTTP tests send one request through the routes, the middleware, the policy and the controller in the same process, and check the response, the session and the database.
  • actingAs() signs a user in for the request, and getJson() asks for JSON, so one route can be tested as a page and as data.
  • Database tests start from known rows: factories write exactly the rows a test needs, five from one call here.
  • RefreshDatabase builds the tables once per run and rolls every test back, so each test finds empty tables.

Terms in this lesson

AssertableJson
The object Laravel passes to a closure given to assertJson(), whose methods such as has(), where() and missing() check a JSON response key by key.
DatabaseTruncation
Laravel's test trait that migrates the test database once and then empties its tables between tests, instead of rolling back a transaction.
HTTP test
A feature test that sends a request through the whole application, its routes, middleware and controller, without a server, and then checks the response, the session or the database.
mutation testing
A way to measure a test suite: small deliberate bugs, called mutants, are put into the code one at a time, and a mutant counts as caught when at least one test fails.
negative test
A test that acts as someone who must be refused, or sends input that must be rejected, and checks both the refusal and that nothing was changed.
RefreshDatabase
Laravel's test trait that builds the test database's tables once per run with migrate:fresh and runs each test inside a transaction that is rolled back when the test ends.
test isolation
The property that no test depends on data or state another test left behind, so each test passes alone, together with the others and in any order.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.