Laravel Tutorial · Beyond the Basics · Lesson 26 of 27

Testing Laravel Apps

Write feature tests that send requests and assert on responses and the database, use RefreshDatabase and factories, and fake mail, queues and HTTP calls.

Create a free account to save this page, take notes and track your progress. Sign in

Feature tests

Laravel ships with PHPUnit and Pest support. Feature tests call your routes and check the result:

use Illuminate\Foundation\Testing\RefreshDatabase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;                         // a clean database for every test

    public function test_users_can_create_posts(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)
            ->postJson('/api/posts', ['title' => 'Hello'])
            ->assertCreated()
            ->assertJsonPath('data.title', 'Hello');

        $this->assertDatabaseHas('posts', ['title' => 'Hello', 'user_id' => $user->id]);
    }

    public function test_title_is_required(): void
    {
        $this->actingAs(User::factory()->create())
            ->postJson('/api/posts', [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('title');
    }
}

Run them with php artisan test (add --parallel for speed). Fakes keep tests fast and offline: Mail::fake(), Queue::fake(), Event::fake(), Notification::fake(), Storage::fake(), and Http::fake() for outgoing API calls.

The code

The runnable example uses the same request-and-assert approach (this tutorial's visit() helper does what postJson does in a test), including faking an outgoing HTTP API.

Request tests with a faked external API
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;

Route::post('/convert', function (Request $request) {
    $data = $request->validate(['amount' => 'required|numeric|min:0', 'to' => 'required|in:EUR,GBP']);
    $rate = Http::get('https://rates.example.com/latest', ['to' => $data['to']])->json('rate');
    return ['amount' => round($data['amount'] * $rate, 2), 'currency' => $data['to']];
});

Http::fake(['rates.example.com/*' => Http::response(['rate' => 0.92])]);   // no real network call

$check = function (string $name, bool $ok) {
    echo ($ok ? 'PASS' : 'FAIL'), " $name\n";
};

$response = visit('POST', '/convert', ['amount' => 100, 'to' => 'EUR']);
$check('converts with the fetched rate', $response->getStatusCode() === 200 && json_decode($response->getContent())->amount == 92);

$response = visit('POST', '/convert', ['amount' => -5, 'to' => 'JPY']);
$errors = array_keys(json_decode($response->getContent(), true)['errors'] ?? []);
$check('rejects invalid input with 422', $response->getStatusCode() === 422 && $errors === ['amount', 'to']);

Http::assertSent(fn ($request) => str_contains($request->url(), 'to=EUR'));
$check('called the rates API with the right currency', true);

Output

POST /convert -> 200 {"amount":92,"currency":"EUR"}
PASS converts with the fetched rate
POST /convert -> 422 {"message":"The amount field must be at least 0. (and 1 more error)","errors":{"amount":["The amount field must be at least 0."],"to":["The selected to is invalid."]}}
PASS rejects invalid input with 422
PASS called the rates API with the right currency

Run this on your computer — see “Get Started” for setup.

Test yourself

What does the RefreshDatabase trait do in tests?

  1. Speeds up queries
  2. Gives each test a clean database state
  3. Seeds production data
  4. Disables migrations
Show answer

Answer: Gives each test a clean database state

Was this page helpful?