A test suite that breaks every time you rename a method is not protecting you from anything. It is a second copy of the implementation, written in assertions, that must be maintained alongside the first. Teams in that situation eventually stop refactoring, or start deleting tests, and both outcomes are rational responses to a suite that costs more than it returns.
The distinction that fixes it is about what a test observes. A test that asserts on behaviour visible at a boundary — an HTTP response, a database row, a dispatched job — survives any internal restructuring that preserves that behaviour. A test that asserts a particular service called a particular method does not.
Test at the seam, not at the class
// Brittle: asserts HOW it works. Rename or restructure and this fails
// while the application remains entirely correct.
it('calls the calculator', function () {
$calc = Mockery::mock(PriceCalculator::class);
$calc->shouldReceive('calculate')->once()->andReturn(100);
app()->instance(PriceCalculator::class, $calc);
(new CheckoutService())->checkout($order);
});
// Durable: asserts WHAT happens. Survives any refactor that keeps the
// behaviour, fails for every change that breaks it.
it('charges the customer and records the order', function () {
Queue::fake();
$order = Order::factory()->for($this->tenant)->create(['total_minor' => 12_000]);
$this->actingAs($this->user)
->postJson("/api/orders/{$order->id}/checkout")
->assertOk()
->assertJsonPath('status', 'paid');
expect($order->refresh()->status)->toBe(OrderStatus::Paid);
$this->assertDatabaseHas('ledger_entries', ['order_id' => $order->id, 'amount_minor' => 12_000]);
Queue::assertPushed(SendReceipt::class);
});Mock at the edges of your system — the payment gateway, the third-party API, the mail transport — and use the real implementation for everything you own. Mocking your own classes couples the test to a structure you should be free to change, and it is the single most common reason Laravel test suites become an obstacle.
Make the database fast rather than avoiding it
The instinct to avoid database tests comes from slowness, and the slowness is almost always fixable. Wrapping each test in a transaction that rolls back is dramatically faster than migrating between tests. Seed reference data once per suite rather than per test. Use factories with explicit states so a test creates the three rows it needs, not a fixture file with two hundred.
With those in place, a few thousand tests hitting a real database run in a couple of minutes, and you get to test the thing that actually holds your data — including the constraints, cascades and defaults that an in-memory substitute quietly does not enforce.
Architecture tests are the highest-value ones you are not writing
// Structural rules become failing builds instead of review comments.
arch('domain code stays free of the framework')
->expect('App\\Domain')
->not->toUse(['Illuminate\\Http', 'Illuminate\\Database\\Eloquent']);
arch('controllers do not query directly')
->expect('App\\Http\\Controllers')
->not->toUse('Illuminate\\Support\\Facades\\DB');
arch('every command is final and readonly')
->expect('App\\Commands')->toBeFinal()->toBeReadonly();
arch('no debugging helpers ship to production')
->expect(['dd', 'dump', 'ray', 'var_dump'])->not->toBeUsed();- Cover the money paths and the permission paths thoroughly; cover getters and simple mappings not at all.
- Write one test per behaviour, named for the behaviour. A test called it_works tells the next reader nothing when it fails.
- Assert on outcomes — rows, responses, dispatched jobs, emitted events — not on interactions between your own objects.
- Treat a flaky test as broken and fix it the day it appears. Tolerated flakiness trains the team to ignore red builds.
- Add an authorization test for every endpoint: the request that should be refused is the one nobody writes and attackers find.
If a test fails when you rename a private method, it was never testing your application. It was testing your last set of naming choices.
The measure I trust for a suite's health is whether people refactor willingly. In codebases with boundary-level tests, engineers restructure freely because the suite tells them quickly whether behaviour changed. In codebases with heavily mocked unit tests, the same engineers work around bad structure rather than fixing it — not from laziness, but because the refactor requires rewriting forty tests that assert nothing a user would notice.