Skip to Content
Course content

69: Test-Driven Development in PHP

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you've probably heard that Test-Driven Development (TDD) is the "correct" way to write professional software. In reality, the first time you try it, it feels like you're trying to build a house by staring at a blueprint and pretending the walls exist. It feels backwards. But once it clicks, you'll realize it's not about testing—it's about design.

I get that TDD is "test first," but how does that actually work if I haven't written any logic yet?

It feels like a paradox, right? You're writing a test for a function that doesn't even exist. That's exactly the point. This is the "Red" phase of the Red-Green-Refactor cycle. You define the behavior you want before you worry about the implementation.

Let's say we're building a DiscountCalculator. I want it to take a total price and a discount percentage and return the new price. Instead of writing the class, I write the test first:

public function testItCalculatesTenPercentDiscount()
{
    $calculator = new DiscountCalculator();
    $result = $calculator->apply(100, 10); 
    
    $this->assertEquals(90, $result);
}

When you run this, PHPUnit will scream at you because DiscountCalculator doesn't exist. That's your "Red" light. Now, you write the absolute minimum amount of code to make that test pass. Not a line more. I don't care if the code is ugly; I just want it green.

class DiscountCalculator 
{
    public function apply($total, $percent) 
    {
        return $total - ($total * ($percent / 100));
    }
}

Now it's "Green." You've proven the core requirement works. Now you move to "Refactor," where you clean up the code, improve naming, or optimize, knowing that if you break something, the test will immediately catch you.

Do I really need to write a test for every single line of code? That seems like it would double my workload.

This is the most common complaint I hear from juniors. Here is the secret: TDD isn't about 100% code coverage; it's about 100% behavior coverage. You aren't testing lines of code; you're testing requirements.

If you have a simple getter or setter, don't waste your time testing it. But if you have a complex conditional—like "if the user is a VIP and it's Tuesday, give them 20% off, unless the item is already on sale"—that's where TDD shines. I've spent entire days debugging a single "edge case" that a 5-line test would have caught in milliseconds. The "extra" time you spend writing tests upfront is actually just borrowing time from the "frantically debugging at 2 AM" phase of the project.

What happens when my class needs a database or an API? I can't boot up a whole server just for a unit test.

If your test needs a database to run, you're not writing a unit test; you're writing an integration test. In TDD, we want our unit tests to be lightning-fast. To do that, we use Dependency Injection and Mocks.

Imagine our DiscountCalculator needs to check a database to see if a coupon code is valid. Don't pass the database connection into the calculator. Instead, pass an interface.

class DiscountCalculator 
{
    private $couponRepo;

    public function __construct(CouponRepositoryInterface $repo) 
    {
        $this->couponRepo = $repo;
    }

    public function apply($total, $code) 
    {
        if (!$this->couponRepo->isValid($code)) {
            return $total;
        }
        // apply discount logic...
    }
}

In your test, you don't use the real MySQLCouponRepository. You create a "Mock"—a fake object that mimics the interface and returns exactly what you tell it to. This isolates your logic from the infrastructure. If the test fails, you know the bug is in your calculator, not because the database server is down.

When do I actually do the "refactor" part? I usually just stop once the test turns green.

I'll be honest: when I first started, I did the same thing. I'd get the test to pass and move on. But "Green" code is often "quick and dirty" code. TDD gives you a safety net that allows you to be aggressive with your refactoring.

After the test passes, look at your code. Are the variable names vague? Is there a magic number that should be a constant? Is the method too long? Because you have a passing test, you can rewrite the entire internals of that method. If you finish and the test is still green, you've successfully improved the design without changing the behavior. That's the magic of TDD—it separates the "making it work" phase from the "making it right" phase.




📋 Practical Task

Build a Tiered Membership Pricing Engine

Your goal is to build a PricingEngine class using a strict TDD workflow. You must write the tests first, run them to see them fail, and then implement the minimum code necessary to make them pass.

Requirements:

  • The engine should have a method calculatePrice(float $basePrice, string $membershipLevel).
  • Bronze members get a 5% discount.
  • Silver members get a 10% discount.
  • Gold members get a 20% discount.
  • Any other membership level (or an empty string) should result in the original base price (no discount).

Your Workflow:

  1. Write a test for the Bronze discount $\rightarrow$ Run it (Fail) $\rightarrow$ Implement logic (Pass).
  2. Write a test for the Silver discount $\rightarrow$ Run it (Fail) $\rightarrow$ Implement logic (Pass).
  3. Write a test for the Gold discount $\rightarrow$ Run it (Fail) $\rightarrow$ Implement logic (Pass).
  4. Write a test for an Invalid/Guest level $\rightarrow$ Run it (Fail) $\rightarrow$ Implement logic (Pass).
  5. Refactor your calculatePrice method to remove any repetitive logic or use a lookup table/array instead of multiple if statements.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.