Laravel Testing Strategy: Feature vs Unit Tests
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
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' => '[email protected]',
'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.
Further Reading
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.