404 Not Found

404 Not Found


nginx

Laravel Automated Testing

Tests are Laravel's "safety net"—running them every time you make a code change ensures that any bugs are caught immediately and don't make it into the production environment.

1. What You'll Learn


2. A True Story of a Developer on Late-Night Duty

(1) Pain Point: Bugs occur every time we deploy

Bob deployed a new version of ShopMetrics on Friday night—he changed the order discount logic, manually tested a few scenarios to make sure everything was working, and then went live. As it turned out, on Monday morning, Alice reported that negative discounts had caused order totals to become negative, resulting in over 100 users receiving their items for free. Charlie said, "If you had automated tests, this bug would have been caught when the code was committed."

(2) Solutions for Automated Testing

Automated tests run automatically with every code change—the discount logic is fully covered by tests, and test cases for negative discounts immediately fail, ensuring that bugs never make it into production.

PHP
// Test that negative discount is rejected
test('order rejects negative discount', function () {
    $response = $this->postJson('/api/v1/orders', [
        'items' => [['product_id' => 1, 'quantity' => 1]],
        'discount' => -10,  // Should be rejected!
    ]);
    $response->assertJsonValidationErrors('discount');
});

(3) Revenue

After Bob added the tests, the bug involving negative discounts was caught during development, and Alice never encountered another "free shopping" incident.


3. Test Environment Configuration

(1) phpunit.xml

XML
<!-- phpunit.xml -->
<phpunit>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

(2) .env.testing

BASH
# .env.testing — dedicated test environment
APP_ENV=testing
APP_KEY=base64:test-key-for-testing-only
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

(3) The Testing Pyramid

100%
graph TD
    A["Unit Tests<br/>(Fast, many)<br/>Model methods, Service logic"] --> B["Feature Tests<br/>(Medium, some)<br/>HTTP requests, DB queries"]
    B --> C["API/E2E Tests<br/>(Slow, few)<br/>Full request lifecycle"]
Level Quantity Speed Test Content
Unit Multi Speed (ms) Pure Logic, Model Methods
Feature Medium Medium (100 ms) HTTP requests, DB operations
API/E2E Few Slow (s) Complete request chain

(1) ▶ Example: ShopMetrics Test Environment Configuration

BASH
# Create .env.testing
cp .env .env.testing

# Edit .env.testing
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync

# Run tests
php artisan test           # All tests
php artisan test --parallel  # Parallel (faster)
php artisan test --coverage  # With coverage report

Output:

TEXT
# Command executed successfully

4. Unit Testing

(1) PHPUnit vs. Pest

Dimension PHPUnit Pest
Syntax Class + Method Functional
Sample Code More Less
Readability Medium ✅ High
Compatibility 100% Based on PHPUnit
Suitable for Complex testing Simple and fast

(2) PHPUnit Style

PHP
// tests/Unit/Models/ShopTest.php
class ShopTest extends TestCase
{
    public function test_shop_generates_slug(): void
    {
        $shop = Shop::factory()->make(['name' => 'Alice Store']);
        $this->assertEquals('alice-store', Str::slug($shop->name));
    }

    public function test_active_scope_filters_active_shops(): void
    {
        Shop::factory()->create(['status' => 'active']);
        Shop::factory()->create(['status' => 'suspended']);

        $activeShops = Shop::active()->get();

        $this->assertCount(1, $activeShops);
        $this->assertEquals('active', $activeShops->first()->status);
    }

    public function test_revenue_formatted_accessor(): void
    {
        $shop = Shop::factory()->make(['revenue' => 12345.67]);
        $this->assertEquals('$12,345.67', $shop->revenue_formatted);
    }
}

(3) Pest Style

PHP
// tests/Unit/Models/ShopTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('generates slug from name', function () {
    $shop = Shop::factory()->make(['name' => 'Alice Store']);
    expect(Str::slug($shop->name))->toBe('alice-store');
});

it('filters active shops via scope', function () {
    Shop::factory()->create(['status' => 'active']);
    Shop::factory()->create(['status' => 'suspended']);

    $active = Shop::active()->get();

    expect($active)->toHaveCount(1);
    expect($active->first()->status)->toBe('active');
});

it('formats revenue with currency symbol', function () {
    $shop = Shop::factory()->make(['revenue' => 12345.67]);
    expect($shop->revenue_formatted)->toBe('$12,345.67');
});

(1) ▶ Example: ShopMetrics Model Unit Tests

PHP
// tests/Unit/Models/OrderTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total from items', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 10.00],
        ['product_id' => 2, 'quantity' => 1, 'price' => 25.00],
    ]);

    $order->updateTotal();

    expect($order->fresh()->total)->toBe(45.00);
});

it('rejects invalid status transition', function () {
    $order = Order::factory()->create(['status' => 'completed']);

    expect(fn () => $order->update(['status' => 'pending']))
        ->toThrow(InvalidArgumentException::class);
});

