Laravel Feature and Unit Tests: A Practical Guide with PHPUnit

Published on December 03, 2025
Laravel PHPUnit Testing TDD FeatureTests UnitTests

Introduction to Laravel Testing with PHPUnit

PHPUnit is the industry-standard testing framework for PHP, and Laravel provides first-class support for it. Understanding how to effectively write both unit and feature tests is crucial for building robust, maintainable applications.

Why PHPUnit with Laravel?

  • Industry standard with extensive documentation
  • Tight integration with Laravel
  • Rich assertion library
  • Data providers for parameterized testing
  • Mocking and test doubles support
  • Code coverage reporting

Setting Up PHPUnit in Laravel

Configuration Files

<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml -->

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache">
    
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory suffix="Test.php">./tests/Integration</directory>
        </testsuite>
    </testsuites>
    
    <source>
        <include>
            <directory suffix=".php">./app</directory>
            <directory suffix=".php">./src</directory>
        </include>
        <exclude>
            <directory>./app/Console</directory>
            <directory>./app/Exceptions</directory>
            <directory>./app/Http/Middleware</directory>
        </exclude>
    </source>
    
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="LOG_CHANNEL" value="null"/>
        <env name="FILESYSTEM_DISK" value="local"/>
        <ini name="memory_limit" value="512M"/>
        <ini name="error_reporting" value="-1"/>
    </php>
    
    <coverage>
        <report>
            <html outputDirectory="coverage/html" lowUpperBound="50" highLowerBound="90"/>
            <clover outputFile="coverage/clover.xml"/>
            <text outputFile="coverage/coverage.txt" showOnlySummary="true"/>
        </report>
    </coverage>
</phpunit>

Comprehensive Assertion Library

Basic Assertions

<?php
// tests/Unit/AssertionExamplesTest.php

namespace Tests\Unit;

use Tests\TestCase;

class AssertionExamplesTest extends TestCase
{
    public function test_basic_assertions()
    {
        // Equality assertions
        $this->assertEquals('expected', 'expected');
        $this->assertSame(123, 123); // Strict comparison
        $this->assertNotEquals('foo', 'bar');
        
        // Boolean assertions
        $this->assertTrue(true);
        $this->assertFalse(false);
        $this->assertNull(null);
        $this->assertNotNull('value');
        
        // Type assertions
        $this->assertIsArray([]);
        $this->assertIsBool(true);
        $this->assertIsFloat(3.14);
        $this->assertIsInt(42);
        $this->assertIsNumeric('123');
        $this->assertIsObject(new \stdClass());
        $this->assertIsString('string');
        
        // Comparison assertions
        $this->assertGreaterThan(1, 2);
        $this->assertGreaterThanOrEqual(2, 2);
        $this->assertLessThan(2, 1);
        $this->assertLessThanOrEqual(1, 1);
        
        // String assertions
        $this->assertStringContainsString('foo', 'foobar');
        $this->assertStringStartsWith('foo', 'foobar');
        $this->assertStringEndsWith('bar', 'foobar');
        $this->assertMatchesRegularExpression('/foo/', 'foobar');
        
        // Array assertions
        $array = ['foo' => 'bar'];
        $this->assertArrayHasKey('foo', $array);
        $this->assertArrayNotHasKey('baz', $array);
        $this->assertContains('bar', $array);
        $this->assertNotContains('baz', $array);
        $this->assertCount(1, $array);
    }
    
    public function test_laravel_specific_assertions()
    {
        // HTTP assertions (for feature tests)
        $response = response()->json(['data' => 'value']);
        
        // Status assertions
        $this->assertEquals(200, $response->status());
        
        // JSON assertions
        $this->assertJson(['data' => 'value'], $response->content());
        
        // Database assertions
        $user = \App\Models\User::factory()->create();
        $this->assertDatabaseHas('users', ['id' => $user->id]);
        $this->assertDatabaseMissing('users', ['email' => 'nonexistent@example.com']);
        $this->assertDatabaseCount('users', 1);
        
        // Authentication assertions
        $this->actingAs($user);
        $this->assertAuthenticated();
        $this->assertAuthenticatedAs($user);
    }
}

Unit Testing in Depth

Testing Services

<?php
// app/Services/OrderService.php

namespace App\Services;

