Your test suite passes.
CI is green.
Everyone merges their pull requests with confidence.
Then a seemingly harmless refactor breaks production.
If that sounds familiar, the problem isn’t always a lack of tests. Sometimes it’s the quality of those tests.
Just like production code can suffer from code smells, tests can accumulate test smells—patterns that make tests harder to understand, slower to maintain, or less effective at catching real bugs. A passing test suite isn’t automatically a trustworthy one.
After reviewing countless PHP projects over the years, I’ve noticed the same patterns appearing again and again. Whether you’re using PHPUnit, Pest, Laravel, Symfony, or another PHP framework, these smells are surprisingly common.
Let’s look at the ones worth eliminating first.
Last article in this Testing category you can find here: https://codecraftdiary.com/2026/07/13/testing-laravel-events-guide/
1. Assertion Roulette
One of the easiest ways to make a test frustrating is filling it with dozens of unrelated assertions.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPWhen this test fails, the failure message rarely tells the whole story. Another developer has to inspect multiple assertions before understanding what actually broke.
A better approach is giving each test a single responsibility.
Instead of verifying five unrelated things, split them into smaller tests with descriptive names.
Small tests are easier to understand, easier to debug, and much harder to accidentally break.
Run on OnlinePHP.io
Try It Yourself: https://onlinephp.io/c/927b9
2. Mystery Guest
A test should be completely understandable on its own.
Unfortunately, many tests quietly depend on hidden data.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPWhere did user ID 1 come from?
Was it seeded?
Created by another test?
Imported from a dump?
Nobody knows.
This hidden dependency is known as the Mystery Guest smell.
Modern PHP testing tools make this easy to avoid.
Instead of relying on unknown database state, create exactly what your test needs.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPNow every developer—and every CI pipeline—knows exactly where that user came from.
Run on OnlinePHP.io
Try It Yourself:https://onlinephp.io/c/42b4d
3. Fragile Assertions
Many tests verify far more than they actually need.
Imagine testing an API response.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPEverything looks fine until another developer adds a harmless field like avatar_url.
Nothing is broken.
Your test still fails.
That’s not a useful failure—it’s unnecessary maintenance.
Instead, assert the behavior that actually matters.
For example, in Laravel:
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPYour tests should protect business behavior, not formatting details that change every sprint.
Run on OnlinePHP.io
Try It Yourself: https://onlinephp.io/c/78fae
4. Duplicate Setup Everywhere
Open a large test suite and sometimes every test starts with exactly the same setup.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPRepeat that fifty times and your tests become noisy.
When setup dominates the test, readers spend more time understanding the preparation than the actual behavior being tested.
Extract common setup into helper methods, builders, or reusable factory states.
The goal isn’t fewer lines of code.
The goal is making the important part of the test immediately obvious.
5. Testing Implementation Instead of Behavior
This is probably the most expensive mistake teams make.
Imagine changing an internal algorithm.
The application still behaves exactly the same.
Users notice nothing.
Yet twenty tests suddenly fail.
Why?
Because they were testing implementation details rather than observable behavior.
A good test should answer:
“What should the application do?”
Not:
“How is the application currently doing it?”
Implementation changes happen constantly.
Business behavior should remain stable.
The closer your tests are to user-visible behavior, the more valuable they’ll become during refactoring.
6. Hardcoded IDs and Magic Values
Hardcoded values have a habit of surviving far longer than they should.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPSix months later, someone changes the seed data.
Now the test mysteriously fails.
Even worse, nobody remembers why the number 42 was special in the first place.
Prefer meaningful constants, enums, or freshly created models.
Your future self will thank you.
7. Logic Inside Tests
Tests should explain behavior.
They shouldn’t contain business logic themselves.
For example:
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPNow your test contains conditional logic that needs to be understood before anyone can even interpret the assertion.
Instead, create separate test cases.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPSimple tests communicate intent much better than clever ones.
8. Slow Tests That Don’t Need to Be Slow
A slow test suite is one of the fastest ways to discourage developers from running tests regularly. If executing your test suite takes ten or fifteen minutes, people naturally start skipping it during development and rely solely on CI. That’s when bugs begin slipping through.
The good news is that PHP itself is rarely the bottleneck. More often, the slowdown comes from tests doing work they simply don’t need to do. Calling real third-party APIs, waiting with sleep(), rebuilding large datasets before every test, or repeating expensive setup hundreds of times can quickly turn a fast suite into a painful one.
Modern testing frameworks provide better alternatives for nearly all of these scenarios. Laravel, for example, allows you to fake queues, notifications, mail, events, and storage without sacrificing confidence in your application. Time-dependent code can be tested by freezing or traveling through time instead of waiting in real time, and external services should almost always be replaced with controlled test doubles rather than actual network requests.
The goal isn’t to make every individual test as fast as possible. It’s to eliminate unnecessary work while still testing real application behavior. A test that finishes in 50 milliseconds instead of 5 seconds might not seem important on its own, but multiply that by thousands of tests running on every pull request, and the difference becomes significant.
Fast feedback encourages developers to run tests frequently. And the more often your tests are executed, the more valuable they become.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPpublic function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPA short PHP example demonstrating this test smell.
Run on OnlinePHP.io
Try It Yourself: https://onlinephp.io/c/b6f80
9. Giant Tests That Verify Everything
Almost every long-lived project has at least one of these tests. You open the file expecting to understand a single piece of functionality, only to find a test that’s hundreds of lines long. It creates users, seeds multiple database records, processes orders, sends emails, dispatches events, updates the cache, writes logs, and finishes with a long list of assertions. At first glance, it feels comprehensive. In reality, it’s trying to verify far too many responsibilities at once.
public function test_complete_checkout_process(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$response = $this->postJson('/api/orders', [
'product_id' => $product->id,
]);
$response->assertCreated();
Mail::assertSent(OrderConfirmationMail::class);
Event::assertDispatched(OrderCreated::class);
Queue::assertPushed(ProcessInvoice::class);
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
]);
$this->assertDatabaseHas('payments', [
'status' => 'completed',
]);
Cache::has("user:{$user->id}:orders");
Log::shouldHaveReceived('info');
}PHPThe biggest problem with these “all-in-one” tests is that they become extremely fragile over time. When they fail, it’s often unclear what actually broke. Did the email notification change? Was the database state incorrect? Did an event stop dispatching? Or did a completely unrelated refactor accidentally affect one small part of the workflow? Instead of immediately fixing the issue, developers often spend more time investigating the failure than solving the real problem.
A better approach is to split large scenarios into smaller, focused tests that each verify a single aspect of the workflow. One test can ensure an order is created successfully, another can verify that the correct event is dispatched, and a third can confirm that a notification is sent. Together, these tests provide the same level of confidence while remaining significantly easier to understand, maintain, and debug.
Remember that a test suite is also documentation. When someone new joins the project, they should be able to read your tests and quickly understand how the application behaves. That’s almost impossible when one massive test tries to describe an entire business process. Smaller, focused tests produce clearer failure messages, encourage better design, and make future refactoring much less risky.
10. Tests Nobody Trusts
This is the smell that eventually destroys an entire test suite.
Developers start saying things like:
“Oh, just rerun the pipeline.”
“That test always fails randomly.”
“Ignore that one.”
The moment your team stops trusting the tests, they stop providing value.
Random failures, hidden dependencies, flaky assertions, and inconsistent environments slowly erode confidence until developers treat the test suite as background noise.
A reliable test suite doesn’t have to be enormous.
It has to be predictable.
If a test fails, developers should immediately believe something is actually wrong.
That’s the standard every test suite should aim for.
Final Thoughts
Writing tests is only half the job.
Writing tests that remain useful six months later is where the real challenge begins.
Whenever you create a new test, ask yourself a few simple questions:
- Does this test verify behavior instead of implementation?
- Would another developer understand it without extra context?
- Can it run independently?
- Will it still be valuable after a refactor?
- If this test fails in CI tomorrow, would I trust the failure?
A healthy test suite isn’t measured by the number of tests it contains or by achieving 100% code coverage.
It’s measured by the confidence it gives your team when making changes.
Because the best tests aren’t the ones that simply pass—they’re the ones that catch the bugs that matter.