it('scope completed returns only completed orders', function () {
    Order::factory()->create(['status' => 'completed']);
    Order::factory()->create(['status' => 'pending']);
    Order::factory()->create(['status' => 'cancelled']);

    expect(Order::completed()->count())->toBe(1);
});

Output:

TEXT
// Execution Successful

5. Functional Testing

(1) HTTP Request Testing

PHP
// tests/Feature/ShopControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('displays shops list on index page', function () {
    $shops = Shop::factory()->count(3)->create();

    $response = $this->get(route('shops.index'));

    $response->assertStatus(200);
    $response->assertViewIs('shops.index');
    foreach ($shops as $shop) {
        $response->assertSee($shop->name);
    }
});

it('creates a shop with valid data', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'New Shop',
        'slug' => 'new-shop',
        'description' => 'A test shop',
    ]);

    $response->assertRedirect(route('shops.index'));
    $this->assertDatabaseHas('shops', ['name' => 'New Shop']);
});

it('validates required fields on shop creation', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), []);

    $response->assertSessionHasErrors(['name', 'slug']);
});

it('prevents non-owners from creating shops', function () {
    $user = User::factory()->create(['role' => 'analyst']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'Unauthorized Shop',
        'slug' => 'unauthorized',
    ]);

    $response->assertForbidden();
});

(2) Database Assertions

Assertion Method Description
assertDatabaseHas() Records exist in the database
assertDatabaseMissing() No record found in the database
assertDatabaseCount() Match by number of records
assertSoftDeleted() Soft-deleted records exist

(1) ▶ Example: ShopMetrics CRUD Functional Testing

PHP
// tests/Feature/ProductControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists products for a shop', function () {
    Product::factory()->count(5)->create(['shop_id' => $this->shop->id]);

    $response = $this->actingAs($this->owner)
        ->get(route('shops.products.index', $this->shop));

    $response->assertOk();
    $response->assertViewHas('products');
});

it('stores a new product', function () {
    Storage::fake('public');

    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Widget Pro',
            'sku' => 'WP-001',
            'price' => 49.99,
            'stock' => 100,
        ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('products', ['sku' => 'WP-001']);
});

it('rejects negative price', function () {
    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Bad Product',
            'sku' => 'BP-001',
            'price' => -10,
            'stock' => 50,
        ]);

    $response->assertSessionHasErrors('price');
});

it('deletes a product with soft delete', function () {
    $product = Product::factory()->create(['shop_id' => $this->shop->id]);

    $this->actingAs($this->owner)
        ->delete(route('products.destroy', $product));

    $this->assertSoftDeleted($product);
});

Output:

TEXT
// Execution Successful

6. API Testing

(1) Sanctum Token Authentication Testing

PHP
// tests/Feature/Api/ShopApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('requires authentication for API access', function () {
    $this->getJson('/api/v1/shops')
        ->assertUnauthorized();
});

it('authenticates with Sanctum token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test-token', ['read'])->plainTextToken;

    $this->withHeader('Authorization', "Bearer {$token}")
        ->getJson('/api/v1/shops')
        ->assertOk();
});

it('lists shops via API', function () {
    $user = User::factory()->create();
    $shops = Shop::factory()->count(3)->create(['tenant_id' => $user->tenant_id]);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/shops');

    $response->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'slug', 'status', 'created_at'],
            ],
        ]);
});

it('creates shop via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'API Shop',
            'slug' => 'api-shop',
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'API Shop');
});

it('rejects duplicate slug', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    Shop::factory()->create(['tenant_id' => $user->tenant_id, 'slug' => 'existing']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'Duplicate',
            'slug' => 'existing',
        ]);

    $response->assertUnprocessable()
        ->assertJsonValidationErrors('slug');
});

(2) JSON Assertion Methods

Method Description
assertJson() JSON contains the specified fragment
assertJsonPath() Match values in a specified path
assertJsonStructure() JSON Structure Matching
assertJsonValidationErrors() Validation error: field included
assertJsonCount() Array Length Matching
assertJsonFragment() JSON contains a snippet

(1) ▶ Example: ShopMetrics API Test Suite

PHP
// tests/Feature/Api/OrderApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists orders with pagination', function () {
    Order::factory()->count(25)->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);

    $response = $this->actingAs($this->owner)
        ->getJson('/api/v1/orders?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure(['meta' => ['current_page', 'total']]);
});

it('shows order with items', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);
    $order->items()->create(['product_id' => 1, 'quantity' => 2, 'price' => 10]);

    $response = $this->actingAs($this->owner)
        ->getJson("/api/v1/orders/{$order->id}");

    $response->assertOk()
        ->assertJsonPath('data.order_number', $order->order_number)
        ->assertJsonStructure(['data' => ['items']]);
});

