The Most Common Test Smells in PHP (And How to Fix Them)

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/


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');
}
PHP

When 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


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');
}
PHP

Where 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');
}
PHP

Now 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


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');
}
PHP

Everything 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');
}
PHP

Your tests should protect business behavior, not formatting details that change every sprint.

Run on OnlinePHP.io

Try It Yourself: https://onlinephp.io/c/78fae


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');
}
PHP

Repeat 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.


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.


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');
}
PHP

Six 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.


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');
}
PHP

Now 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');
}
PHP

Simple tests communicate intent much better than clever ones.


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');
}
PHP
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');
}
PHP

A short PHP example demonstrating this test smell.

Run on OnlinePHP.io

Try It Yourself: https://onlinephp.io/c/b6f80


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');
}
PHP

The 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.


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.


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.

Leave a Reply

Your email address will not be published. Required fields are marked *