use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class OrderService
{
    public function createOrder(User $user, array $items, array $shippingInfo): Order
    {
        return DB::transaction(function () use ($user, $items, $shippingInfo) {
            // Validate items
            $this->validateItems($items);
            
            // Calculate totals
            $subtotal = $this->calculateSubtotal($items);
            $shipping = $this->calculateShipping($subtotal, $shippingInfo);
            $tax = $this->calculateTax($subtotal, $shippingInfo);
            $total = $subtotal + $shipping + $tax;
            
            // Check stock availability
            $this->checkStockAvailability($items);
            
            // Create order
            $order = Order::create([
                'user_id' => $user->id,
                'order_number' => $this->generateOrderNumber(),
                'subtotal' => $subtotal,
                'shipping' => $shipping,
                'tax' => $tax,
                'total' => $total,
                'status' => 'pending',
                'shipping_address' => $shippingInfo,
            ]);
            
            // Add order items
            foreach ($items as $item) {
                $order->items()->create([
                    'product_id' => $item['product_id'],
                    'quantity' => $item['quantity'],
                    'unit_price' => $item['unit_price'],
                    'total_price' => $item['quantity'] * $item['unit_price'],
                ]);
                
                // Update stock
                Product::where('id', $item['product_id'])
                      ->decrement('stock_quantity', $item['quantity']);
            }
            
            // Log order creation
            Log::info('Order created', ['order_id' => $order->id, 'user_id' => $user->id]);
            
            return $order->load('items');
        });
    }
    
    protected function validateItems(array $items): void
    {
        if (empty($items)) {
            throw new \InvalidArgumentException('Order must contain at least one item');
        }
        
        foreach ($items as $item) {
            if (!isset($item['product_id'], $item['quantity'], $item['unit_price'])) {
                throw new \InvalidArgumentException('Each item must have product_id, quantity, and unit_price');
            }
            
            if ($item['quantity'] <= 0) {
                throw new \InvalidArgumentException('Quantity must be greater than 0');
            }
            
            if ($item['unit_price'] < 0) {
                throw new \InvalidArgumentException('Unit price cannot be negative');
            }
        }
    }
    
    protected function calculateSubtotal(array $items): float
    {
        return array_reduce($items, function ($carry, $item) {
            return $carry + ($item['quantity'] * $item['unit_price']);
        }, 0);
    }
    
    protected function calculateShipping(float $subtotal, array $shippingInfo): float
    {
        // Simplified shipping calculation
        if ($subtotal > 100) {
            return 0; // Free shipping for orders over $100
        }
        
        return 10.00; // Flat rate shipping
    }
    
    protected function calculateTax(float $subtotal, array $shippingInfo): float
    {
        // Simplified tax calculation (8% tax rate)
        return $subtotal * 0.08;
    }
}
<?php
// tests/Unit/Services/OrderServiceTest.php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\OrderService;
use App\Models\User;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;

class OrderServiceTest extends TestCase
{
    use RefreshDatabase;
    
