Testing is the backbone of reliable software development, and Laravel provides an exceptional testing ecosystem. In this comprehensive guide, we'll explore everything you need to know about testing Laravel applications using Pest 4, covering unit tests, feature tests, integration tests, external service mocking, and advanced testing patterns.
Table of Contents
- Setting Up the Testing Environment
- Understanding Test Types
- Unit Testing Fundamentals
- Feature Testing Deep Dive
- Integration Testing
- Testing External Services and APIs
- Mocking and Stubbing with Mockery
- Laravel's Fake Methods
- Database Testing Strategies
- Testing Jobs, Queues, and Batches
- Comprehensive List of Assertions
- Advanced Testing Patterns
- Best Practices and Tips
Setting Up the Testing Environment
First, let's ensure your Laravel 12 application is properly configured for testing with Pest 4.
Configuration
Update your phpunit.xml file for Laravel 12:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" 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"/>
</php>
</phpunit>
Parallel Testing (Laravel 12 Feature)
Laravel 12 introduces built-in parallel testing capabilities. To enable parallel testing, install the required package:
composer require brianium/paratest --dev
Then run tests in parallel:
# Run tests with automatic process detection
php artisan test --parallel
# Specify number of processes
php artisan test --parallel --processes=4
Basic Pest Configuration
Create tests/Pest.php for Laravel 12 and Pest 4:
<?php
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
// Feature tests with database refresh
uses(TestCase::class, RefreshDatabase::class)->in('Feature');
// Unit tests without database
uses(TestCase::class)->in('Unit');
// Custom expectations
expect()->extend('toBeValidEmail', function () {
return $this->toBeString()
->and(filter_var($this->value, FILTER_VALIDATE_EMAIL))->not->toBeFalse();
});
expect()->extend('toBeActiveUser', function () {
return $this->toBeInstanceOf(App\Models\User::class)
->and($this->value->is_active)->toBeTrue();
});
// Test helpers
function createUser(array $attributes = []): App\Models\User
{
return App\Models\User::factory()->create($attributes);
}
function authenticateAs(User $user = null): User
{
$user = $user ?? createUser();
test()->actingAs($user);
return $user;
}
Understanding Test Types
Unit Tests
Test individual components in isolation, typically single methods or classes. These tests run fast and don't require external dependencies.
Feature Tests
Test complete features from HTTP request to response, including middleware, controllers, and views. These tests simulate real user interactions.
Integration Tests
Test how different parts of your application work together, including database interactions and external services.
Browser Tests (Pest 4 Feature)
Pest 4 introduces browser testing capabilities using Playwright, allowing you to test your application in a real browser environment:
# Install browser testing plugin
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest
npx playwright install
<?php
// tests/Browser/UserRegistrationTest.php
use Pest\Browser\TestCase;
uses(TestCase::class);
it('allows user registration through browser', function () {
$this->browse(function ($browser) {
$browser->visit('/register')
->type('name', 'John Doe')
->type('email', '[email protected]')
->type('password', 'password')
->type('password_confirmation', 'password')
->press('Register')
->assertPathIs('/dashboard')
->assertSee('Welcome, John Doe!');
});
});
Unit Testing Fundamentals
Unit tests focus on testing individual components in isolation. Here's how to structure effective unit tests:
Testing a Service Class
<?php
// app/Services/OrderCalculator.php
namespace App\Services;
class OrderCalculator
{
public function calculateTotal(array $items, float $taxRate = 0.1): float
{
$subtotal = array_sum(array_column($items, 'price'));
$tax = $subtotal * $taxRate;
return round($subtotal + $tax, 2);
}
public function calculateDiscount(float $total, string $couponCode): float
{
$discounts = [
'SAVE10' => 0.1,
'SAVE20' => 0.2,
'FIRST50' => 0.5,
];
if (!isset($discounts[$couponCode])) {
return 0;
}
return round($total * $discounts[$couponCode], 2);
}
}
<?php
// tests/Unit/Services/OrderCalculatorTest.php
use App\Services\OrderCalculator;
describe('OrderCalculator', function () {
beforeEach(function () {
$this->calculator = new OrderCalculator();
});
describe('calculateTotal', function () {
it('calculates total with default tax rate', function () {
$items = [
['price' => 10.00],
['price' => 20.00],
['price' => 30.00],
];
$total = $this->calculator->calculateTotal($items);
expect($total)->toBe(66.0); // 60 + 6 (10% tax)
});
it('calculates total with custom tax rate', function () {
$items = [['price' => 100.00]];
$total = $this->calculator->calculateTotal($items, 0.08);
expect($total)->toBe(108.0);
});
it('handles empty items array', function () {
$total = $this->calculator->calculateTotal([]);
expect($total)->toBe(0.0);
});
});
describe('calculateDiscount', function () {
it('applies valid discount coupon', function () {
$discount = $this->calculator->calculateDiscount(100, 'SAVE10');
expect($discount)->toBe(10.0);
});
it('returns zero for invalid coupon', function () {
$discount = $this->calculator->calculateDiscount(100, 'INVALID');
expect($discount)->toBe(0.0);
});
});
});
Testing Models
<?php
// tests/Unit/Models/UserTest.php
use App\Models\User;
use App\Models\Post;
describe('User Model', function () {
it('has fillable attributes', function () {
$user = new User();
expect($user->getFillable())->toContain('name', 'email', 'password');
});
it('hides password from array', function () {
$user = User::factory()->make();
expect($user->toArray())->not->toHaveKey('password');
});
it('casts email_verified_at to datetime', function () {
$user = User::factory()->make([
'email_verified_at' => '2024-01-01 12:00:00'
]);
expect($user->email_verified_at)->toBeInstanceOf(Carbon\Carbon::class);
});
it('has many posts relationship', function () {
$user = new User();
expect($user->posts())->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\HasMany::class);
});
});
Feature Testing Deep Dive
Feature tests simulate real user interactions with your application:
Testing Controllers and Routes
<?php
// tests/Feature/PostControllerTest.php
describe('PostController', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->post = Post::factory()->create(['user_id' => $this->user->id]);
});
describe('index', function () {
it('displays all posts', function () {
Post::factory()->count(5)->create();
$response = $this->get('/posts');
$response->assertStatus(200)
->assertViewIs('posts.index')
->assertViewHas('posts');
expect($response->viewData('posts'))->toHaveCount(6); // 5 + 1 from beforeEach
});
it('filters posts by category', function () {
Post::factory()->create(['category' => 'tech']);
Post::factory()->create(['category' => 'health']);
$response = $this->get('/posts?category=tech');
$response->assertStatus(200);
$posts = $response->viewData('posts');
expect($posts)->toHaveCount(1);
expect($posts->first()->category)->toBe('tech');
});
});
describe('file upload', function () {
it('handles file uploads correctly', function () {
Storage::fake('public');
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$response = $this->actingAs(User::factory()->create())
->post('/upload', ['photo' => $file]);
$response->assertStatus(200);
Storage::disk('public')->assertExists('uploads/photo.jpg');
});
it('validates file types', function () {
Storage::fake('public');
$file = UploadedFile::fake()->create('document.pdf', 100);
$response = $this->actingAs(User::factory()->create())
->post('/upload', ['photo' => $file]);
$response->assertSessionHasErrors(['photo']);
Storage::disk('public')->assertMissing('uploads/document.pdf');
});
it('handles multiple file uploads', function () {
Storage::fake('public');
$files = [
UploadedFile::fake()->image('photo1.jpg'),
UploadedFile::fake()->image('photo2.jpg'),
UploadedFile::fake()->image('photo3.jpg'),
];
$response = $this->actingAs(User::factory()->create())
->post('/upload-multiple', ['photos' => $files]);
$response->assertStatus(200);
foreach ($files as $index => $file) {
Storage::disk('public')->assertExists("uploads/photo{$index}.jpg");
}
});
});
Notification Fake
<?php
// tests/Feature/NotificationTest.php
use Illuminate\Support\Facades\Notification;
use App\Notifications\OrderShipped;
use App\Notifications\PasswordReset;
describe('Notifications', function () {
it('sends notifications to users', function () {
Notification::fake();
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($notification) use ($order) {
return $notification->order->id === $order->id;
});
});
it('sends notifications via specific channels', function () {
Notification::fake();
$user = User::factory()->create(['phone' => '+1234567890']);
$user->notify(new OrderShipped(Order::factory()->create()));
Notification::assertSentTo($user, OrderShipped::class, function ($notification) {
return in_array('sms', $notification->via($notification->notifiable));
});
});
it('sends notifications to multiple users', function () {
Notification::fake();
$users = User::factory()->count(3)->create();
Notification::send($users, new PasswordReset('token123'));
Notification::assertSentTo($users, PasswordReset::class);
Notification::assertSentToTimes($users->first(), PasswordReset::class, 1);
});
});
Cache Fake
<?php
// tests/Feature/CacheTest.php
use Illuminate\Support\Facades\Cache;
describe('Cache Operations', function () {
it('caches expensive operations', function () {
Cache::shouldReceive('remember')
->once()
->with('user_stats_123', 3600, Mockery::type('Closure'))
->andReturn(['posts' => 10, 'comments' => 25]);
$service = new App\Services\UserStatsService();
$stats = $service->getUserStats(123);
expect($stats)->toHaveKey('posts', 10);
expect($stats)->toHaveKey('comments', 25);
});
it('handles cache miss gracefully', function () {
Cache::shouldReceive('get')
->once()
->with('user_preferences_123')
->andReturnNull();
Cache::shouldReceive('put')
->once()
->with('user_preferences_123', Mockery::any(), 3600);
$service = new App\Services\UserPreferencesService();
$preferences = $service->getPreferences(123);
expect($preferences)->toBeArray();
});
});
Database Testing Strategies
Using Transactions
<?php
// tests/Feature/DatabaseTransactionTest.php
use Illuminate\Foundation\Testing\DatabaseTransactions;
class DatabaseTransactionTest extends TestCase
{
use DatabaseTransactions;
public function test_database_transaction_example()
{
$user = User::create([
'name' => 'Test User',
'email' => '[email protected]',
'password' => bcrypt('password')
]);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
// Data will be rolled back after test
}
}
Database Seeders in Tests
<?php
// tests/Feature/DatabaseSeederTest.php
describe('Database Operations', function () {
it('seeds test data correctly', function () {
$this->seed([
CategorySeeder::class,
ProductSeeder::class,
]);
expect(Category::count())->toBeGreaterThan(0);
expect(Product::count())->toBeGreaterThan(0);
$techCategory = Category::where('name', 'Technology')->first();
expect($techCategory)->not->toBeNull();
expect($techCategory->products)->toHaveCount(5);
});
it('handles database constraints', function () {
$category = Category::factory()->create();
$product = Product::factory()->create(['category_id' => $category->id]);
expect(fn() => $category->delete())
->toThrow(Illuminate\Database\QueryException::class);
$product->delete();
$category->delete(); // Now this should work
$this->assertDatabaseMissing('categories', ['id' => $category->id]);
});
});
Testing Database Relationships
<?php
// tests/Unit/Models/RelationshipTest.php
describe('Model Relationships', function () {
it('creates related models correctly', function () {
$user = User::factory()
->has(Post::factory()->count(3))
->create();
expect($user->posts)->toHaveCount(3);
expect($user->posts->first())->toBeInstanceOf(Post::class);
});
it('handles eager loading', function () {
User::factory()
->has(Post::factory()->count(2))
->count(3)
->create();
$users = User::with('posts')->get();
// Should only execute 2 queries (users + posts)
$this->assertEquals(3, $users->count());
expect($users->first()->posts)->toHaveCount(2);
});
it('tests polymorphic relationships', function () {
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => Post::class,
'commentable_id' => $post->id
]);
expect($comment->commentable)->toBeInstanceOf(Post::class);
expect($comment->commentable->id)->toBe($post->id);
});
});
Testing Jobs, Queues, and Batches
Testing Individual Jobs
<?php
// tests/Unit/Jobs/ProcessOrderJobTest.php
use App\Jobs\ProcessOrder;
use App\Models\Order;
describe('ProcessOrder Job', function () {
it('processes order successfully', function () {
$order = Order::factory()->create(['status' => 'pending']);
$job = new ProcessOrder($order);
$job->handle();
$order->refresh();
expect($order->status)->toBe('processing');
expect($order->processed_at)->not->toBeNull();
});
it('handles job failure', function () {
$order = Order::factory()->create(['status' => 'pending']);
// Mock a service that will fail
$mockService = Mockery::mock(App\Services\OrderProcessingService::class);
$mockService->shouldReceive('process')
->once()
->andThrow(new Exception('Processing failed'));
$this->app->instance(App\Services\OrderProcessingService::class, $mockService);
$job = new ProcessOrder($order);
expect(fn() => $job->handle())->toThrow(Exception::class);
$order->refresh();
expect($order->status)->toBe('pending'); // Status unchanged
});
it('retries failed jobs', function () {
$job = new ProcessOrder(Order::factory()->create());
expect($job->tries)->toBe(3);
expect($job->timeout)->toBe(300);
});
});
Testing Job Batches
<?php
// tests/Feature/BatchProcessingTest.php
use Illuminate\Support\Facades\Bus;
use App\Jobs\ProcessBatchItem;
use App\Jobs\CompleteBatch;
describe('Batch Processing', function () {
it('processes batch successfully', function () {
Bus::fake();
$items = collect(range(1, 10))->map(fn($i) => new ProcessBatchItem($i));
$batch = Bus::batch($items)
->then(fn() => dispatch(new CompleteBatch()))
->name('Test Batch')
->dispatch();
Bus::assertBatched(function ($batch) {
return $batch->name === 'Test Batch' &&
$batch->jobs->count() === 10;
});
});
it('handles batch cancellation', function () {
Bus::fake();
$batch = Bus::batch([
new ProcessBatchItem(1),
new ProcessBatchItem(2),
])
->allowFailures()
->dispatch();
$batch->cancel();
expect($batch->cancelled())->toBeTrue();
});
it('processes batch with progress tracking', function () {
$items = OrderItem::factory()->count(5)->create();
$jobs = $items->map(fn($item) => new ProcessOrderItem($item));
$batch = Bus::batch($jobs)
->progress(function ($batch) {
// Track progress
cache(['batch_progress' => $batch->progress()]);
})
->dispatch();
// Simulate job completion
foreach ($batch->jobs as $job) {
$job->handle();
}
expect(cache('batch_progress'))->toBe(100);
});
});
Testing Failed Jobs
<?php
// tests/Feature/FailedJobTest.php
describe('Failed Job Handling', function () {
it('handles job failures gracefully', function () {
Queue::fake();
$job = new ProcessOrder(Order::factory()->create());
// Simulate job failure
$job->fail(new Exception('Database connection failed'));
Queue::assertPushed(ProcessOrder::class);
// Check that failure was logged
$this->assertDatabaseHas('failed_jobs', [
'payload' => json_encode($job)
]);
});
it('retries failed jobs with exponential backoff', function () {
$job = new ProcessOrder(Order::factory()->create());
expect($job->backoff())->toEqual([1, 5, 10, 30]);
});
});
Comprehensive List of Assertions
Pest-specific Assertions
Pest 4 introduces enhanced assertion capabilities with fluent syntax:
<?php
describe('Pest Assertions', function () {
it('demonstrates basic expectations', function () {
$value = 'Laravel Testing';
expect($value)->toBe('Laravel Testing')
->toBeString()
->toContain('Testing')
->toHaveLength(15)
->not->toBeEmpty();
});
it('demonstrates Pest 4 new assertions', function () {
$collection = collect([1, 2, 3, 4, 5]);
expect($collection)
->toHaveCount(5)
->toContain(3)
->each->toBeInt()
->and($collection->first())->toBe(1);
// Chaining with 'and' for multiple assertions
expect($collection->first())
->toBeInt()
->and($collection->last())->toBe(5);
});
it('demonstrates array assertions', function () {
$array = ['name' => 'John', 'age' => 30, 'skills' => ['PHP', 'Laravel']];
expect($array)->toHaveKey('name')
->toHaveKey('name', 'John')
->toHaveKeys(['name', 'age'])
->toHaveCount(3)
->toMatchArray(['name' => 'John', 'age' => 30]);
expect($array['skills'])->toContain('PHP')
->toContainEqual('Laravel')
->each->toBeString();
});
it('demonstrates object assertions', function () {
$user = new User(['name' => 'John', 'email' => '[email protected]']);
expect($user)->toBeInstanceOf(User::class)
->toHaveProperty('name')
->toHaveProperty('name', 'John')
->toHaveProperties(['name', 'email']);
});
it('demonstrates numeric assertions', function () {
$number = 42;
expect($number)->toBe(42)
->toEqual(42)
->toBeInt()
->toBeGreaterThan(40)
->toBeLessThan(50)
->toBeGreaterThanOrEqual(42)
->toBeBetween(40, 45);
});
it('demonstrates boolean and null assertions', function () {
expect(true)->toBeTrue()
->toBeTruthy()
->not->toBeFalse()
->not->toBeFalsy();
expect(null)->toBeNull()
->toBeFalsy();
expect('')->toBeEmpty()
->toBeFalsy();
});
it('demonstrates callable assertions', function () {
$callable = fn() => throw new InvalidArgumentException('Test error');
expect($callable)->toThrow(InvalidArgumentException::class)
->toThrow(InvalidArgumentException::class, 'Test error');
$validCallable = fn() => 'success';
expect($validCallable)->not->toThrow();
});
it('demonstrates collection assertions', function () {
$collection = collect([1, 2, 3, 4, 5]);
expect($collection)
->toHaveCount(5)
->toContain(3)
->each->toBeInt()
->and($collection->first())->toBe(1);
$users = collect([
['name' => 'John', 'active' => true],
['name' => 'Jane', 'active' => false],
]);
expect($users)
->each->toHaveKey('name')
->each->toHaveKey('active')
->and($users->first()['name'])->toBe('John');
});
});
Laravel-specific Assertions
<?php
describe('Laravel Assertions', function () {
it('demonstrates HTTP response assertions', function () {
$response = $this->get('/');
$response->assertOk()
->assertStatus(200)
->assertSuccessful()
->assertViewIs('welcome')
->assertViewHas('title')
->assertViewHas('title', 'Welcome')
->assertSee('Welcome to Laravel')
->assertSeeText('Welcome')
->assertDontSee('Error')
->assertSeeInOrder(['Welcome', 'Laravel']);
});
it('demonstrates JSON response assertions', function () {
$response = $this->getJson('/api/users');
$response->assertJson(['success' => true])
->assertJsonStructure([
'data' => ['*' => ['id', 'name', 'email']],
'meta' => ['total', 'per_page']
])
->assertJsonFragment(['name' => 'John Doe'])
->assertJsonMissing(['password'])
->assertJsonCount(10, 'data')
->assertJsonPath('meta.total', 100);
});
it('demonstrates redirect assertions', function () {
$response = $this->post('/login', []);
$response->assertRedirect()
->assertRedirect('/dashboard')
->assertRedirectToRoute('dashboard')
->assertRedirectToSignedRoute('profile', ['user' => 1]);
});
it('demonstrates session assertions', function () {
$response = $this->post('/contact', []);
$response->assertSessionHas('message')
->assertSessionHas('message', 'Success!')
->assertSessionHasInput('email')
->assertSessionHasErrors(['name', 'email'])
->assertSessionHasErrorsIn('registration', ['email'])
->assertSessionHasNoErrors()
->assertSessionMissing('temp_data');
});
it('demonstrates authentication assertions', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->assertAuthenticated()
->assertAuthenticatedAs($user)
->assertGuest('web');
auth()->logout();
$this->assertGuest();
});
it('demonstrates database assertions', function () {
$user = User::factory()->create(['email' => '[email protected]']);
$this->assertDatabaseHas('users', ['email' => '[email protected]'])
->assertDatabaseMissing('users', ['email' => '[email protected]'])
->assertDatabaseCount('users', 1)
->assertDeleted($user) // After soft delete
->assertSoftDeleted('users', ['id' => $user->id]);
});
it('demonstrates model assertions', function () {
$post = Post::factory()->create(['title' => 'Test Post']);
$user = User::factory()->create();
expect($post)->toExist()
->toBeModel(Post::class)
->toHaveProperty('title', 'Test Post');
$post->delete();
expect($post)->not->toExist();
});
});
Custom Assertions
<?php
// tests/Pest.php
expect()->extend('toBeValidEmail', function () {
$email = $this->value;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new ExpectationFailedException("Expected '{$email}' to be a valid email address");
}
return $this;
});
expect()->extend('toBeActiveUser', function () {
$user = $this->value;
if (!$user instanceof User || !$user->is_active) {
throw new ExpectationFailedException('Expected user to be active');
}
return $this;
});
// Using custom assertions
describe('Custom Assertions', function () {
it('uses custom email assertion', function () {
expect('[email protected]')->toBeValidEmail();
expect('invalid-email')->not->toBeValidEmail();
});
it('uses custom user assertion', function () {
$activeUser = User::factory()->create(['is_active' => true]);
$inactiveUser = User::factory()->create(['is_active' => false]);
expect($activeUser)->toBeActiveUser();
expect($inactiveUser)->not->toBeActiveUser();
});
});
Advanced Testing Patterns
Pest 4 Advanced Features
Test Datasets with Enhanced Syntax
<?php
// Advanced dataset usage in Pest 4
describe('Email Validation', function () {
it('validates email formats correctly', function (string $email, bool $expected) {
$validator = Validator::make(['email' => $email], [
'email' => 'required|email'
]);
expect($validator->passes())->toBe($expected);
})->with([
['[email protected]', true],
['invalid-email', false],
['[email protected]', true],
['@example.com', false],
]);
// Named datasets for better readability
it('handles different user types', function (string $userType, array $expectedPermissions) {
$user = User::factory()->create(['type' => $userType]);
expect($user->permissions)->toEqual($expectedPermissions);
})->with([
'admin' => ['admin', ['create', 'read', 'update', 'delete']],
'editor' => ['editor', ['create', 'read', 'update']],
'viewer' => ['viewer', ['read']],
]);
});
Test Groups and Organization
<?php
// Grouping tests for better organization
describe('User Management', function () {
describe('Authentication', function () {
it('authenticates valid users')
->group('auth')
->group('smoke');
it('rejects invalid credentials')
->group('auth')
->group('security');
});
describe('Authorization', function () {
it('allows admin access')
->group('auth')
->group('admin');
it('denies user access to admin areas')
->group('auth')
->group('security');
});
});
// Run specific groups
// php artisan test --group=smoke
// php artisan test --group=auth,admin
Custom Test Helpers
<?php
// tests/Support/TestHelpers.php
namespace Tests\Support;
trait DatabaseHelpers
{
protected function seedUsers(int $count = 5): Collection
{
return User::factory()->count($count)->create();
}
protected function createOrderWithItems(int $itemCount = 3): Order
{
$order = Order::factory()->create();
OrderItem::factory()
->count($itemCount)
->create(['order_id' => $order->id]);
return $order;
}
protected function assertDatabaseHasExactCount(string $table, int $count): void
{
$actual = DB::table($table)->count();
expect($actual)->toBe($count, "Expected {$count} records in {$table}, found {$actual}");
}
}
// Using in tests
uses(DatabaseHelpers::class);
describe('Order Processing', function () {
it('processes orders with multiple items', function () {
$order = $this->createOrderWithItems(5);
expect($order->items)->toHaveCount(5);
$this->assertDatabaseHasExactCount('orders', 1);
$this->assertDatabaseHasExactCount('order_items', 5);
});
});
Testing Complex Workflows
<?php
// tests/Feature/EcommerceWorkflowTest.php
describe('E-commerce Workflow', function () {
it('completes full purchase workflow', function () {
// Setup
[$user, $product, $cart] = $this->setupEcommerceScenario();
// Add product to cart
$this->addToCartAndVerify($user, $product, $cart);
// Apply discount
$this->applyDiscountAndVerify($cart, 'SAVE10');
// Process payment
$order = $this->processPaymentAndVerify($user, $cart);
// Verify order fulfillment
$this->verifyOrderFulfillment($order);
});
private function setupEcommerceScenario(): array
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 100, 'stock' => 10]);
$cart = Cart::factory()->create(['user_id' => $user->id]);
return [$user, $product, $cart];
}
private function addToCartAndVerify(User $user, Product $product, Cart $cart): void
{
$response = $this->actingAs($user)
->post('/cart/add', [
'product_id' => $product->id,
'quantity' => 2
]);
$response->assertOk();
$cart->refresh();
expect($cart->items)->toHaveCount(1);
expect($cart->total)->toBe(200.0);
}
private function applyDiscountAndVerify(Cart $cart, string $coupon): void
{
$response = $this->post('/cart/coupon', ['code' => $coupon]);
$response->assertOk();
$cart->refresh();
expect($cart->discount_amount)->toBe(20.0); // 10% of 200
expect($cart->final_total)->toBe(180.0);
}
private function processPaymentAndVerify(User $user, Cart $cart): Order
{
Http::fake([
'api.stripe.com/*' => Http::response([
'id' => 'ch_test_123',
'status' => 'succeeded'
])
]);
$response = $this->actingAs($user)
->post('/checkout', [
'payment_method' => 'card_token_123',
'billing_address' => '123 Test St'
]);
$response->assertRedirect('/orders/confirmation');
$order = Order::where('user_id', $user->id)->latest()->first();
expect($order)->not->toBeNull();
expect($order->total)->toBe(180.0);
expect($order->status)->toBe('paid');
return $order;
}
private function verifyOrderFulfillment(Order $order): void
{
Queue::fake();
event(new OrderPaid($order));
Queue::assertPushed(ProcessOrderFulfillment::class);
Queue::assertPushed(SendOrderConfirmationEmail::class);
}
});
Testing with Time Manipulation
<?php
// tests/Feature/TimeBasedTest.php
use Illuminate\Support\Facades\Date;
describe('Time-based Features', function () {
it('handles subscription expiration', function () {
// Travel to a specific date
$this->travel('2024-01-01');
$user = User::factory()->create();
$subscription = Subscription::factory()->create([
'user_id' => $user->id,
'expires_at' => now()->addDays(30)
]);
expect($subscription->isActive())->toBeTrue();
// Travel to expiration date
$this->travelTo($subscription->expires_at->addDay());
expect($subscription->fresh()->isActive())->toBeFalse();
});
it('handles daily job scheduling', function () {
Queue::fake();
$this->travel('2024-01-01 09:00:00');
// Run daily cleanup job
Artisan::call('schedule:run');
Queue::assertPushed(DailyCleanupJob::class);
// Travel forward 1 day
$this->travel(24 * 60 * 60); // 24 hours in seconds
Artisan::call('schedule:run');
Queue::assertPushedTimes(DailyCleanupJob::class, 2);
});
it('handles timezone conversions', function () {
$user = User::factory()->create(['timezone' => 'America/New_York']);
// Set app timezone to UTC
config(['app.timezone' => 'UTC']);
$this->travelTo('2024-01-01 12:00:00 UTC');
$event = Event::factory()->create([
'starts_at' => now()->setTimezone($user->timezone)
]);
expect($event->starts_at->format('H:i'))->toBe('07:00'); // 5 hour difference
});
});
Testing Concurrent Operations
<?php
// tests/Feature/ConcurrencyTest.php
describe('Concurrency Handling', function () {
it('handles race conditions in inventory', function () {
$product = Product::factory()->create(['stock' => 1]);
// Simulate two concurrent purchase attempts
$user1 = User::factory()->create();
$user2 = User::factory()->create();
// Mock concurrent requests
$responses = collect([
$this->actingAs($user1)->post('/purchase', ['product_id' => $product->id]),
$this->actingAs($user2)->post('/purchase', ['product_id' => $product->id])
]);
// One should succeed, one should fail
$successes = $responses->filter(fn($r) => $r->isRedirect())->count();
$failures = $responses->filter(fn($r) => $r->status() === 409)->count();
expect($successes)->toBe(1);
expect($failures)->toBe(1);
$product->refresh();
expect($product->stock)->toBe(0);
});
it('handles database locks correctly', function () {
$account = Account::factory()->create(['balance' => 1000]);
// Simulate concurrent withdrawals
DB::transaction(function () use ($account) {
$lockedAccount = Account::lockForUpdate()->find($account->id);
if ($lockedAccount->balance >= 500) {
$lockedAccount->decrement('balance', 500);
}
});
$account->refresh();
expect($account->balance)->toBe(500);
});
});
Testing Performance
<?php
// tests/Feature/PerformanceTest.php
describe('Performance Tests', function () {
it('handles large datasets efficiently', function () {
// Create large dataset
User::factory()->count(1000)->create();
$startTime = microtime(true);
// Query with pagination
$users = User::paginate(50);
$endTime = microtime(true);
$executionTime = ($endTime - $startTime) * 1000; // Convert to milliseconds
expect($executionTime)->toBeLessThan(100); // Should complete in under 100ms
expect($users->count())->toBe(50);
});
it('optimizes N+1 queries', function () {
User::factory()
->has(Post::factory()->count(3))
->count(10)
->create();
// Enable query logging
DB::enableQueryLog();
// Fetch users with posts (should use eager loading)
$users = User::with('posts')->get();
$queries = DB::getQueryLog();
// Should only execute 2 queries: users + posts
expect(count($queries))->toBe(2);
expect($users->count())->toBe(10);
expect($users->first()->posts->count())->toBe(3);
});
it('handles memory usage efficiently', function () {
$startMemory = memory_get_usage();
// Process large collection in chunks
User::factory()->count(1000)->create();
User::chunk(100, function ($users) {
foreach ($users as $user) {
// Process user
$user->update(['last_processed' => now()]);
}
});
$endMemory = memory_get_usage();
$memoryUsed = $endMemory - $startMemory;
// Should use less than 50MB
expect($memoryUsed)->toBeLessThan(50 * 1024 * 1024);
});
});
Best Practices and Tips
Senior-Level Testing Strategies
Test Architecture Patterns
<?php
// tests/Feature/OrderWorkflowTest.php
class OrderWorkflowTest extends TestCase
{
use RefreshDatabase;
private User $customer;
private Product $product;
private Cart $cart;
protected function setUp(): void
{
parent::setUp();
// Arrange: Setup test data
$this->customer = $this->createVerifiedCustomer();
$this->product = $this->createProductWithStock(10);
$this->cart = $this->createEmptyCart($this->customer);
}
/**
* @test
* @group ecommerce
* @group integration
*/
public function it_completes_full_purchase_workflow(): void
{
// Act: Execute the workflow
$this->addProductToCart($this->product, 2);
$this->applyDiscount('SAVE10');
$order = $this->processPayment();
// Assert: Verify the outcome
$this->assertOrderCreated($order);
$this->assertInventoryReduced($this->product, 2);
$this->assertCartCleared($this->customer);
$this->assertNotificationsSent($order);
}
private function createVerifiedCustomer(): User
{
return User::factory()
->verified()
->withProfile()
->create();
}
private function assertOrderCreated(Order $order): void
{
expect($order)
->toBeInstanceOf(Order::class)
->and($order->status)->toBe(OrderStatus::CONFIRMED)
->and($order->total)->toBeGreaterThan(0);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $this->customer->id,
'status' => OrderStatus::CONFIRMED->value,
]);
}
}
Domain-Driven Test Structure
<?php
// tests/Domain/Order/OrderAggregateTest.php
namespace Tests\Domain\Order;
describe('Order Aggregate', function () {
beforeEach(function () {
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->paymentService = Mockery::mock(PaymentServiceInterface::class);
$this->inventoryService = Mockery::mock(InventoryServiceInterface::class);
$this->orderService = new OrderService(
$this->orderRepository,
$this->paymentService,
$this->inventoryService
);
});
describe('Order Creation', function () {
it('creates order with valid items', function () {
// Arrange
$customer = Customer::factory()->create();
$items = OrderItem::factory()->count(3)->make();
$this->inventoryService
->shouldReceive('reserveItems')
->once()
->with($items->toArray())
->andReturn(true);
$this->orderRepository
->shouldReceive('save')
->once()
->with(Mockery::type(Order::class));
// Act
$order = $this->orderService->createOrder($customer, $items);
// Assert
expect($order)
->toBeInstanceOf(Order::class)
->and($order->customer_id)->toBe($customer->id)
->and($order->items)->toHaveCount(3)
->and($order->status)->toBe(OrderStatus::PENDING);
});
});
});
Contract Testing
<?php
// tests/Contracts/PaymentGatewayContractTest.php
describe('PaymentGateway Contract', function () {
it('fulfills payment gateway interface', function (PaymentGatewayInterface $gateway) {
$paymentRequest = new PaymentRequest(
amount: Money::USD(10000), // $100.00
token: 'tok_test_123',
metadata: ['order_id' => '123']
);
$result = $gateway->charge($paymentRequest);
expect($result)
->toBeInstanceOf(PaymentResult::class)
->and($result->success)->toBeBool()
->and($result->transactionId)->toBeString()
->and($result->amount)->toBeInstanceOf(Money::class);
})->with([
fn() => app(StripePaymentGateway::class),
fn() => app(PayPalPaymentGateway::class),
fn() => app(MockPaymentGateway::class),
]);
});
Test Organization
<?php
// Use descriptive test names
describe('User Registration', function () {
describe('when valid data is provided', function () {
it('creates a new user account', function () {
// Test implementation
});
it('sends a welcome email', function () {
// Test implementation
});
it('redirects to dashboard', function () {
// Test implementation
});
});
describe('when invalid data is provided', function () {
it('returns validation errors for missing fields', function () {
// Test implementation
});
it('returns validation error for duplicate email', function () {
// Test implementation
});
});
});
Using Data Providers
<?php
describe('Validation Tests', function () {
it('validates email formats', function (string $email, bool $shouldBeValid) {
$validator = Validator::make(['email' => $email], ['email' => 'email']);
expect($validator->passes())->toBe($shouldBeValid);
})->with([
['[email protected]', true],
['invalid-email', false],
['test@', false],
['@example.com', false],
['test@example', false],
]);
it('validates password strength', function (string $password, bool $shouldBeValid) {
$validator = Validator::make(['password' => $password], [
'password' => 'min:8|regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/'
]);
expect($validator->passes())->toBe($shouldBeValid);
})->with([
['Password123', true],
['password123', false], // No uppercase
['PASSWORD123', false], // No lowercase
['Password', false], // No numbers
['Pass1', false], // Too short
]);
});
Shared Test Data
<?php
// tests/Support/TestDataFactory.php
class TestDataFactory
{
public static function createEcommerceScenario(): array
{
$category = Category::factory()->create(['name' => 'Electronics']);
$product = Product::factory()->create([
'category_id' => $category->id,
'price' => 299.99,
'stock' => 50
]);
$user = User::factory()->create(['email_verified_at' => now()]);
return compact('category', 'product', 'user');
}
public static function createOrderWithItems(int $itemCount = 3): Order
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
OrderItem::factory()
->count($itemCount)
->create(['order_id' => $order->id]);
return $order;
}
}
// Using in tests
describe('Order Processing', function () {
it('processes order with multiple items', function () {
$order = TestDataFactory::createOrderWithItems(5);
expect($order->items)->toHaveCount(5);
expect($order->total)->toBeGreaterThan(0);
});
});
Test Helpers and Macros
<?php
// tests/Support/TestHelpers.php
trait TestHelpers
{
protected function authenticateUser(?User $user = null): User
{
$user = $user ?: User::factory()->create();
$this->actingAs($user);
return $user;
}
protected function authenticateAdmin(): User
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
protected function createProductWithStock(int $stock = 10): Product
{
return Product::factory()->create(['stock' => $stock]);
}
protected function assertDatabaseHasCount(string $table, int $count): void
{
$actual = DB::table($table)->count();
$this->assertEquals($count, $actual, "Expected {$count} records in {$table}, found {$actual}");
}
protected function assertJobWasDispatched(string $jobClass, ?callable $callback = null): void
{
Queue::assertPushed($jobClass, $callback);
}
}
// Using in base test class
// tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
use CreatesApplication, TestHelpers;
}
// tests/Feature/ProductTest.php
describe('Product Management', function () {
it('creates product successfully', function () {
$admin = $this->authenticateAdmin();
$product = $this->createProductWithStock(20);
expect($product->stock)->toBe(20);
$this->assertDatabaseHasCount('products', 1);
});
});
Environment-Specific Testing
<?php
// tests/Feature/EnvironmentTest.php
describe('Environment-specific Features', function () {
it('handles production-specific logic', function () {
app()->detectEnvironment(fn() => 'production');
$service = new App\Services\CacheService();
// In production, cache should be used
expect($service->shouldUseCache())->toBeTrue();
});
it('handles development-specific logic', function () {
app()->detectEnvironment(fn() => 'local');
$service = new App\Services\CacheService();
// In development, cache might be disabled
expect($service->shouldUseCache())->toBeFalse();
});
it('handles testing environment correctly', function () {
expect(app()->environment())->toBe('testing');
expect(config('app.debug'))->toBeTrue();
});
});
Testing Configuration
<?php
// tests/Feature/ConfigurationTest.php
describe('Application Configuration', function () {
it('has correct database configuration for testing', function () {
expect(config('database.default'))->toBe('sqlite');
expect(config('database.connections.sqlite.database'))->toBe(':memory:');
expect(config('database.connections.sqlite.foreign_key_constraints'))->toBeTrue();
});
it('has correct mail configuration for testing', function () {
expect(config('mail.default'))->toBe('array');
});
it('has correct queue configuration for testing', function () {
expect(config('queue.default'))->toBe('sync');
});
it('can override configuration in tests', function () {
config(['services.stripe.key' => 'test_key_123']);
expect(config('services.stripe.key'))->toBe('test_key_123');
});
});
Testing Middleware
<?php
// tests/Feature/MiddlewareTest.php
describe('Middleware', function () {
describe('Authentication Middleware', function () {
it('allows authenticated users', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/dashboard');
$response->assertOk();
});
it('redirects guests to login', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
});
describe('Admin Middleware', function () {
it('allows admin users', function () {
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->get('/admin/dashboard');
$response->assertOk();
});
it('denies non-admin users', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/admin/dashboard');
$response->assertStatus(403);
});
});
describe('Rate Limiting Middleware', function () {
it('allows requests within limit', function () {
for ($i = 0; $i < 10; $i++) {
$response = $this->get('/api/limited-endpoint');
$response->assertOk();
}
});
it('blocks requests exceeding limit', function () {
// Make 60 requests (assuming limit is 60 per minute)
for ($i = 0; $i < 61; $i++) {
$response = $this->get('/api/limited-endpoint');
}
$response->assertStatus(429); // Too Many Requests
});
});
});
Testing Validation Rules
<?php
// tests/Feature/ValidationTest.php
describe('Custom Validation Rules', function () {
it('validates custom rules correctly', function () {
$validator = Validator::make([
'username' => 'test_user_123'
], [
'username' => [new App\Rules\AlphanumericUnderscore]
]);
expect($validator->passes())->toBeTrue();
});
it('fails validation for invalid custom rules', function () {
$validator = Validator::make([
'username' => 'test-user-123'
], [
'username' => [new App\Rules\AlphanumericUnderscore]
]);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->first('username'))
->toContain('may only contain letters, numbers, and underscores');
});
});
Testing Artisan Commands
<?php
// tests/Feature/CommandTest.php
describe('Artisan Commands', function () {
it('runs cleanup command successfully', function () {
// Create some old data
User::factory()->create(['created_at' => now()->subDays(400)]);
User::factory()->create(['created_at' => now()->subDays(200)]);
$this->artisan('app:cleanup-old-users --days=365')
->expectsOutput('Cleaning up users older than 365 days...')
->expectsOutput('Deleted 1 old user(s)')
->assertExitCode(0);
expect(User::count())->toBe(1);
});
it('handles command options correctly', function () {
User::factory()->count(5)->create(['created_at' => now()->subDays(400)]);
$this->artisan('app:cleanup-old-users', [
'--days' => 365,
'--dry-run' => true
])
->expectsOutput('Dry run mode: Would delete 5 user(s)')
->assertExitCode(0);
// No users should be deleted in dry run
expect(User::count())->toBe(5);
});
it('validates command arguments', function () {
$this->artisan('app:cleanup-old-users --days=invalid')
->expectsOutput('Error: Days must be a valid number')
->assertExitCode(1);
});
});
Testing Observers and Events
<?php
// tests/Feature/ObserverTest.php
describe('Model Observers', function () {
it('triggers observer methods on model events', function () {
Event::fake();
$user = User::create([
'name' => 'John Doe',
'email' => '[email protected]',
'password' => bcrypt('password')
]);
Event::assertDispatched(Registered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
});
it('handles model deletion correctly', function () {
Mail::fake();
$user = User::factory()->create();
$user->delete();
Mail::assertSent(AccountDeletionNotification::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
});
});
Testing File Operations
<?php
// tests/Feature/FileOperationTest.php
describe('File Operations', function () {
it('processes CSV files correctly', function () {
Storage::fake('local');
$csvContent = "name,email\nJohn Doe,[email protected]\nJane Smith,[email protected]";
Storage::disk('local')->put('users.csv', $csvContent);
$importer = new App\Services\UserImporter();
$result = $importer->importFromCsv(storage_path('app/users.csv'));
expect($result['imported'])->toBe(2);
expect(User::count())->toBe(2);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
it('handles malformed CSV files', function () {
Storage::fake('local');
$malformedCsv = "name,email\nJohn Doe\nJane Smith,[email protected]";
Storage::disk('local')->put('malformed.csv', $malformedCsv);
$importer = new App\Services\UserImporter();
expect(fn() => $importer->importFromCsv(storage_path('app/malformed.csv')))
->toThrow(InvalidArgumentException::class);
});
});
Performance Testing Tips
<?php
// tests/Feature/PerformanceOptimizationTest.php
describe('Performance Optimizations', function () {
it('uses database indexes effectively', function () {
// Create test data
User::factory()->count(1000)->create();
DB::enableQueryLog();
// This query should use an index
$users = User::where('email', '[email protected]')->get();
$queries = DB::getQueryLog();
$lastQuery = end($queries);
// Check that the query execution time is reasonable
expect($lastQuery['time'])->toBeLessThan(50); // milliseconds
});
it('implements proper caching strategy', function () {
Cache::flush();
$service = new App\Services\StatisticsService();
// First call should hit the database
$startTime = microtime(true);
$stats1 = $service->getMonthlyStats();
$firstCallTime = (microtime(true) - $startTime) * 1000;
// Second call should hit the cache
$startTime = microtime(true);
$stats2 = $service->getMonthlyStats();
$secondCallTime = (microtime(true) - $startTime) * 1000;
expect($stats1)->toEqual($stats2);
expect($secondCallTime)->toBeLessThan($firstCallTime / 2);
});
});
Testing Error Handling
<?php
// tests/Feature/ErrorHandlingTest.php
describe('Error Handling', function () {
it('handles database connection errors gracefully', function () {
// Temporarily break database connection
config(['database.connections.sqlite.database' => '/nonexistent/path']);
DB::purge('sqlite');
$response = $this->get('/dashboard');
$response->assertStatus(500);
$response->assertSee('Service temporarily unavailable');
});
it('handles external API failures', function () {
Http::fake(['*' => Http::response([], 500)]);
$service = new App\Services\WeatherService();
expect(fn() => $service->getCurrentWeather('London'))
->toThrow(App\Exceptions\ExternalServiceException::class);
});
it('logs errors appropriately', function () {
Log::fake();
try {
throw new Exception('Test error for logging');
} catch (Exception $e) {
Log::error('Test error occurred', ['exception' => $e]);
}
Log::assertLogged('error', function ($message, $context) {
return str_contains($message, 'Test error occurred') &&
isset($context['exception']);
});
});
});
Conclusion
This comprehensive guide demonstrates professional-grade testing practices for Laravel 12 applications using Pest 4. The strategies outlined here are designed for senior developers who understand that testing is not just a development practice, but a fundamental aspect of software architecture.
Testing Architecture Principles
Advanced Testing Strategies
- Domain-Driven Testing: Structure tests around business domains, not technical layers
- Behavior-Driven Development: Write tests that describe user behavior and business outcomes
- Property-Based Testing: Use data generators to test edge cases automatically
- Mutation Testing: Verify test quality by introducing controlled bugs
- Performance Testing: Include performance assertions in feature tests
Best Practices
Code Quality Standards
- Test Naming: Use behavior-driven naming that describes business outcomes
- Test Structure: Follow the Given-When-Then pattern for clarity
- Mock Strategy: Mock at boundaries, not within your domain
- Data Management: Use factories with meaningful defaults and realistic data
- Error Testing: Test both happy paths and all failure scenarios
- Performance: Include performance assertions and memory usage checks
Advanced Patterns for Enterprise Applications
- Test Doubles: Use spies, stubs, and mocks appropriately for different scenarios
- Time Manipulation: Test time-dependent business logic comprehensively
- Concurrency Testing: Verify thread safety and race condition handling
- Database Transactions: Use appropriate isolation levels for different test types
- External Service Testing: Implement comprehensive contract testing
- Security Testing: Include authentication, authorization, and data protection tests
Key Tools and Technologies
Core Testing Framework
- Pest 4: Modern testing framework with enhanced assertion capabilities and browser testing
- Laravel Testing: Built-in testing utilities optimized for Laravel 12
- PHPUnit: Foundation testing framework with advanced features
Mocking and Stubbing
- Mockery: Advanced mocking capabilities for complex scenarios
- Laravel Fakes: Built-in fakes for Laravel services (Mail, Queue, Storage, etc.)
- HTTP Fakes: Comprehensive HTTP client testing
Advanced Testing Tools
- Parallel Testing: Laravel 12's built-in parallel test execution
- Browser Testing: Pest 4's Playwright integration for end-to-end testing
- Database Testing: In-memory databases and transaction rollbacks
- Performance Testing: Memory and execution time assertions
Measuring Success
Test Coverage Metrics
- Line Coverage: Minimum 80% for business logic, 90% for critical paths
- Branch Coverage: Minimum 70% for complex decision trees
- Mutation Score: Minimum 80% to ensure test quality
- Performance: All tests complete within acceptable time limits
Quality Indicators
- Test Execution Time: Feature tests complete in under 30 seconds
- Test Reliability: Less than 1% flaky test rate
- Bug Detection: Tests catch 95% of bugs before production
- Refactoring Safety: Confident refactoring with comprehensive test coverage
Final Thoughts
Remember:
- Tests are documentation that never goes out of date
- Testing drives better design by forcing you to think about interfaces and dependencies
- Comprehensive testing enables confident refactoring and continuous improvement
- Quality testing reduces technical debt and accelerates development velocity
Follow me on LinkedIn for more Laravel tips!
Would you like to learn more about Laravel tests? Let me know in the comments below!