it('updates order status', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'status' => 'pending',
    ]);

    Event::fake(OrderStatusChanged::class);

    $response = $this->actingAs($this->owner)
        ->patchJson("/api/v1/orders/{$order->id}/status", [
            'status' => 'completed',
        ]);

    $response->assertOk();
    expect($order->fresh()->status)->toBe('completed');
    Event::assertDispatched(OrderStatusChanged::class);
});

Output:

TEXT
// Execution Successful

7. Test Coverage and CI

(1) Coverage Report

BASH
# Generate HTML coverage report
php artisan test --coverage-html=coverage

# Minimum coverage threshold
php artisan test --coverage --min=80

# Coverage for specific directory
php artisan test --coverage-filter=app/Services

(2) GitHub Actions CI

YAML
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: shopmetrics_test
        ports:
          - 3306:3306
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: xdebug
      - run: composer install --no-interaction
      - run: cp .env.testing .env
      - run: php artisan key:generate
      - run: php artisan test --coverage --min=80

(1) ▶ Example: ShopMetrics CI Test Configuration

YAML
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: [8.2, 8.3]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - run: composer install
      - run: php artisan test --parallel
      - run: php artisan test --coverage --min=70
        if: matrix.php == '8.3'

  larastan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
      - run: composer install
      - run: ./vendor/bin/phpstan analyse --level=5

Output:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

8. Comprehensive Example: ShopMetrics Test Suite

PHP
// ============================================
// Comprehensive: ShopMetrics Test Suite
// Covers: unit, feature, API tests with Pest
// ============================================

// tests/Unit/Services/OrderServiceTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total correctly', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 0, 'total' => 0]);
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 15.00],
        ['product_id' => 2, 'quantity' => 3, 'price' => 10.00],
    ]);

    $service->calculateTotal($order);

    expect($order->fresh()->total)->toBe(60.00);
});

it('applies discount within limits', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100, 'discount' => 0, 'total' => 100]);

    $service->applyDiscount($order, 20);

    expect($order->fresh()->total)->toBe(80.00);
});

it('rejects discount exceeding subtotal', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100]);

    expect(fn () => $service->applyDiscount($order, 150))
        ->toThrow(InvalidArgumentException::class);
});

// tests/Feature/Api/CompleteOrderFlowTest.php
it('completes full order flow via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    $shop = Shop::factory()->create(['tenant_id' => $user->tenant_id]);
    $products = Product::factory()->count(3)->create(['shop_id' => $shop->id, 'stock' => 50]);

    Event::fake();
    Queue::fake();

    // Create order
    $response = $this->actingAs($user)->postJson('/api/v1/orders', [
        'shop_id' => $shop->id,
        'items' => [
            ['product_id' => $products[0]->id, 'quantity' => 2],
            ['product_id' => $products[1]->id, 'quantity' => 1],
        ],
    ]);

    $response->assertCreated();
    $orderId = $response->json('data.id');

    // Verify order in database
    $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);

    // Update status to processing
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
        ->assertOk();

    // Update status to completed
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'completed'])
        ->assertOk();

    expect(Order::find($orderId)->status)->toBe('completed');
});

❓ FAQ

Q Which should I choose, PHPUnit or Pest?
A We recommend Pest for new projects—it has a more concise syntax and is more readable. Pest is built on top of PHPUnit, so it supports all PHPUnit features. You can use both together in existing PHPUnit projects.
Q What is the difference between RefreshDatabase and DatabaseMigrations?
A RefreshDatabase runs migrate first and then wraps each test in a transaction (for faster rollback); DatabaseMigrations runs migrate before each test and rollback after each test. RefreshDatabase is faster and is recommended.
Q How do I mock external services during testing?
A Use Http::fake() to simulate HTTP responses: Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])]). Use Mail::fake() for email, Event::fake() for events, and Queue::fake() for queues.
Q What is an appropriate level of test coverage?
A 80%+ for core business logic, 70%+ for model methods, and 60%+ for controllers. Aiming for 100% coverage is not cost-effective—focus on covering key business paths and complex logic, rather than every getter and setter.
Q How can I run tests in parallel in CI?
A php artisan test --parallel=4 Run them in parallel using 4 processes. Each test must be independent (not dependent on data from other tests), and use an in-memory SQLite database to avoid database contention.
Q How do I handle file uploads in tests?
A Use Storage::fake('disk') to simulate the file system, and use UploadedFile::fake()->create('test.jpg') to create a dummy file. The dummy file is automatically cleaned up after the test.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Write three unit tests for the Shop model (active scope, revenue_formatted accessor, and slug generation) using Pest syntax, and ensure they all pass.

  2. Advanced Exercise (⭐⭐): Write functional tests for the ShopController CRUD operations (index/show/store/update/destroy), including assertions for validation failures and permission checks, using the RefreshDatabase trait.

  3. Challenge (⭐⭐⭐): Write a complete API order process test—create an order → update the status → verify database changes → mock events and queues → confirm that Event::assertDispatched and Queue::assertPushed pass, with a coverage rate of ≥ 80%.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