Testing Strategy — What to Test and How

Testing Strategy — What to Test and How

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 32 of 35 Level: Intermediate Prerequisites: Action ClassesService 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 RefreshDatabase in 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 --parallel in CI, --filter locally for fast feedback.

Tips and Gotchas

⚠️ Warning: RefreshDatabase drops and recreates the database schema on every test run. For large test suites, this is slow. Use LazilyRefreshDatabase instead — 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


← Git Hooks | Next: API Resources →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.