Series: Every Laravel Project Should Have These Building Blocks
Part: 32 of 35 Level: Intermediate Prerequisites: Action Classes, Service Classes
What You’ll Learn
- The three types of tests and when to use each
- Feature tests for HTTP flows and database interactions
- Unit tests for pure business logic
- Using Fakes to isolate tests from external services
- Factory-first test data
- Testing Policies and Actions
- Running a meaningful test suite efficiently
The Three Test Types
Laravel supports three kinds of tests, each with a specific scope:
Feature Tests: Test the full HTTP request/response cycle. They make real database calls, go through middleware, run validation, hit controllers. Use them for any code that touches HTTP.
Unit Tests: Test a single class in isolation, with no database or HTTP. Fast. Use them for Services, Actions, and pure business logic.
Policy Tests: Test authorization rules. A subset of Feature or Unit tests, but worth calling out: every Policy deserves its own test.
The ratio across these projects is roughly 80% Feature tests, 20% Unit tests. HTTP flows are where bugs live; pure logic is easier to verify quickly.
Feature Test Structure
// tests/Feature/Orders/CreateOrderTest.php <?php declare(strict_types=1); namespace Tests\Feature\Orders; use App\Events\OrderPlaced; use App\Mail\OrderConfirmationMail; use App\Models\Order; use App\Models\Product; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; use Tests\TestCase; final class CreateOrderTest extends TestCase { use RefreshDatabase; public function test_authenticated_user_can_place_an_order(): void { // Arrange Event::fake(); Mail::fake(); $user = User::factory()->create(); $product = Product::factory()->inStock()->create(); // Act $response = $this->actingAs($user) ->postJson(route('api.v1.orders.store'), [ 'product_id' => $product->id, 'quantity' => 2, ]); // Assert — HTTP response $response->assertStatus(201) ->assertJsonStructure([ 'success', 'data' => ['id', 'total', 'status'], ]); // Assert — Database state $this->assertDatabaseHas('orders', [ 'user_id' => $user->id, 'status' => 'pending', ]); // Assert — Side effects Event::assertDispatched(OrderPlaced::class); Mail::assertQueued(OrderConfirmationMail::class); } public function test_guest_cannot_place_an_order(): void { $response = $this->postJson(route('api.v1.orders.store'), [ 'product_id' => 1, 'quantity' => 1, ]); $response->assertUnauthorized(); $this->assertDatabaseCount('orders', 0); } public function test_order_fails_validation_with_out_of_stock_product(): void { $user = User::factory()->create(); $product = Product::factory()->outOfStock()->create(); $response = $this->actingAs($user) ->postJson(route('api.v1.orders.store'), [ 'product_id' => $product->id, 'quantity' => 1, ]); $response->assertUnprocessable() ->assertJsonValidationErrors(['product_id']); } } The AAA pattern – Arrange, Act, Assert – applies to every test. Each test has exactly one Act (the HTTP call or method call). Everything before is setup. Everything after is assertions.
Unit Test Structure
// tests/Unit/Services/OrderServiceTest.php <?php declare(strict_types=1); namespace Tests\Unit\Services; use App\DTOs\OrderCustomerData; use App\Enums\PaymentGateway; use App\Services\Order\OrderService; use App\Services\Stripe\StripePaymentServiceInterface; use Mockery; use Mockery\MockInterface; use PHPUnit\Framework\TestCase; final class OrderServiceTest extends TestCase { private MockInterface $stripe; private OrderService $service; protected function setUp(): void { parent::setUp(); $this->stripe = Mockery::mock(StripePaymentServiceInterface::class); $this->service = new OrderService($this->stripe); } protected function tearDown(): void { Mockery::close(); parent::tearDown(); } public function test_it_calculates_total_with_tax_correctly(): void { // Arrange $items = collect([ ['price' => 100_00, 'quantity' => 2], // $200.00 ['price' => 50_00, 'quantity' => 1], // $50.00 ]); // Act $total = $this->service->calculateTotal($items, taxRate: 0.10); // Assert $this->assertEquals(275_00, $total); // $250 + 10% = $275 } public function test_it_creates_payment_intent_with_correct_amount(): void { // Arrange $this->stripe ->shouldReceive('createPaymentIntent') ->once() ->with(275_00, 'usd') ->andReturn((object) ['id' => 'pi_test_123', 'client_secret' => 'secret']); // Act $intent = $this->service->initiatePayment(275_00); // Assert $this->assertEquals('pi_test_123', $intent->id); } } Unit tests extend PHPUnit\Framework\TestCase (not Laravel’s TestCase) – no framework boot, no database, no HTTP. They’re fast: hundreds per second.
Fakes — Replacing External Services
Never let tests hit real external services. Laravel provides Fakes for the most common ones:
// Email Mail::fake(); Mail::assertSent(OrderConfirmationMail::class); Mail::assertQueued(OrderConfirmationMail::class); Mail::assertNotSent(OrderConfirmationMail::class); // Events Event::fake(); Event::assertDispatched(OrderPlaced::class); Event::assertDispatched(OrderPlaced::class, function ($event): bool { return $event->order->id === $this->order->id; }); Event::assertNotDispatched(OrderCancelled::class); // Jobs Bus::fake(); Bus::assertDispatched(ProcessOrderInventory::class); Bus::assertDispatchedTimes(SendNotification::class, 3); // HTTP (for external API calls) Http::fake([ 'api.stripe.com/*' => Http::response(['id' => 'pi_test_123'], 200), 'api.name.com/*' => Http::response(['available' => true], 200), ]); // Notifications Notification::fake(); Notification::assertSentTo($user, ServiceRequestRejectedNotification::class); Notification::assertNothingSentTo($user); Factories — The Only Way to Create Test Data
Every test that touches the database should use factories. No manual DB::insert(), no seeder calls in tests:
// ✅ Using factories $user = User::factory()->create(); $admin = User::factory()->admin()->create(); $order = Order::factory()->for($user)->withItems(3)->create(); // ❌ Manual inserts (brittle, doesn't respect model logic) DB::table('users')->insert([...]); Define states for common test scenarios:
// database/factories/ProductFactory.php public function definition(): array { return [ 'name' => fake()->words(3, true), 'price' => fake()->numberBetween(1000, 50000), // in cents 'stock' => fake()->numberBetween(10, 100), 'is_active' => true, ]; } public function outOfStock(): static { return $this->state(['stock' => 0]); } public function inStock(): static { return $this->state(['stock' => fake()->numberBetween(5, 100)]); } public function featured(): static { return $this->state(['is_featured' => true]); } In tests: Product::factory()->outOfStock()->create() is readable and expressive.
Testing Actions
Actions are easy to test because they have a single execute() or handle() method:
final class CreateOrderActionTest extends TestCase { use RefreshDatabase; public function test_it_creates_an_order_with_items(): void { // Arrange Event::fake(); $user = User::factory()->create(); $items = CartItem::factory()->count(3)->for($user)->create(); $data = OrderCustomerData::fromArray([ 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'payment_gateway' => 'stripe', 'payment_reference_id' => 'pi_test_123', ]); // Act $order = (new CreateOrderAction)->execute($user, $data, collect($items)); // Assert $this->assertInstanceOf(Order::class, $order); $this->assertEquals($user->id, $order->user_id); $this->assertDatabaseCount('order_items', 3); Event::assertDispatched(OrderPlaced::class); } public function test_it_rolls_back_on_payment_failure(): void { // ... } } Testing Policies
final class OrderPolicyTest extends TestCase { use RefreshDatabase; public function test_user_can_view_their_own_order(): void { $user = User::factory()->create(); $order = Order::factory()->for($user)->create(); $this->assertTrue($user->can('view', $order)); } public function test_user_cannot_view_another_users_order(): void { $user = User::factory()->create(); $other = User::factory()->create(); $order = Order::factory()->for($other)->create(); $this->assertFalse($user->can('view', $order)); } public function test_admin_can_view_any_order(): void { $admin = Admin::factory()->create(); $order = Order::factory()->create(); $this->assertTrue($admin->can('viewAny', Order::class)); } } Running Tests Efficiently
# All tests php artisan test --compact # All tests in parallel (much faster on multi-core machines) php artisan test --compact --parallel # Specific file php artisan test --compact tests/Feature/Orders/CreateOrderTest.php # Filter by test name php artisan test --compact --filter=test_authenticated_user_can_place_an_order # Specific test class php artisan test --compact --filter=CreateOrderTest In CI, always run --parallel. Locally, use --filter to run only what’s relevant to your current change.
FAQs
Should I write feature or unit tests in Laravel?
Mostly feature tests – they cover routing, middleware, validation, and DB in one pass. Unit-test pure logic (Actions, Services, DTOs) where isolation matters.
What should I NOT test?
The framework. Don’t test that Eloquent saves or that validation rules Laravel ships actually work – test YOUR rules, YOUR logic, YOUR edge cases.
Key Takeaways
- Feature tests for HTTP flows (80% of tests). Unit tests for pure logic (20%).
- Always use
RefreshDatabasein Feature tests that write to the database. - Fake external services (
Mail::fake(),Event::fake(),Bus::fake(),Http::fake()) – never let tests hit real services. - Factory states (
outOfStock(),admin(),withItems()) make tests readable and expressive. - Test every Policy explicitly – authorization logic is security-critical and deserves its own test class.
- Use
--parallelin CI,--filterlocally for fast feedback.
Tips and Gotchas
⚠️ Warning:
RefreshDatabasedrops and recreates the database schema on every test run. For large test suites, this is slow. UseLazilyRefreshDatabaseinstead — it only refreshes the database if a test actually touches it, which cuts migration time significantly.💡 Tip: Name test methods as sentences:
test_authenticated_user_can_place_an_order. When a test fails in CI, the test name in the output tells you exactly what broke without opening the file.testOrder()tells you nothing.🔥 Expert Note: The ratio that matters is not “how many tests” but “how much behavior is covered.” Ten well-written Feature tests covering the critical user paths are worth more than 100 unit tests on internal helpers. Test what users do, not how code works.