Friday, 4:47 PM. I pushed what I thought was a simple refactor to production. By 5:03 PM, my phone was buzzing like a wasp trapped in a jar. Slack messages, PagerDuty alerts, and the dreaded call from my manager: "The entire checkout flow is down."
You know that feeling when your stomach drops through the floor? Yeah, that.
Turns out, I'd "improved" our Order model by renaming a method from calculateTotal()
to the more descriptive calculateOrderTotal()
. Seemed harmless enough. What I didn't know was that three different services were calling that method directly, and our payment webhook was expecting it to exist. No tests caught it because, well, we didn't have tests for "the entire universe of things that might depend on this method."
That weekend, fueled by shame and Red Bull, I wrote 247 tests. Today, three years later, our test suite has saved us from disasters approximately 1,847 times (I keep a tally on a sticky note). Let me share what I've learned about testing in Laravel, minus the panic attacks.
The Testing Trinity: Understanding Your Options
Think of Laravel testing like home security. Unit tests are your door locks - they protect individual pieces. Feature tests are your security system - they ensure everything works together. Integration tests are like having a security guard walk through your house - they verify the whole system operates correctly with external services.
Unit Tests: The Foundation Nobody Wants to Build
Unit tests are like flossing - everyone knows they should do it, but finding excuses is easier. "It's just a getter method!" "This logic is too simple to break!" Famous last words.
Here's a unit test that would've saved my Friday evening:
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Order;
use App\Models\OrderItem;
class OrderTest extends TestCase
{
public function test_calculate_total_sums_item_prices()
{
$order = new Order();
// Using a factory would be better, but let's keep it simple
$order->items = collect([
new OrderItem(['price' => 10.00, 'quantity' => 2]),
new OrderItem(['price' => 25.50, 'quantity' => 1]),
]);
$this->assertEquals(45.50, $order->calculateTotal());
}
public function test_calculate_total_applies_discount()
{
$order = new Order(['discount_percent' => 10]);
$order->items = collect([
new OrderItem(['price' => 100.00, 'quantity' => 1]),
]);
$this->assertEquals(90.00, $order->calculateTotal());
}
}
The beauty of unit tests? They run in milliseconds. I can run 500 unit tests in the time it takes to make coffee. They're your first line of defense against "small" changes that cascade into big problems.
Feature Tests: Where the Real Magic Happens
If unit tests are checking that your car's engine works, feature tests are checking that you can actually drive to work. They test entire workflows, hitting your routes, controllers, middleware, and database.
Remember my checkout disaster? Here's the feature test that now prevents it:
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
class CheckoutTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_complete_checkout()
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 99.99]);
$response = $this->actingAs($user)
->postJson('/api/cart/add', [
'product_id' => $product->id,
'quantity' => 2
]);
$response->assertStatus(200);
$checkoutResponse = $this->actingAs($user)
->postJson('/api/checkout', [
'payment_method' => 'credit_card',
'card_number' => '4242424242424242',
'exp_month' => 12,
'exp_year' => 2025,
'cvc' => '123'
]);
$checkoutResponse->assertStatus(200)
->assertJson([
'status' => 'success',
'total' => 199.98
]);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'total' => 199.98,
'status' => 'completed'
]);
}
}
This test caught three bugs last month alone. Three! Each one would've been a production fire. Instead, they were just failed CI builds that we fixed over coffee.
Integration Tests: The Full System Workout
Integration tests are where you stop pretending external services don't exist. They test how your application plays with others - payment gateways, email services, that sketchy third-party API your boss insisted on using.
Here's an integration test that's saved our bacon more than once:
namespace Tests\Integration;
use Tests\TestCase;
use App\Services\PaymentService;
use App\Services\InventoryService;
use App\Models\Order;
use Illuminate\Support\Facades\Http;
class PaymentProcessingTest extends TestCase
{
public function test_payment_processing_with_inventory_update()
{
// Mock external payment API
Http::fake([
'payments.stripe.com/*' => Http::response([
'status' => 'succeeded',
'id' => 'ch_1234567890'
], 200)
]);
// Mock inventory API
Http::fake([
'inventory.internal.api/*' => Http::response([
'status' => 'updated',
'remaining' => 5
], 200)
]);
$order = Order::factory()->create([
'total' => 150.00,
'status' => 'pending'
]);
$paymentService = app(PaymentService::class);
$result = $paymentService->processPayment($order);
$this->assertTrue($result->success);
$this->assertEquals('ch_1234567890', $result->transaction_id);
// Verify inventory was updated
$order->refresh();
$this->assertEquals('completed', $order->status);
$this->assertNotNull($order->paid_at);
}
}
Last month, Stripe changed their API response format slightly. Guess what caught it before it hit production? This test. It failed in CI, we updated our code, and our customers never knew there was almost a problem.
Common Testing Pitfalls (And How I've Hit Every Single One)
The "It Works on My Machine" Test
Early in my testing journey, I wrote this gem:
public function test_csv_export_works()
{
$response = $this->get('/export/users');
$this->assertFileExists('/Users/tom/Downloads/users.csv');
}
Shockingly, this failed on literally everyone else's machine. And on CI. And in production. Tests should never depend on your local environment.
The Time Bomb Test
public function test_subscription_expires_after_30_days()
{
$user = User::factory()->create([
'subscription_expires_at' => '2024-12-31'
]);
$this->assertTrue($user->hasActiveSubscription());
}
This test worked great! Until January 1st, 2025. Now I use Carbon's test helpers:
public function test_subscription_expires_after_30_days()
{
Carbon::setTestNow('2024-12-01');
$user = User::factory()->create([
'subscription_expires_at' => Carbon::now()->addDays(30)
]);
$this->assertTrue($user->hasActiveSubscription());
Carbon::setTestNow('2025-01-01');
$this->assertFalse($user->hasActiveSubscription());
}
The "Test Everything in One Giant Test" Anti-Pattern
I once wrote a 200-line test that tested user registration, email verification, profile updates, password resets, and account deletion. When it failed, the error message was about as helpful as "something went wrong somewhere."
Break. Your. Tests. Down. One test, one assertion (okay, maybe 2-3 related assertions). Your future self will thank you.
Real-World Testing Strategy That Actually Works
After three years and countless production incidents, here's my battle-tested approach:
1. Start with the scary stuff: What keeps you up at night? Payment processing? User authentication? Test those first.
2. Test the money path: Any code that touches revenue gets tested. Period. I once had a rounding error that cost us $3,000 over a weekend. Never again.
3. Use factories like your life depends on it:
// This is the way
$order = Order::factory()
->has(OrderItem::factory()->count(3))
->for(User::factory()->premium())
->create();
// Not this nightmare
$user = new User();
$user->name = 'Test User';
$user->email = 'test@example.com';
$user->type = 'premium';
// ... 20 more lines
4. Mock external services, but test the integration separately: Your unit tests shouldn't fail because AWS is having a bad day.
5. Make your tests tell a story:
public function test_user_cannot_purchase_out_of_stock_item()
{
$this->withoutExceptionHandling();
$shopper = User::factory()->create();
$popularItem = Product::factory()->create([
'stock' => 0,
'name' => 'PS5' // Too real?
]);
$response = $this->actingAs($shopper)
->postJson('/api/cart/add', [
'product_id' => $popularItem->id
]);
$response->assertStatus(422)
->assertJson([
'error' => 'Product is out of stock'
]);
}
The Test That Saved Christmas (Literally)
December 23rd, 2022. I was already in vacation mode, sipping eggnog and pretending to work. Then I pushed a "tiny CSS fix" that accidentally included a change to our pricing service. The unit tests caught it immediately:
FAIL Tests\Unit\PricingServiceTest
⨯ test_holiday_pricing_applies_correct_discount
Expected: 89.99
Actual: 8999.00
at tests/Unit/PricingServiceTest.php:42
Turns out I'd removed a decimal point conversion. Instead of charging $89.99, we would've charged $8,999.00. For every. Single. Order.
That test didn't just save Christmas - it saved my career.
Your Testing Action Plan
-
Today: Write one test. Just one. Test the thing that scared you most last week.
-
This Week: Add tests to your most critical path. For e-commerce, it's checkout. For SaaS, it's billing. You know what yours is.
-
This Month: Aim for 70% code coverage on new code. Not because coverage metrics matter, but because it forms the habit.
-
This Quarter: Set up CI/CD that refuses to deploy without passing tests. Make future you unable to repeat past you's mistakes.
The Bottom Line
Testing isn't about reaching 100% coverage or following textbook definitions. It's about sleeping better at night. It's about pushing to production on Friday afternoon without breaking into a cold sweat. It's about refactoring with confidence instead of fear.
Every test you write is a gift to future you. And trust me, future you needs all the help they can get.
Now if you'll excuse me, I need to write a test for the feature I just deployed. What? I said I learned my lesson, not that I'm perfect.
P.S. - That sticky note with my "tests saved us" tally? It's at 1,848 now. The test I'm about to write for today's deploy will make it 1,849. Because I'm not taking any chances with weekend deploys anymore.
Add Comment
No comments yet. Be the first to comment!