Testing: Write Tests That Actually Catch Bugs
Most tests are useless. They pass when code breaks and fail when code works. Here's how to write tests that matter.
Table of Contents
- The Problem With Most Tests
- Tests That Actually Matter
- What to Test
- 1. Business Logic
- 2. Edge Cases
- 3. Security
- Unit vs Integration Tests
- Unit Tests: Test One Thing
- Integration Tests: Test Everything Together
- Real Project Example
- Testing Strategy
- What to Test
- What to Skip
- Common Mistakes
- Mistake 1: Testing Implementation, Not Behavior
- Mistake 2: Brittle Tests
- Mistake 3: No Database Cleanup
- Running Tests
- Test-Driven Development
- When to Write Tests
- Bottom Line
Testing: Write Tests That Actually Catch Bugs
You write tests. They all pass. Code still breaks in production.
Sound familiar?
Here's how to write tests that actually catch bugs.
The Problem With Most Tests
// Useless test
public function test_user_has_name()
{
$user = new User();
$user->name = 'John';
$this->assertEquals('John', $user->name);
}
This tests... that variables work? Congrats, PHP works.
Tests That Actually Matter
// Useful test
public function test_user_cannot_place_order_without_payment_method()
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/orders', [
'product_id' => 1,
'quantity' => 2,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors('payment_method');
$this->assertDatabaseMissing('orders', ['user_id' => $user->id]);
}
This tests actual business logic. If it passes, the feature works.
What to Test
1. Business Logic
public function test_order_total_calculates_correctly()
{
$order = Order::factory()->create();
$order->items()->create(['price' => 1000, 'quantity' => 2]);
$order->items()->create(['price' => 500, 'quantity' => 3]);
// 2000 + 1500 = 3500
$this->assertEquals(3500, $order->calculateTotal());
}
2. Edge Cases
public function test_order_with_zero_quantity_fails()
{
$response = $this->post('/api/orders', [
'product_id' => 1,
'quantity' => 0, // Edge case
]);
$response->assertStatus(422);
}
public function test_order_with_negative_quantity_fails()
{
$response = $this->post('/api/orders', [
'product_id' => 1,
'quantity' => -5, // Edge case
]);
$response->assertStatus(422);
}
3. Security
public function test_user_cannot_view_other_users_orders()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user2->id]);
$response = $this->actingAs($user1)->get("/api/orders/{$order->id}");
$response->assertStatus(403);
}
Unit vs Integration Tests
Unit Tests: Test One Thing
// Test a single method
public function test_discount_calculates_correctly()
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(1000, 10); // 10% off 1000
$this->assertEquals(900, $result);
}
Fast. Isolated. Tests logic only.
Integration Tests: Test Everything Together
// Test entire flow
public function test_user_can_complete_purchase()
{
$user = User::factory()->create(['balance' => 5000]);
$product = Product::factory()->create(['price' => 1000]);
$response = $this->actingAs($user)->post('/api/purchase', [
'product_id' => $product->id,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'product_id' => $product->id,
]);
$this->assertEquals(4000, $user->fresh()->balance);
}
Slower. Tests database, controllers, models together. Catches integration bugs.
Real Project Example
Client: E-commerce API
Problem: Bugs in production every week
Before tests:
- Bugs per week: 8-12
- Time fixing bugs: 15 hours/week
- Customer complaints: Daily
After adding tests:
- Test coverage: 75%
- Bugs per week: 1-2
- Time fixing bugs: 3 hours/week
- Customer complaints: Rare
Tests written: 247
Time invested: 40 hours
Time saved per month: 48 hours
Worth it.
Testing Strategy
What to Test (Priority Order)
- Payment logic - Money is involved
- Authentication - Security critical
- Data validation - Prevents bad data
- Business rules - Core functionality
- Edge cases - Where bugs hide
What to Skip
- Getters/setters (waste of time)
- Framework features (Laravel already tested)
- Third-party packages (not your code)
Common Mistakes
Mistake 1: Testing Implementation, Not Behavior
// Bad: Tests how it works
public function test_user_repository_calls_database()
{
$repo = new UserRepository();
$repo->shouldReceive('query')->once();
$repo->getUsers();
}
// Good: Tests what it does
public function test_get_users_returns_active_users_only()
{
User::factory()->create(['status' => 'active']);
User::factory()->create(['status' => 'inactive']);
$users = (new UserRepository())->getUsers();
$this->assertCount(1, $users);
$this->assertEquals('active', $users[0]->status);
}
Mistake 2: Brittle Tests
// Bad: Breaks when you add fields
$this->assertEquals([
'id' => 1,
'name' => 'John',
'email' => 'john@example.com',
'created_at' => '2026-03-04',
], $response->json());
// Good: Test what matters
$response->assertJsonStructure(['id', 'name', 'email']);
$this->assertEquals('John', $response->json('name'));
Mistake 3: No Database Cleanup
// Bad: Tests affect each other
public function test_create_user()
{
User::create(['email' => 'test@example.com']);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
// Good: Clean database between tests
use RefreshDatabase;
public function test_create_user()
{
User::create(['email' => 'test@example.com']);
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
}
Running Tests
# All tests
php artisan test
# Specific test
php artisan test --filter test_user_can_login
# With coverage
php artisan test --coverage
# Parallel (faster)
php artisan test --parallel
Test-Driven Development (TDD)
Write test first, then code:
// 1. Write failing test
public function test_user_can_reset_password()
{
$user = User::factory()->create();
$response = $this->post('/api/password/reset', [
'email' => $user->email,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('password_resets', ['email' => $user->email]);
}
// 2. Run test (fails)
// 3. Write code to make it pass
// 4. Refactor
Pros:
- Forces you to think about requirements
- Code is testable by design
- Catches bugs before they exist
Cons:
- Slower initially
- Requires discipline
Our take: Use TDD for critical features (payments, auth). Skip for simple CRUD.
When to Write Tests
Always test:
- Payment processing
- Authentication
- Data validation
- Business calculations
- Security features
Sometimes test:
- CRUD operations
- Simple queries
- UI components
Skip testing:
- Prototypes
- Throwaway code
- Framework features
Bottom Line
Tests aren't about coverage percentage. They're about catching bugs.
Write tests for code that matters. Skip tests for trivial stuff.
Good tests save time. Bad tests waste time.
Need help with testing?
We write tests for Nigerian development teams. Test strategies, CI/CD setup, quality assurance.
📞 WhatsApp: +234 708 711 0468
📧 info@raspibtech.com
📍 Lagos Island
Related:
Need Help with Your Project?
Let's discuss how Raspib Technology can help transform your business
Related Articles
Laravel 11: What Changed and Why You Should Care
Laravel 11 is out. Slimmer structure, better performance, and features that actually save time. Here's what matters.
Read more →Laravel 12: The Upgrade You've Been Waiting For
Laravel 12 brings major improvements. Here's what changed and why it matters for your projects.
Read more →Next.js 15: The Features That Actually Matter
Next.js 15 changed a lot. Here's what affects your projects, what breaks, and when to upgrade.
Read more →