How to write unit tests in PHP

Writing unit tests is all about writing down the behavior of the code that we want to test. Once the unit test is written it gets locked and the actual code should fulfill these steps of behaviors in the specified order. Writing unit tests makes the developer stick to what is instructed in the test script. If anything other than the expected behavior mentioned in the test script is found in the actual code block then the test fails.

We can say that writing unit tests is constraining the developer to write just what is expected by the test script. And this is called Test Driven Development (TDD). Writing unit tests helps us in refactoring our code a lot and hence we develop robust and clean code.

Understanding what is code behavior

We can explain the code behavior something like the steps that we may draw if we have to explain the internal working of the code block using a flowchart. Let’s say we want to write a test for a function that calculates tax for the customer’s cart. We can explain the behavior of this function in something like the below flowchart diagram.

Flowchart for tax calculation

List out test cases by looking into the flowchart

Let’s list some of the initial test cases first.

  • should return zero if the customer is tax exempted
  • should throw an exception if the region id is not found in the tax rates list
  • should return zero if all items in the cart are nontaxable product
  • should return some value that is greater than zero if the cart has some taxable product

Let’s check the code for the above flowchart diagram

This is the initial code written for the very first time by looking into the flowchart and a little bit of thinking. We will exactly look for the “Cart::calculateTax” method in the below code block.

PHP
<?php

namespace Technicallysound\Blog;

class Cart
{
    private $items = [];
    private TaxRates $taxRates;
    private Customer $customer;

    private float $subtotal = 0.0;
    private float $taxableSubtotal = 0.0;

    private float $taxRate = 0.0;

    public function __construct(
        Customer $customer
    ) {
        $this->customer = $customer;
        $this->taxRates = new TaxRates();
    }

    public function addProduct(Product $product): Cart
    {
        if (!array_key_exists($product->getId(), $this->items)) {
            $this->items[$product->getId()] = [
                'product' => $product,
                'qty' => 1
            ];
        } else {
            $this->items[$product->getId()]['qty'] += 1;
        }
        return $this;
    }

    private function getTaxableSubtotal(): float
    {
        foreach ($this->items as $item) {
            /** @var $product Product */
            $product = $item['product'];
            // Is the product taxable?
            if ($product->isTaxable()) {
                $this->taxableSubtotal += ($product->getPrice() * $item['qty']);
            }
            $this->subtotal += ($product->getPrice() * $item['qty']);
        }
        return $this->taxableSubtotal;
    }

    public function calculateTax(): float
    {
        // check if customer is exempted from tax
        if ($this->customer->getIsTaxExempted()) {
            return 0.0; // return 0 and terminate
        } else {
            // get customer address and region id from the address
            $regionId = $this->customer->getAddress()->getRegionId();
            // get tax rate for the region id
            $this->taxRate = $this->taxRates->getTaxRate($regionId);
            // Get taxable subtotal
            $taxableSubtotal = $this->getTaxableSubtotal();
            // then calculate tax
            $tax = ($taxableSubtotal * $this->taxRate) / 100;
            return $tax;
        }
    }
}

Let’s write the test script for our first test case

The test case is “calculate tax function should return zero if the customer is tax exempted“.

PHP
public function testCalculateTaxReturnsZeroForExemptedCustomers(): void
{
    $customer = $this->getMockBuilder(Blog\Customer::class)
        ->onlyMethods(['getIsTaxExempted'])
        ->getMock();
    // our first assertion in this test
    // Customer::getIsTaxExempted should be called once during calculateTax process
    $customer->expects($this->once())
        ->method('getIsTaxExempted')
        ->willReturn(true); // forcefully making "getIsTaxExempted" to return true

    $cart = new Blog\Cart($customer);

    $result = $cart->calculateTax();
    // the second assertion 
    // resulting tax value is equal to zero
    $this->assertEquals($result, 0);
}

We need a customer object that is tax exempted. This customer object is our input for the cart class and we need to somehow pass this customer object to the cart class. And we are passing it via the cart constructor method.

Here we are creating the customer object that forcibly returns true for the “is tax exempted” method by utilizing the mocking feature of unit testing, so that we can make sure of the thing that the customer we passed to the cart class is tax exempted. And hence the “calculate tax” function should return zero. And that is the test we want to achieve.

Here we have two assertions in our first test function.

  • The “getIsTaxExempted” method should be called once on the customer object.
  • The resulting tax value should be equal to zero.
ShellScript
bash-5.1$ php vendor/bin/phpunit
PHPUnit 9.5.27 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.10
Configuration: /app/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.951, Memory: 6.00 MB

OK (1 test, 2 assertions)
bash-5.1$ 

Let’s write the second test case which is “Calculate tax method should throw an exception if no tax rate is found for the given region id“.

PHP
public function testCalculateTaxThrowsExceptionIfNoTaxRateIsFoundForTheGivenRegionId(): void
{
    $customer = $this->getMockBuilder(Blog\Customer::class)
        ->onlyMethods(['getIsTaxExempted', 'getAddress'])
        ->getMock();
    /*
    our first assertion in this test
    Customer::getIsTaxExempted should be called once during calculateTax process
    */
    $customer->expects($this->once())
        ->method('getIsTaxExempted')
        ->willReturn(false); // forcefully making "getIsTaxExempted" to return false

    /*
    the third assertion in this test
    we should get exception of the specific type "Exception" 
    as no tax rate is found for the region id
    */
    $this->expectException(\Exception::class);
    /*
    the fourth assertion in this test
    checking the exact exception message
    */
    $this->expectExceptionMessage("No tax rate found for the specified region id!");

    $cart = new Blog\Cart($customer);

    $cart->calculateTax();
}

In this test case, we want our code to be covered beyond the “Is customer tax exempted?” section, so we forcefully set the “getIsTaxExempted” method to return false on the customer object by using the mocking feature of PHPUnit. We mocked the customer object and did not set the address on it so that when the “getAddress” method will be called on the customer object it will return one mocked address object where all properties will be in the not initialized state. So the region id will be null and that should raise an exception with a specific message. And that is the test we want to achieve with our second test.

We can also set the address object on the customer object if we want to avoid an automated mock object and to make sure that we are testing with a real region id that we are sure does not exist in the tax rates list.

PHP
$address = $this->getMockBuilder(Blog\Address::class)
    ->getMock();
$address->setRegionId(0); // for sure we know that this region id does not exist in taxrates list
$customer->expects($this->once())
    ->method('getAddress')
    ->willReturn($address);

The code can be found here https://github.com/rajeebsaraswati/technicallysound-unit-testing in case you want to have a look into it.

2 thoughts on “How to write unit tests in PHP”

  1. I was reϲоmmended this blog by my cousin. I am
    not sure whether tһis post is writtеn by him as no one else know such Ԁetailed about my problem.
    You are wonderful! Thanks!

    Reply

Leave a Comment

Share via
Copy link
Powered by Social Snap