    private OrderService $orderService;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->orderService = new OrderService();
    }
    
    public function test_create_order_successfully()
    {
        // Arrange
        $user = User::factory()->create();
        $product = Product::factory()->create([
            'price' => 50.00,
            'stock_quantity' => 10,
        ]);
        
        $items = [
            [
                'product_id' => $product->id,
                'quantity' => 2,
                'unit_price' => 50.00,
            ],
        ];
        
        $shippingInfo = [
            'address' => '123 Main St',
            'city' => 'New York',
            'country' => 'USA',
        ];
        
        // Act
        $order = $this->orderService->createOrder($user, $items, $shippingInfo);
        
        // Assert
        $this->assertInstanceOf(Order::class, $order);
        $this->assertEquals($user->id, $order->user_id);
        $this->assertEquals('pending', $order->status);
        $this->assertEquals(100.00, $order->subtotal); // 2 * 50
        $this->assertEquals(10.00, $order->shipping); // Flat rate
        $this->assertEquals(8.00, $order->tax); // 8% of 100
        $this->assertEquals(118.00, $order->total); // 100 + 10 + 8
        
        // Check order items
        $this->assertCount(1, $order->items);
        $this->assertEquals($product->id, $order->items[0]->product_id);
        $this->assertEquals(2, $order->items[0]->quantity);
        $this->assertEquals(100.00, $order->items[0]->total_price);
        
        // Check stock was updated
        $this->assertEquals(8, $product->fresh()->stock_quantity); // 10 - 2
        
        // Check database
        $this->assertDatabaseHas('orders', [
            'id' => $order->id,
            'user_id' => $user->id,
            'total' => 118.00,
        ]);
    }
    
    public function test_create_order_with_empty_items_throws_exception()
    {
        $user = User::factory()->create();
        
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Order must contain at least one item');
        
        $this->orderService->createOrder($user, [], []);
    }
    
    public function test_create_order_with_invalid_item_structure_throws_exception()
    {
        $user = User::factory()->create();
        
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Each item must have product_id, quantity, and unit_price');
        
        $this->orderService->createOrder($user, [
            ['product_id' => 1] // Missing quantity and unit_price
        ], []);
    }
    
    public function test_create_order_with_zero_quantity_throws_exception()
    {
        $user = User::factory()->create();
        $product = Product::factory()->create();
        
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Quantity must be greater than 0');
        
        $this->orderService->createOrder($user, [
            ['product_id' => $product->id, 'quantity' => 0, 'unit_price' => 10.00]
        ], []);
    }
    
    /**
     * @dataProvider shippingCalculationProvider
     */
    public function test_shipping_calculation($subtotal, $expectedShipping)
    {
        $method = new \ReflectionMethod($this->orderService, 'calculateShipping');
        $method->setAccessible(true);
        
        $result = $method->invoke($this->orderService, $subtotal, []);
        
        $this->assertEquals($expectedShipping, $result);
    }
    
    public function shippingCalculationProvider(): array
    {
        return [
            'free shipping for large order' => [150.00, 0.00],
            'flat rate for small order' => [50.00, 10.00],
            'exactly 100 should get flat rate' => [100.00, 10.00],
        ];
    }
}

Feature Testing in Depth

Testing API Endpoints

<?php
// tests/Feature/Api/ProductApiTest.php

namespace Tests\Feature\Api;

use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_get_products_list()
    {
        Product::factory()->count(15)->create();
        
        $response = $this->getJson('/api/products');
        
        $response->assertStatus(200);
        $response->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'price', 'slug', 'description']
            ],
            'links',
            'meta',
        ]);
        $response->assertJsonCount(15, 'data');
    }
    
    public function test_get_single_product()
    {
        $product = Product::factory()->create();
        
        $response = $this->getJson("/api/products/{$product->id}");
        
        $response->assertStatus(200);
        $response->assertJson([
            'data' => [
                'id' => $product->id,
                'name' => $product->name,
                'price' => $product->price,
                'description' => $product->description,
            ]
        ]);
    }
    
    public function test_get_nonexistent_product_returns_404()
    {
        $response = $this->getJson('/api/products/999');
        
        $response->assertStatus(404);
        $response->assertJson(['message' => 'Product not found']);
    }
    
    public function test_create_product_as_admin()
    {
        $admin = User::factory()->create(['role' => 'admin']);
        $category = Category::factory()->create();
        
        Storage::fake('public');
        $image = UploadedFile::fake()->image('product.jpg');
        
        $productData = [
            'name' => 'New Product',
            'description' => 'Product description',
            'price' => 99.99,
            'stock_quantity' => 10,
            'category_id' => $category->id,
            'images' => [$image],
        ];
        
        $response = $this->actingAs($admin)
            ->postJson('/api/products', $productData);
        
        $response->assertStatus(201);
        $response->assertJson([
            'message' => 'Product created successfully',
            'data' => [
                'name' => 'New Product',
                'price' => 99.99,
            ]
        ]);
        
        $this->assertDatabaseHas('products', [
            'name' => 'New Product',
            'price' => 99.99,
        ]);
        
        // Check image was stored
        Storage::disk('public')->assertExists('products/' . $image->hashName());
    }
    
    public function test_create_product_requires_authentication()
    {
        $response = $this->postJson('/api/products', []);
        
        $response->assertStatus(401);
    }
    
    public function test_create_product_requires_admin_role()
    {
        $user = User::factory()->create(['role' => 'customer']);
        
        $response = $this->actingAs($user)
            ->postJson('/api/products', []);
        
        $response->assertStatus(403);
    }
    
    public function test_create_product_validates_required_fields()
    {
        $admin = User::factory()->create(['role' => 'admin']);
        
        $response = $this->actingAs($admin)
            ->postJson('/api/products', []);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors([
            'name', 'price', 'category_id'
        ]);
    }
}

