Why Bother Testing?
Writing tests feels like extra work until:
- You break something in production that tests would have caught
- You refactor and have no idea if you broke anything
- You come back to code after 3 months and forgot what it does
Tests are documentation that runs.
PHPUnit Basics
Create a test file:
// tests/Unit/CartTest.php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Cart;
class CartTest extends TestCase
{
public function test_empty_cart_has_zero_total(): void
{
$cart = new Cart();
$this->assertEquals(0, $cart->total());
}
public function test_cart_calculates_item_totals(): void
{
$cart = new Cart();
$cart->add(['name' => 'Widget', 'price' => 1000, 'quantity' => 2]);
$this->assertEquals(2000, $cart->total());
}
}
Run them:
php artisan test
# or
./vendor/bin/phpunit
Name Tests Like Documentation
Good test names tell you what broke without reading the code:
// Good - I know exactly what failed
public function test_user_cannot_checkout_with_empty_cart(): void
public function test_discount_code_applies_percentage_off(): void
public function test_shipping_is_free_for_orders_over_fifty(): void
// Bad - tells me nothing
public function testCheckout(): void
public function testDiscount(): void
Testing Laravel Controllers
Feature tests hit your actual routes:
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Models\Post;
class PostTest extends TestCase
{
public function test_guests_cannot_create_posts(): void
{
$response = $this->post('/posts', [
'title' => 'My Post',
'content' => 'Content here'
]);
$response->assertRedirect('/login');
}
public function test_users_can_create_posts(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'title' => 'My Post',
'content' => 'Content here'
]);
$response->assertRedirect('/posts');
$this->assertDatabaseHas('posts', [
'title' => 'My Post',
'user_id' => $user->id
]);
}
}
Test Validation
public function test_post_title_is_required(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'content' => 'Just content, no title'
]);
$response->assertSessionHasErrors('title');
}
Factories Make Test Data Easy
// database/factories/PostFactory.php
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'content' => fake()->paragraphs(3, true),
'user_id' => User::factory(),
];
}
public function published(): static
{
return $this->state(['published_at' => now()]);
}
}
Use them in tests:
$post = Post::factory()->create();
$publishedPosts = Post::factory()->published()->count(5)->create();
Mocking External Services
Don't hit real APIs in tests:
public function test_weather_widget_displays_temperature(): void
{
Http::fake([
'api.weather.com/*' => Http::response(['temp' => 72], 200)
]);
$response = $this->get('/dashboard');
$response->assertSee('72');
}
Laravel has fakes for Mail, Queue, Storage, etc:
public function test_welcome_email_is_sent(): void
{
Mail::fake();
$this->post('/register', [
'email' => 'test@example.com',
'password' => 'password123'
]);
Mail::assertSent(WelcomeEmail::class);
}
What to Actually Test
Test the important stuff:
- Business logic (calculations, state changes)
- Things that broke before
- Edge cases (empty inputs, big numbers, special characters)
- Security stuff (auth, permissions)
Skip testing:
- Simple getters/setters
- Framework code
- Third-party packages
Make Tests Part of Your Workflow
Run tests before committing:
# In your pre-commit hook or CI
php artisan test --parallel
Broken tests should block deployment. Period.
Start Small
You don't need 100% coverage. Start with:
- Tests for your most critical features
- A test for every bug you fix (so it doesn't come back)
- Tests for complex logic
Add more over time. Some tests are infinitely better than no tests.
