Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage. Use when writing Laravel tests with PHPUnit or Pest, or driving a Laravel feature test-first.
// Step 1: RED — Write a failing test
public function test_a_product_can_be_created(): void
{
$product = Product::factory()->create(['name' => 'Test Product']);
$this->assertDatabaseHas('products', ['name' => 'Test Product']);
}
// Step 2: GREEN — Write the migration, model, and factory
// Step 3: REFACTOR — Improve while keeping tests green
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// Call $this->withoutExceptionHandling() only in tests that
// test non-HTTP exceptions; it suppresses assertStatus() etc.
}
// Helper: Authenticate and return user
protected function actingAsUser(): mixed
{
$user = \App\Models\User::factory()->create();
$this->actingAs($user);
return $user;
}
protected function actingAsAdmin(): mixed
{
$admin = \App\Models\User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
}
namespace Tests\Unit\Models;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase;
public function test_it_hides_sensitive_attributes(): void
{
$user = User::factory()->create();
$this->assertArrayNotHasKey('password', $user->toArray());
}
public function test_admin_scope_returns_only_admins(): void
{
User::factory()->admin()->create();
User::factory()->count(3)->create();
$this->assertCount(1, User::admin()->get());
}
}
class ProductTest extends TestCase
{
use RefreshDatabase;
public function test_active_scope_filters_correctly(): void
{
Product::factory()->count(3)->create(['is_active' => true]);
Product::factory()->count(2)->create(['is_active' => false]);
$this->assertCount(3, Product::active()->get());
}
public function test_it_belongs_to_a_user(): void
{
$user = User::factory()->create();
$product = Product::factory()->create(['user_id' => $user->id]);
$this->assertTrue($product->user->is($user));
}
}
Feature / HTTP Testing
namespace Tests\Feature\Http\Controllers;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductControllerTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_login(): void
{
$this->get(route('products.create'))->assertRedirect(route('login'));
}
public function test_it_stores_a_new_product(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->post(route('products.store'), [
'name' => 'New Product',
'description' => 'Description',
'price' => 2999,
'stock' => 10,
]);
$response->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', [
'name' => 'New Product',
'user_id' => $user->id,
]);
}
public function test_it_validates_required_fields(): void
{
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), [])
->assertSessionHasErrors(['name', 'price']);
}
public function test_users_cannot_modify_others_products(): void
{
$owner = User::factory()->create();
$attacker = User::factory()->create();
$product = Product::factory()->create(['user_id' => $owner->id]);
$this->actingAs($attacker)
->delete(route('products.destroy', $product))
->assertForbidden();
}
}
JSON API Testing
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductApiTest extends TestCase
{
use RefreshDatabase;
public function test_unauthenticated_requests_are_rejected(): void
{
$this->getJson('/api/products')->assertUnauthorized();
}
public function test_it_lists_paginated_products(): void
{
$user = User::factory()->create();
Product::factory()->count(5)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->getJson('/api/products');
$response->assertOk();
$response->assertJsonCount(5, 'data');
$response->assertJsonStructure([
'data' => [['id', 'name', 'price']],
'meta' => ['current_page', 'last_page', 'total'],
]);
}
public function test_it_creates_a_product(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/products', [
'name' => 'API Product',
'price' => 4999,
]);
$response->assertCreated();
$response->assertJsonPath('data.name', 'API Product');
}
public function test_users_cannot_delete_others_products(): void
{
$owner = User::factory()->create();
$attacker = User::factory()->create();
$product = Product::factory()->create(['user_id' => $owner->id]);
$this->actingAs($attacker)
->deleteJson("/api/products/{$product->id}")
->assertForbidden();
}
}
Sanctum API Auth Testing
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
public function test_users_can_register(): void
{
$response = $this->postJson('/api/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'Password123!',
'password_confirmation' => 'Password123!',
]);
$response->assertCreated();
$response->assertJsonStructure(['data' => ['user', 'token']]);
}
public function test_users_can_login(): void
{
User::factory()->create([
'email' => 'test@example.com',
'password' => Hash::make('Password123!'),
]);
$response = $this->postJson('/api/login', [
'email' => 'test@example.com',
'password' => 'Password123!',
]);
$response->assertOk();
$response->assertJsonStructure(['data' => ['token']]);
}
public function test_users_cannot_login_with_wrong_password(): void
{
User::factory()->create(['email' => 'test@example.com']);
$this->postJson('/api/login', [
'email' => 'test@example.com',
'password' => 'wrong',
])->assertUnprocessable();
}
public function test_token_bearer_authenticates_requests(): void
{
$user = User::factory()->create();
$token = $user->createToken('test')->plainTextToken;
$this->withToken($token)
->getJson('/api/user')
->assertOk()
->assertJsonPath('data.email', $user->email);
}
}
Mocking and Fakes
HTTP Fake
use Illuminate\Support\Facades\Http;
public function test_it_handles_successful_payment(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
]);
$result = (new PaymentService())->charge(2999);
$this->assertTrue($result->success);
}
public function test_it_handles_gateway_failure(): void
{
Http::fake([
'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),
]);
$this->expectException(PaymentFailedException::class);
(new PaymentService())->charge(2999);
}
public function test_it_retries_on_timeout(): void
{
Http::fake([
'api.stripe.com/*' => Http::sequence()
->pushStatus(408)
->pushStatus(200),
]);
$this->assertTrue((new PaymentService())->charge(2999)->success);
}
Mail Fake
Mail::fake();
$order->sendConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
return $mail->hasTo($order->user->email);
});