Skip to Content
Course content

43: Unit Testing with PHPUnit

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

I've spent the last hour writing a DiscountCalculator class for a client project. It's not a huge piece of logic, but it has a few rules: a percentage discount, a flat-rate discount, and a minimum spend requirement. Right now, my "testing process" looks like this:

<?php
require 'DiscountCalculator.php';

$calc = new DiscountCalculator();
var_dump($calc->apply('PERCENT', 100, 20)); // I expect 80
var_dump($calc->apply('FLAT', 100, 20));    // I expect 80
var_dump($calc->apply('PERCENT', 50, 20));  // I expect 50 (because min spend is 60)
?>

I'm staring at the terminal, manually checking if the output is correct. It works for now, but I'm terrified to touch this code in six months. If I change one line to optimize the logic, I have to manually run these var_dump statements again. There has to be a better way to prove the code works without me acting as a human calculator.

Moving beyond the var_dump

I'm going to pull in PHPUnit. If you haven't used it yet, it's the industry standard. First, I'll get it into the project via Composer. I use the --dev flag because I don't need the testing framework running on the actual production server.

composer require --dev phpunit/phpunit

Now, instead of a random script, I'll create a dedicated test file. PHPUnit looks for classes that extend TestCase and methods that start with the word test. Let's try to automate that first check: the percentage discount.

<?php
use PHPUnit\Framework\TestCase;

class DiscountCalculatorTest extends TestCase {
    public function testApplyPercentageDiscount() {
        $calc = new DiscountCalculator();
        $result = $calc->apply('PERCENT', 100, 20);
        
        $this->assertEquals(80, $result);
    }
}

I'll run it from the vendor folder: ./vendor/bin/phpunit DiscountCalculatorTest.php. I get a big green OK. It feels great, but it's a bit boring. The real value comes when things break.

Finding the edge cases

I started thinking: what happens if a user passes a negative discount? Or a negative total? My current code probably just does the math and returns a weird number. Let's see. I'll add a test for a negative discount.

public function testNegativeDiscountThrowsException() {
    $calc = new DiscountCalculator();
    // I expect this to fail or throw an error, but let's see what happens
    $result = $calc->apply('PERCENT', 100, -10);
    $this->assertEquals(110, $result); 
}

The test passes. Wait—it passes? That's actually a failure in my business logic. The calculator just added 10% to the price because I passed a negative number. My code is "correct" according to the test, but "wrong" according to the real world. This is exactly why we unit test; it forces me to define what "correct" actually means.

I should be throwing an InvalidArgumentException here. Let me adjust the test first to reflect how the code should behave, and then I'll fix the code. This is a common workflow: write the failing test, then write the code to make it pass.

public function testNegativeDiscountThrowsException() {
    $this->expectException(InvalidArgumentException::class);
    
    $calc = new DiscountCalculator();
    $calc->apply('PERCENT', 100, -10);
}

Now, when I run PHPUnit, it fails. It tells me Failed asserting that exception of type "InvalidArgumentException" is thrown. Now I can go into DiscountCalculator.php and actually add the validation logic to throw that exception. Once I save the file and run the test again... OK. The green light is back.

Testing the "Invisible" Rules

The last bit of logic is the minimum spend. The rule is: "If the total is under $60, no discount is applied." I've been testing the "happy path" (where it works), but I need to test the boundaries. What happens at exactly $59.99? What happens at exactly $60.00?

I'll use a dataProvider. I don't want to write five different methods that all do the same thing. I can just feed a list of scenarios into one test method.

public function discountDataProvider() {
    return [
        'below_min_spend' => [50, 20, 50],   // Total 50, Discount 20%, Result should stay 50
        'at_min_spend'    => [60, 20, 48],   // Total 60, Discount 20%, Result should be 48
        'above_min_spend' => [100, 20, 80],  // Total 100, Discount 20%, Result 80
    ];
}

/**
 * @dataProvider discountDataProvider
 */
public function testMinSpendLogic($total, $discount, $expected) {
    $calc = new DiscountCalculator();
    $this->assertEquals($expected, $calc->apply('PERCENT', $total, $discount));
}

This is where PHPUnit becomes powerful. Instead of one test, I now have three distinct scenarios running through the same logic. If I accidentally change the minimum spend to $70 in the code, the at_min_spend test will fail instantly, and I'll know exactly where the regression happened.




📋 Practical Task

Refactoring the TaxCalculator with PHPUnit

You have been handed a TaxCalculator class that calculates sales tax based on a region code. However, the current implementation is buggy and has no tests. Your goal is to bring it under control using PHPUnit.

The Requirements:

  • Region 'US' should apply 7% tax.
  • Region 'EU' should apply 20% tax.
  • Any unknown region should throw an UnexpectedValueException.
  • The calculator should not allow negative price inputs.

Your Task:

  1. Create a TaxCalculatorTest class.
  2. Write a test method using a dataProvider to verify the tax rates for 'US' and 'EU'.
  3. Write a test to ensure an UnexpectedValueException is thrown when an invalid region (e.g., 'JP') is provided.
  4. Write a test to ensure an InvalidArgumentException is thrown if a negative price is passed to the calculator.
  5. Run the tests, observe the failures, and modify the TaxCalculator logic until all tests pass.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.