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
- Test environment configuration: phpunit.xml and .env.testing
- Unit Testing: Isolated testing of Model methods and Service classes
- Functional Testing: HTTP Request Testing and Database Transactions Trait
- API Testing: Sanctum Token Authentication + JSON Assertions
- Test Coverage: Coverage Reports Integrated with CI
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.
// 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
<!-- 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
# .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
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
# 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:
# 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
// 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
// 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
// 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:
// Execution Successful
5. Functional Testing
(1) HTTP Request Testing
// 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
// 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:
// Execution Successful
6. API Testing
(1) Sanctum Token Authentication Testing
// 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
// 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:
// Execution Successful
7. Test Coverage and CI
(1) Coverage Report
# 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
# .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
# .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:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
8. Comprehensive Example: ShopMetrics Test Suite
// ============================================
// 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
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.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.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.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
- The test environment uses an in-memory SQLite database, which offers the fastest performance
- Unit tests cover pure logic (Model/Service), while functional tests cover HTTP requests
- Pest has a concise syntax and is compatible with PHPUnit at the core
- API Testing Using
actingAs()/ Bearer Token + JSON Assertions - Event::fake()/Queue::fake() isolates side effects
- CI automatically runs tests, with a code coverage threshold of 70-80%
📝 Exercises
-
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.
-
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.
-
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%.