Advanced Testing Techniques

Testing with Mocks and Dependency Injection

<?php
// app/Services/PaymentProcessor.php

namespace App\Services;

interface PaymentGateway
{
    public function charge(float $amount, array $details): array;
    public function refund(string $transactionId, float $amount = null): array;
}

class PaymentProcessor
{
    private PaymentGateway $gateway;
    
    public function __construct(PaymentGateway $gateway)
    {
        $this->gateway = $gateway;
    }
    
    public function processPayment(float $amount, array $paymentDetails): array
    {
        try {
            $result = $this->gateway->charge($amount, $paymentDetails);
            
            if (!$result['success']) {
                throw new \Exception('Payment failed: ' . ($result['error'] ?? 'Unknown error'));
            }
            
            return [
                'success' => true,
                'transaction_id' => $result['transaction_id'],
                'amount' => $amount,
                'processed_at' => now(),
            ];
            
        } catch (\Exception $e) {
            \Log::error('Payment processing failed', [
                'amount' => $amount,
                'error' => $e->getMessage(),
            ]);
            
            return [
                'success' => false,
                'error' => $e->getMessage(),
            ];
        }
    }
}
<?php
// tests/Unit/Services/PaymentProcessorTest.php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\PaymentProcessor;
use App\Services\PaymentGateway;
use Mockery;
use Mockery\MockInterface;

class PaymentProcessorTest extends TestCase
{
    private PaymentGateway|MockInterface $gatewayMock;
    private PaymentProcessor $processor;
    
    protected function setUp(): void
    {
        parent::setUp();
        
        $this->gatewayMock = Mockery::mock(PaymentGateway::class);
        $this->processor = new PaymentProcessor($this->gatewayMock);
    }
    
    public function test_successful_payment_processing()
    {
        // Arrange
        $amount = 100.00;
        $paymentDetails = ['card_number' => '4242424242424242'];
        $transactionId = 'ch_123456';
        
        $this->gatewayMock
            ->shouldReceive('charge')
            ->once()
            ->with($amount, $paymentDetails)
            ->andReturn([
                'success' => true,
                'transaction_id' => $transactionId,
            ]);
        
        // Act
        $result = $this->processor->processPayment($amount, $paymentDetails);
        
        // Assert
        $this->assertTrue($result['success']);
        $this->assertEquals($transactionId, $result['transaction_id']);
        $this->assertEquals($amount, $result['amount']);
        $this->assertArrayHasKey('processed_at', $result);
    }
    
    public function test_failed_payment_processing()
    {
        $this->gatewayMock
            ->shouldReceive('charge')
            ->once()
            ->with(100.00, Mockery::any())
            ->andReturn([
                'success' => false,
                'error' => 'Insufficient funds',
            ]);
        
        $result = $this->processor->processPayment(100.00, []);
        
        $this->assertFalse($result['success']);
        $this->assertEquals('Payment failed: Insufficient funds', $result['error']);
    }
    
    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }
}

Test Organization and Best Practices

Test Organization Structure

tests/
├── Unit/
│   ├── Models/
│   │   ├── UserTest.php
│   │   ├── ProductTest.php
│   │   └── OrderTest.php
│   ├── Services/
│   │   ├── PaymentServiceTest.php
│   │   ├── OrderServiceTest.php
│   │   └── NotificationServiceTest.php
│   ├── Repositories/
│   │   ├── UserRepositoryTest.php
│   │   └── ProductRepositoryTest.php
│
├── Feature/
│   ├── Api/
│   │   ├── AuthTest.php
│   │   ├── ProductApiTest.php
│   │   ├── OrderApiTest.php
│   │   └── UserApiTest.php
│   ├── Web/
│   │   ├── HomePageTest.php
│   │   ├── CheckoutTest.php
│   │   └── AdminTest.php
│
├── TestCase.php
├── CreatesApplication.php
└── Pest.php

Running and Analyzing Tests

Test Commands

# Run all tests
php artisan test

# Run specific test suite
php artisan test --testsuite=Unit
php artisan test --testsuite=Feature

# Run specific test file
php artisan test tests/Feature/ProductApiTest.php

# Run specific test method
php artisan test --filter=test_create_product

# Run tests with coverage
php artisan test --coverage
php artisan test --coverage-html=coverage/

