Unit Testing in PHP: How to Catch Bugs Before They Bite

Unit testing is one of the most powerful tools a developer can have. Yet many developers treat it as a chore: “Tests slow me down!” The reality is that skipping tests is like building a bridge without checking the supports—one small bug can cause a cascade of problems. In this article, we’ll explore what unit testing is, why it matters, and how to use it effectively for example in PHP, becasue with that I work most of the time. We’ll also discuss best practices, common pitfalls, and my personal workflow for building reliable software.

First Article from testing category you can find below: https://codecraftdiary.com/2025/10/03/why-writing-tests-early-saves-time-and-headaches/

Unit testing is the practice of testing the smallest units of your code in isolation—usually a single function, method, or class. Think of it as inspecting each LEGO block before building your castle. You wouldn’t wait until the castle collapses to find a faulty piece, and unit tests work the same way for your code.

Key points about unit tests:

  • Single responsibility: Each test should check one specific behavior.
  • Fast execution: Unit tests run quickly and provide immediate feedback.
  • Safe refactoring: When tests cover your code, you can make changes confidently.

In my workflow, I use unit tests extensively to cover services, helpers, and external library wrappers. These tests form the foundation of my codebase’s reliability.

Skipping unit tests is like playing Minesweeper blindfolded: you might get lucky, but eventually something will explode. Unit tests catch errors early, saving countless hours of debugging, angry emails, and coffee-fueled panic sessions.

Benefits of unit testing include:

  • Faster development: Fixing bugs early is often 10x faster than post-deployment.
  • Confidence in refactoring: You can safely improve or refactor functions knowing tests will catch regressions.
  • Code documentation: Tests show how a function is intended to behave.

A good unit test is:

  • Isolated: No reliance on external systems like databases or APIs.
  • Deterministic: It should return the same result every time.
  • Readable: Future developers should immediately understand the test’s purpose.

For more complex scenarios, use mock objects to replace external dependencies. This keeps tests fast and reliable.

Unit tests are essential, but they only verify isolated behavior. To test interactions between components, you need feature tests. For example, after building a service with unit tests, you might write feature tests to ensure the service integrates correctly with a database or an API.

In my workflow, unit tests run first and form a safety net for core logic, while feature tests run afterward. I aim to keep the total automated test suite runtime under ~10 minutes, even as the codebase grows. This balance ensures rapid feedback without slowing development.

Below is an example of a test run from the application I’m currently building using the Laravel/Vue stack. This is just a snapshot of the end of the tests, showing a total duration of 6.24s for the entire run, which is currently very good.

  • Start with core units: services, helpers, and small libraries.
  • Automate tests with CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins).
  • Keep tests fast; slow tests risk being ignored.
  • Use mock objects for external APIs and databases.
  • Aim for high coverage (~80–90%), but remember: coverage alone is not bulletproof.

Many companies advertise 100% test coverage, but often it’s mostly unit tests. While valuable, unit tests cannot catch every integration bug—this is why combining unit and feature tests is critical.

  • Testing too much at once – keep unit tests focused.
  • Ignoring failing tests – treat a failing test as a gift to fix early.
  • Overcomplicating test logic – clarity beats cleverness.
  • Relying solely on unit tests – supplement with feature and integration tests.

Sandbox link: https://onlinephp.io/c/0d7fb

This unit test demonstrates how to test a single service (DiscountService) in isolation. The tests are written in plain PHP, so they run directly in onlinephp.io without PHPUnit or any framework.

PHP
class DiscountService {
    public function calculate($price, $discount) {
        if ($discount < 0 || $discount > 100) {
            throw new InvalidArgumentException("Invalid discount");
        }
        return $price - ($price * $discount / 100);
    }
}
  • DiscountService is a small class with one method: calculate().
  • The method computes the discounted price ($price - ($price * $discount / 100)).
  • It also validates the discount: values below 0 or above 100 throw an exception (InvalidArgumentException).

PHP
function assertEqual($expected, $actual, $name) {
    if ($expected === $actual) {
        echo "✅ $name passed\n";
    } else {
        echo "❌ $name failed: expected '$expected', got '$actual'\n";
    }
}
  • We created a simple helper function assertEqual() to replace PHPUnit.
  • It compares the expected value $expected with the actual result $actual.
  • If the test passes → ✅, if it fails → ❌ + message showing what went wrong.

This is typical for a unit test – we check that one small piece of logic behaves correctly.


PHP
$service = new DiscountService();
assertEqual(90, $service->calculate(100, 10), "10% discount test");
assertEqual(0, $service->calculate(100, 100), "100% discount test");
  • We test that calculate() returns the correct price:
    • 100 with 10% discount → 90
    • 100 with 100% discount → 0
  • These tests are isolated, fast, and deterministic.

PHP
try {
    $service->calculate(100, 200);
    echo "❌ Invalid discount test failed\n";
} catch (InvalidArgumentException $e) {
    echo "✅ Invalid discount test passed\n";
}
  • Tests that invalid inputs throw an exception.
  • If no exception occurs → test fails (❌).
  • If exception occurs → test passes (✅).

This is a classic unit test case for boundary and invalid inputs – it ensures the method behaves correctly even in edge cases.

Unit testing is a developer superpower. It catches bugs early, reduces stress, and makes refactoring safe. However, it is only one part of a holistic testing strategy. Combining unit, feature, and integration tests ensures real reliability. Aim for high coverage, but remember: tests are a safety net, not a guarantee. Write tests early, automate, and code confidently—your future self and your users will thank you.

 

Leave a Reply

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