← Back to Developer Blog
💻 DeveloperMarch 4, 20269 min read

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.

By Raspib Technology Team

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)

  1. Payment logic - Money is involved
  2. Authentication - Security critical
  3. Data validation - Prevents bad data
  4. Business rules - Core functionality
  5. 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

Testing Strategies Guide - Write Tests That Catch Bugs