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.
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.
<?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 currencyRun this on your computer — see “Get Started” for setup.
Test yourself
What does the RefreshDatabase trait do in tests?
Speeds up queriesGives each test a clean database stateSeeds production dataDisables migrations
Show answer
Answer: Gives each test a clean database state