# Run tests in parallel
php artisan test --parallel
php artisan test --parallel --processes=4

# Run tests with verbose output
php artisan test --verbose

# Stop on first failure
php artisan test --stop-on-failure

Common Testing Patterns and Solutions

Testing Email Notifications

<?php
// tests/Feature/EmailNotificationTest.php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;

class EmailNotificationTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_order_confirmation_email()
    {
        Mail::fake();
        
        $user = User::factory()->create();
        $order = Order::factory()->create(['user_id' => $user->id]);
        
        // Trigger order confirmation email
        event(new \App\Events\OrderPlaced($order));
        
        // Assert email was sent
        Mail::assertSent(\App\Mail\OrderConfirmation::class, function ($mail) use ($user, $order) {
            return $mail->hasTo($user->email) &&
                   $mail->order->id === $order->id;
        });
    }
    
    public function test_password_reset_notification()
    {
        Notification::fake();
        
        $user = User::factory()->create();
        
        // Send password reset notification
        $user->sendPasswordResetNotification('token123');
        
        Notification::assertSentTo(
            $user,
            \Illuminate\Auth\Notifications\ResetPassword::class,
            function ($notification, $channels) use ($user) {
                return in_array('mail', $channels) &&
                       $notification->user->id === $user->id;
            }
        );
    }
}

Testing File Uploads

<?php
// tests/Feature/FileUploadTest.php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Foundation\Testing\RefreshDatabase;

class FileUploadTest extends TestCase
{
    use RefreshDatabase;
    
    public function test_user_can_upload_avatar()
    {
        Storage::fake('avatars');
        
        $user = User::factory()->create();
        $this->actingAs($user);
        
        $file = UploadedFile::fake()->image('avatar.jpg', 100, 100);
        
        $response = $this->postJson('/api/user/avatar', [
            'avatar' => $file,
        ]);
        
        $response->assertStatus(200);
        
        // Assert the file was stored
        Storage::disk('avatars')->assertExists($file->hashName());
        
        // Assert a file does not exist
        Storage::disk('avatars')->assertMissing('missing.jpg');
    }
    
    public function test_avatar_must_be_valid_image()
    {
        Storage::fake('avatars');
        
        $user = User::factory()->create();
        $this->actingAs($user);
        
        $file = UploadedFile::fake()->create('document.pdf', 1000);
        
        $response = $this->postJson('/api/user/avatar', [
            'avatar' => $file,
        ]);
        
        $response->assertStatus(422);
        $response->assertJsonValidationErrors(['avatar']);
    }
}

Conclusion and Best Practices

Top 10 Laravel Testing Best Practices

  1. Follow AAA Pattern: Arrange, Act, Assert for clear test structure
  2. Use Descriptive Test Names: test_user_cannot_login_with_wrong_password()
  3. Keep Tests Independent: Each test should run in isolation
  4. Test Edge Cases: Empty inputs, invalid data, boundary values
  5. Use Factories: For consistent test data creation
  6. Mock External Services: Don't hit real APIs/databases in unit tests
  7. Measure Test Coverage: Aim for 70-80% on critical paths
  8. Run Tests Frequently: Integrate with your development workflow
  9. Refactor Tests: Keep tests clean and maintainable
  10. Write Tests First: Practice TDD when possible

Common Pitfalls to Avoid

// Bad: Testing implementation details
public function test_internal_implementation()
{
    // Don't test private methods directly
    $result = $this->callPrivateMethod($object, 'privateMethod');
}

// Good: Test public behavior
public function test_public_behavior()
{
    $result = $object->publicMethod();
    $this->assertEquals('expected', $result);
}

// Bad: Overly complex test setup
public function test_something_complex()
{
    // 50 lines of setup...
    // Actual test...
}

// Good: Extract setup to helper methods
public function test_something_simple()
{
    $this->setupTestData();
    $result = $this->service->process();
    $this->assertExpected($result);
}

Getting Started Checklist

  1. Set up PHPUnit configuration
  2. Create base TestCase with common helpers
  3. Write factories for your models
  4. Start with unit tests for services
  5. Add feature tests for critical paths
  6. Set up CI/CD pipeline
  7. Monitor test coverage
  8. Refactor and improve tests regularly

Remember: Testing is a skill that improves with practice. Start small, be consistent, and gradually build your testing expertise. The time invested in testing pays dividends in code quality, maintainability, and developer confidence.