Your TestCase is the constitution of your test suite. Write it carefully.
Your TestCase file starts small. One trait. Two helper methods. A year passes. Now it's 400 lines of conflicting setup logic, and every new test inherits all of it.
What belongs in TestCase:
Global configuration that NEVER changes per test
Traits that EVERY test needs
Cleanup that's always required
What doesn't belong:
Setup for specific test types (put in sub-classes or traits)
Mocks for specific services
Business logic setup
The setUp() Anatomy
A well-structured setUp() method reads like a checklist of “things I don’t want to think about in every test.”
Disable External Dependencies
protected function setUp(): void
{
parent::setUp();
// No asset bundling in tests
$this->withoutVite();
// No rate limiting headaches
$this->withoutMiddleware(ThrottleRequests::class);
}Configure Test Defaults
// In setUp()
// Set default calculation modes
config(['app.calculation_mode' => CalculationMode::COMPOUND]);
// Add domain-specific Faker providers
$this->faker->addProvider(new NationalIdProvider($this->faker));
// Set safe defaults for throttling
config(['app.throttle_limit' => 1000]);Disable Observable Events
Model observers can send notifications, update related records, and call external services. None of that should happen unless you're explicitly testing it. The ignorable-observers package lets you suppress specific events while keeping others active:
// In setUp()
Application::ignoreObservableEvents(['saved', 'created', 'updated']);
Order::ignoreObservableEvents(['saved', 'updated']);
Customer::ignoreObservableEvents(['saved', 'created', 'updated']);
Product::ignoreObservableEvents(['saved', 'created', 'updated']);
Merchant::ignoreObservableEvents(['saved', 'updated']);
// ... 9+ models totalWhen you need to test observer behavior, temporarily re-enable with Model::unignoreObservableEvents(['created']).
Trait Organization
Traits are powerful. They're also easy to abuse. Here's how to use them well.
When to Use Traits
Concern is optional - Not every test needs it
Has its own lifecycle - Needs setUp/tearDown hooks
Reusable across test classes - Multiple tests share the same concern
Good candidates:
RefreshesSushiModels- Only tests using Sushi models with Config mocksAlternateRegionTrait- Only tests for alternate regionHasFormRequestAssertions- Only validation tests
Laravel's Automatic Hook Discovery
Laravel automatically discovers and calls methods named setUpXXX() and tearDownXXX() in traits:
The Sushi Problem
Sushi models cache their SQLite connections statically. When tests mock facades (especially the Application container), this cached connection can become corrupted:
Mockery\Exception\BadMethodCallException:
Received Mockery_0::connection(), but no expectations were specifiedYou mocked Config. Sushi's static connection is pointing to a Mockery ghost. The solution is an opt-in trait that resets the connection:
trait RefreshesSushiModels
{
protected function setUpRefreshesSushiModels(): void
{
$this->resetSushiConnection();
}
protected function tearDownRefreshesSushiModels(): void
{
$this->resetSushiConnection();
}
private function resetSushiConnection(): void
{
CachedOrderSummary::flushEventListeners();
$reflection = new ReflectionClass(CachedOrderSummary::class);
$property = $reflection->getProperty('sushiConnection');
$property->setAccessible(true);
$property->setValue(null, null);
CachedOrderSummary::clearBootedModels();
}
}When to Stay in TestCase
Some concerns are truly global:
DatabaseTransactions - Every test needs isolation
MockeryPHPUnitIntegration - Mockery cleanup is universal
WithFaker - Almost every test uses fake data
CreatesApplication - Laravel bootstrap
Authentication Patterns
Authentication setup is where many tests go wrong. Either they do too much (creating clients in every test) or too little (forgetting scopes).
Passport Client Lazy Initialization
Create clients only when needed:
public function createPassportClients(): void
{
if (Passport::client()->count() >= 5) {
return; // Already initialized
}
$this->artisan('create:personal-access-clients');
}Old pattern (slow):
// In TestCase setUp() - runs for EVERY test
$this->createPassportClients();New pattern (fast):
// Only in tests that need authentication
$this->createPassportClients();
$this->actingAsMerchantUser();
Convenience Authentication Helpers
protected function actingAsMerchantUser(): self
{
$merchant = Merchant::factory()->create();
$user = MerchantUser::factory()
->for($merchant)
->owner()
->create();
// Laravel's actingAs for guard selection
$this->actingAs($user, 'merchant');
// Passport for token scopes (if needed)
Passport::actingAs($user, ['orders:read']);
return $this;
}
protected function actingAsCustomer(): self
{
$customer = Customer::factory()->create();
// For API guard with Passport
$this->actingAs($customer, 'api');
Passport::actingAs($customer, []);
return $this;
}Multi-Guard Setup
// Admin API - guard + scopes
$this->actingAs($adminUser, 'api');
Passport::actingAs($adminUser, ['admin:full']);
// Mobile app - simple API access
$this->actingAs($customer, 'api');
Passport::actingAs($customer, []);
// Merchant portal - guard + specific scopes
$this->actingAs($merchantUser, 'merchant');
Passport::actingAs($merchantUser, ['orders:read', 'orders:write']);Service Faking Strategy
The Golden Rule
Fake BEFORE factory creation.
// CORRECT
Notification::fake();
Mail::fake();
$order = Order::factory()->create(); // afterCreating hooks won't send
// WRONG
$order = Order::factory()->create(); // afterCreating hooks already sent!
Notification::fake(); // Too lateCommon Fakes
Fake | Use For |
|---|---|
| External API calls |
| SMS, email, push notifications |
| Job dispatch assertions |
| Command bus assertions |
| Event dispatch assertions |
| File operations |
Partial Fakes
Sometimes you want SOME jobs to run, but not others:
// Only fake these jobs - let others run normally
Bus::fake([
SendNotificationJob::class,
UpdateSearchIndexJob::class,
]);This is better than faking everything and then wondering why your test doesn't work.
Environment & Locale Testing
Country-Specific Tests
For multi-country applications, tests must set the country explicitly:
trait AlternateRegionTrait
{
protected function setUp(): void
{
parent::setUp();
Config::set('app.country', Region::EU->value);
}
protected function tearDown(): void
{
Config::set('app.country', Region::DEFAULT->value); // Reset to default
parent::tearDown();
}
}Alternative: Set in Test
public function it_calculates_regional_tax(): void
{
Region::EU->setCurrent();
// Test region-specific behavior...
}What's Next
Missed the previous part? Part 4: The Teardown Tango covers tearDown ordering, file isolation, observable events, and assertion patterns.
Part 6: The Reference is the finale - everything from Parts 1-5, organized into checklists:
Time & Randomness checklist
Mocking checklist
Cleanup & Assertions checklist
The complete reference for code reviews and onboarding
See you there.
The Flaky Test Chronicles is a series documenting what we learned from 300+ commits of test suite cleanup. Your TestCase is the constitution of your test suite. Amend it wisely.