Testing Strategy — What to Test and How
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\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.
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.
Further Reading
- Laravel Docs: Testing
- Laravel Docs: HTTP Tests
- Laravel Docs: Database Testing
- PHPUnit Docs
- Laravel Docs: Mocking