Chapter 13. Testing: Ensuring Your Program Does the Right Thing

How do you know your program does what you think it does? Even with your careful attention to detail, are you sure that sales tax calculation function works properly? How do you know?

This chapter is about giving you the peace of mind that comes with answers to those questions. Unit testing is a way of making assertions about small bits of your code—“If I put these values into this function, I should get this other value out.” By creating tests that check the behavior of your code in appropriate situations, you can have confidence in how your program behaves.

PHPUnit is the de facto standard for writing tests for PHP code. Your tests are themselves little bits of PHP code. The following section describes how to install PHPUnit. “Writing a Test” shows some code and a first test for it. Use and run this code to make sure you’ve got PHPUnit installed properly and understand the basic pieces of a test.

Then, “Isolating What You Test” looks at how to narrow the focus of what you’re testing for maximum efficiency.

“Test-Driven Development” extends the tested code by adding some tests for code that doesn’t exist yet, and then adding the code to make the tests pass. This technique can be a handy way to ensure you’re writing code that is tested properly.

At the end of this chapter, “More Information About Testing” provides details on where to find more information about PHPUnit and testing in general.

Installing PHPUnit

The quickest way to get PHPUnit running is to download a self-contained PHP Archive of the entire PHPUnit package and make it executable. As described at the PHPUnit website, the PHPUnit project makes this archive available at https://phar.phpunit.de/phpunit.phar. You can download this file and make it executable to run it directly as in Example 13-1, or just run it through the php command-line program, as in Example 13-2.

Example 13-1. Running PHPUnit as an executable PHAR file
# Assuming phpunit.phar is in the current directory, this
# makes it executable
chmod a+x phpunit.phar
# And this runs it
./phpunit.phar --version
Example 13-2. Running PHPUnit with the php command-line program
php ./phpunit.phar --version

However you run PHPUnit, if things are working properly, the output of phpunit.phar --version should look something like this:

PHPUnit 4.7.6 by Sebastian Bergmann and contributors.

If you decide to use PHPUnit for testing in a larger project that relies on Composer to manage packages and dependencies, add a reference to it in the require-dev section of your composer.json file by running the following command:

composer require-dev phpunit/phpunit

Writing a Test

The restaurant_check() function from Example 5-11 calculates the total bill for a restaurant meal, given the cost of the meal itself, the tax rate, and the tip rate. Example 13-3 shows the function again to refresh your memory.

Example 13-3. restaurant_check()
function restaurant_check($meal, $tax, $tip) {
    $tax_amount = $meal * ($tax / 100);
    $tip_amount = $meal * ($tip / 100);
    $total_amount = $meal + $tax_amount + $tip_amount;

    return $total_amount;
}

Tests in PHPUnit are organized as methods inside a class. The class you write to contain your tests must extend the PHPUnit_Framework_TestCase class. The name of each method that implements a test must begin with test. Example 13-4 shows a class with a test in it for restaurant_check().

Example 13-4. Testing restaurant check calculation
include 'restaurant-check.php';

class RestaurantCheckTest extends PHPUnit_Framework_TestCase {

    public function testWithTaxAndTip() {
        $meal = 100;
        $tax = 10;
        $tip = 20;
        $result = restaurant_check($meal, $tax, $tip);
        $this->assertEquals(130, $result);
    }

}

Note that Example 13-4 assumes that the restaurant_check() function is defined in a file named restaurant-check.php, which it includes before defining the test class. It is your responsibility to make sure that the code that your tests are testing is loaded and available for your test class to invoke.

To run the test, give the filename you’ve saved the code in as an argument to the PHPUnit program:

phpunit.phar RestaurantCheckTest.php

That produces output like the following:

PHPUnit 4.8.11 by Sebastian Bergmann and contributors.

.

Time: 121 ms, Memory: 13.50Mb

OK (1 test, 1 assertion)

Each . before the Time: line represents one test that was run. The last line (OK (1 test, 1 assertion)) tells you the status of all the tests, how many tests were run, and how many assertions all those tests contained. An OK status means no tests failed. This example had one test method, testWithTaxAndTip(), and inside that test method there was one assertion: the call to assertEquals() that checked that the return value from the function equaled 130.

A test method is generally structured like the preceding example. It has a name beginning with test that describes what behavior the method is testing. It does any variable initialization or setup necessary to exercise the code to test. It invokes the code to test. Then it makes some assertions about what happened. Assertions are available as instance methods on the PHPUnit_Framework_TestCase class, so they are available in our test subclass.

The assertion method names each begin with assert. These methods let you check all sorts of aspects of how your code works, such as whether values are equal, elements are present in an array, or an object is an instance of a certain class. Appendix A of the PHPUnit manual lists all the assertion methods available.

PHPUnit’s output looks different when a test fails. Example 13-5 adds a second test method to the RestaurantCheckTest class.

Example 13-5. A test with a failing assertion
include 'restaurant-check.php';

class RestaurantCheckTest extends PHPUnit_Framework_TestCase {

    public function testWithTaxAndTip() {
        $meal = 100;
        $tax = 10;
        $tip = 20;
        $result = restaurant_check($meal, $tax, $tip);
        $this->assertEquals(130, $result);
    }

    public function testWithNoTip() {
        $meal = 100;
        $tax = 10;
        $tip = 0;
        $result = restaurant_check($meal, $tax, $tip);
        $this->assertEquals(120, $result);
    }
}

In Example 13-5, the testWithNoTip() test method asserts that the total check on a $100 meal with 10% tax and no tip should equal $120. This is wrong—the total should be $110. PHPUnit’s output in this case looks like this:

PHPUnit 4.8.11 by Sebastian Bergmann and contributors.

.F

Time: 129 ms, Memory: 13.50Mb

There was 1 failure:

1) RestaurantCheckTest::testWithNoTip
Failed asserting that 110.0 matches expected 120.

RestaurantCheckTest.php:20

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Because the test fails, it gets an F instead of a . in the initial part of the output. PHPUnit also reports more details on the failure. It tells you what test class and test method contained the failure, and what the failed assertion was. The test code expected 120 (the first argument to assertEquals()) but instead got 110 (the second argument to assertEquals()).

If you change the assertion in testWithNoTip() to expect 110 instead, the test passes.

Some deliberation and creativity is usually required to ensure that your tests cover an adequate variety of situations so that you have confidence in how your code behaves. For example, how should restaurant_check() calculate the tip? Some people calculate the tip just on the meal amount, and some on the meal amount plus tax. A test is a good way to be explicit about your function’s behavior. Example 13-6 adds tests that verify the function’s existing behavior: the tip is calculated only on the meal, not on the tax.

Example 13-6. Testing how tip is calculated
include 'restaurant-check.php';

class RestaurantCheckTest extends PHPUnit_Framework_TestCase {

    public function testWithTaxAndTip() {
        $meal = 100;
        $tax = 10;
        $tip = 20;
        $result = restaurant_check($meal, $tax, $tip);
        $this->assertEquals(130, $result);
    }

    public function testWithNoTip() {
        $meal = 100;
        $tax = 10;
        $tip = 0;
        $result = restaurant_check($meal, $tax, $tip);
        $this->assertEquals(110, $result);
    }

    public function testTipIsNotOnTax() {
        $meal = 100;
        $tax = 10;
        $tip = 10;
        $checkWithTax = restaurant_check($meal, $tax, $tip);
        $checkWithoutTax = restaurant_check($meal, 0, $tip);
        $expectedTax = $meal * ($tax / 100);
        $this->assertEquals($checkWithTax, $checkWithoutTax + $expectedTax);
    }

}

The testTipIsNotOnTax() method calculates two different restaurant checks: one with the provided tax rate and one with a tax rate of 0. The difference between these two should just be the expected amount of tax. There should not also be a difference in the tip. The assertion in this test method checks that the check with tax is equal to the check without tax, plus the expected tax amount. This ensures that the function is not calculating the tip on the tax amount, too.

Isolating What You Test

An important principle of productive testing is that the thing you’re testing should be as isolated as possible. Ideally, there is no global state or long-lived resource outside of your test function whose contents or behavior could change the results of the test function. Your test functions should produce the same result regardless of the order in which they are run.

Consider the validate_form() function from Example 7-13. To validate incoming data, it examines the $_POST array and uses filter_input() to operate directly on INPUT_POST. This is a concise way to access the data that needs validating. However, in order to test this function, it looks like we’d have to adjust values in the auto-global $_POST array. What’s more, that wouldn’t even help filter_input() work properly. It always looks at the underlying, unmodified submitted form data, even if you change the values in $_POST.

To make this function testable, it needs to be passed the submitted form data to validate as an argument. Then this array can be referenced instead of $_POST, and filter_var() can examine the array’s elements. Example 13-7 shows this isolated version of the validate_form() function.

Example 13-7. Validating form data in isolation
function validate_form($submitted) {
    $errors = array();
    $input = array();

    $input['age'] = filter_var($submitted['age'] ?? NULL, FILTER_VALIDATE_INT);
    if ($input['age'] === false) {
        $errors[] = 'Please enter a valid age.';
    }

    $input['price'] = filter_var($submitted['price'] ?? NULL,
                                 FILTER_VALIDATE_FLOAT);
    if ($input['price'] === false) {
        $errors[] = 'Please enter a valid price.';
    }

    $input['name'] = trim($submitted['name'] ?? '');
    if (strlen($input['name']) == 0) {
        $errors[] = "Your name is required.";
    }

    return array($errors, $input);
}

The first argument to filter_var() is the variable to filter. PHP’s normal rules about undefined variables and undefined array indices apply here, so the null coalesce operator is used ($submitted['age'] ?? NULL) to provide NULL as the value being filtered if it’s not present in the array. Since NULL is not a valid integer or float, filter_var() returns false in those cases, just as it would if an invalid number was provided.

When the modified validate_form() function is called in your application, pass $_POST as an argument:

list ($form_errors, $input) = validate_form($_POST);

In your test code, pass it an array of pretend form input that exercises the situation you want to test and then verify the results with assertions. Example 13-8 shows a few tests for validate_form(): one that makes sure decimal ages are not allowed; one that makes sure prices with dollar signs are not allowed; and one that makes sure values are returned properly if a valid price, age, and name are provided.

Example 13-8. Testing isolated form data validation
// validate_form() is defined in this file
include 'isolate-validation.php';

class IsolateValidationTest extends PHPUnit_Framework_TestCase {

    public function testDecimalAgeNotValid() {
        $submitted = array('age' => '6.7',
                           'price' => '100',
                           'name' => 'Julia');
        list($errors, $input) = validate_form($submitted);
        // Expecting only one error -- about age
        $this->assertContains('Please enter a valid age.', $errors);
        $this->assertCount(1, $errors);
    }

    public function testDollarSignPriceNotValid() {
        $submitted = array('age' => '6',
                           'price' => '$52',
                           'name' => 'Julia');
        list($errors, $input) = validate_form($submitted);
        // Expecting only one error -- about age
        $this->assertContains('Please enter a valid price.', $errors);
        $this->assertCount(1, $errors);
    }

    public function testValidDataOK() {
        $submitted = array('age' => '15',
                           'price' => '39.95',
                           // Some whitespace around name that
                           // should be trimmed
                           'name' => '  Julia  ');
        list($errors, $input) = validate_form($submitted);
        // Expecting no errors
        $this->assertCount(0, $errors);
        // Expecting 3 things in input
        $this->assertCount(3, $input);
        $this->assertSame(15, $input['age']);
        $this->assertSame(39.95, $input['price']);
        $this->assertSame('Julia', $input['name']);
    }
}

Example 13-8 uses a few new assertions: assertContains(), assertCount(), and assertSame(). The assertContains() and assertCount() assertions are useful with arrays. The first tests whether a certain element is in an array and the second checks the size of the array. These two assertions express the expected condition about the $errors array in the tests and about the $input array in the third test.

The assertSame() assertion is similar to assertEquals() but goes one step further. In addition to testing that two values are equal, it also tests that the types of the two values are the same. The assertEquals() assertion passes if given the string '130' and the integer 130, but assertSame() fails. Using assertSame() in testValidDataOK() checks that the input data variable types are being set properly by filter_var().

Test-Driven Development

A popular programming technique that makes extensive use of tests is called test-driven development (TDD). The big idea of TDD is that when you have a new feature to implement, you write a test for the feature before you write the code. The test is your expression of what you expect the code to do. Then you write the code for the new feature so that the test passes.

While not ideal for every situation, TDD can be helpful for providing clarity on what you need to do and helping you build a comprehensive set of tests that cover your code. As an example, we can use TDD to add an optional feature to the restaurant_check() function that tells it to include the tax in the total amount when calculating the tip. This feature is implemented as an optional fourth argument to the function. A true value tells restaurant_check() to include the tax in the tip-calculation amount. A false value tells it not to. If no value is provided, the function should behave as it already does.

First, the test. We need a test that tells restaurant_check() to include the tax in the tip-calculation amount and then ensures that the total check amount is correct. We also need a test that makes sure the function works properly when it is explicitly told not to include the tax in the tip-calculation amount. These two new test methods are shown in Example 13-9. (For clarity, just the two new methods are shown, not the whole test class.)

Example 13-9. Adding tests for new tip-calculation logic
public function testTipShouldIncludeTax() {
    $meal = 100;
    $tax = 10;
    $tip = 10;
    // 4th argument of true says that the tax should be included
    // in the tip-calculation amount
    $result = restaurant_check($meal, $tax, $tip, true);
    $this->assertEquals(121, $result);
}

public function testTipShouldNotIncludeTax() {
    $meal = 100;
    $tax = 10;
    $tip = 10;
    // 4th argument of false says that the tax should explicitly
    // NOT be included in the tip-calculation amount
    $result = restaurant_check($meal, $tax, $tip, false);
    $this->assertEquals(120, $result);
}

It should not be surprising that the new test testTipShouldIncludeTax() fails:

PHPUnit 4.8.11 by Sebastian Bergmann and contributors.

...F.

Time: 138 ms, Memory: 13.50Mb

There was 1 failure:

1) RestaurantCheckTest::testTipShouldIncludeTax
Failed asserting that 120.0 matches expected 121.

RestaurantCheckTest.php:40

FAILURES!
Tests: 5, Assertions: 5, Failures: 1.

To get that test to pass, restaurant_check() needs to handle a fourth argument that controls the tip-calculation behavior, as shown in Example 13-10.

Example 13-10. Changing tip calculation logic
function restaurant_check($meal, $tax, $tip, $include_tax_in_tip = false) {
    $tax_amount = $meal * ($tax / 100);
    if ($include_tax_in_tip) {
        $tip_base = $meal + $tax_amount;
    } else {
        $tip_base = $meal;
    }
    $tip_amount = $tip_base * ($tip / 100);
    $total_amount = $meal + $tax_amount + $tip_amount;

    return $total_amount;
}

With the new logic in Example 13-10, the restaurant_check() function reacts to its fourth argument and changes the base of what the tip is calculated on accordingly. This version of restaurant_check() lets all the tests pass:

PHPUnit 4.8.11 by Sebastian Bergmann and contributors.

.....

Time: 120 ms, Memory: 13.50Mb

OK (5 tests, 5 assertions)

Because the test class includes not just the new tests for this new functionality but all of the old tests as well, it ensures that existing code using restaurant_check() before this new feature was added continues to work. A comprehensive set of tests provides reassurance that changes made to the code don’t break existing functionality.

More Information About Testing

As your projects grow larger, the benefits of comprehensive testing increase as well. At first, it feels like a drag to write a bunch of seemingly extra code to verify something obvious, such as the basic mathematical operations in restaurant_check(). But as your project accumulates more and more functionality (and perhaps more and more people working on it), the accumulated tests are invaluable.

Absent some fancy-pants computer science formal methods, which rarely find their way into modern PHP applications, the results of your tests are the evidence you have to answer the question “How do you know your program does what you think it does?” With tests, you know what the program does because you run it in various ways and ensure the results are what you expect.

This chapter shows the basics for integrating PHPUnit into your project and writing some simple tests. To go further, here are a few additional resources about PHPUnit and testing in general:

  • The PHPUnit manual is helpful and comprehensive. It includes tutorial-style information on common PHPUnit tasks as well as reference material on PHPUnit’s features.
  • There is a great list of presentations about PHPUnit at https://phpunit.de/presentations.html.
  • Browsing the test directory of popular PHP packages to see how those packages do their tests is instructive as well. In the Zend Framework, you can find the tests for the zend-form component and the zend-validator component on GitHub. The popular Monolog package has its tests on on GitHub as well.
  • Naturally, PHPUnit has numerous tests that verify its behavior. And those tests are PHPUnit tests!

Chapter Summary

This chapter covered:

  • Understanding the benefits of code testing
  • Installing and running PHPUnit
  • Understanding how test case classes, test methods, and assertions work together in PHPUnit
  • Writing a test that verifies a function’s behavior
  • Running your test in PHPUnit
  • Understanding PHPUnit’s output when tests succeed and fail
  • Understanding why to isolate the code you are testing
  • Removing global variables from code to make it more testable
  • Learning about test-driven development
  • Writing a test for a new feature before the feature’s code is written
  • Writing code to make the new test pass
  • Where to go to find more information about PHPUnit and testing

Exercises

  1. Follow the instructions in “Installing PHPUnit ” to install PHPUnit, write a test class with a single test containing a single simple assertion that passes (such as $this->assertEquals(2, 1 + 1);) and run PHPUnit on your test class.
  2. Add a test case to Example 13-8 that ensures an error is returned when no name is submitted.
  3. Write tests to verify the behavior of the select() function from Example 7-29. Be sure to consider the following situations:

    • If an associative array of options is provided, then each <option> tag should be rendered with the array key as the value attribute of the <option> tag and the array value as the text between <option> and </option>.
    • If a numeric array of options is provided, then each <option> tag should be rendered with the array index as the value attribute of the <option> tag and the array value as the text between <option> and </option>.
    • If no attributes are provided, then the opening tag should be <select>.
    • If an attribute is provided with a boolean true value, then only the attribute’s name should be included inside the opening <select> tag.
    • If an attribute is provided with a boolean false value, then the attribute should not be included inside the opening <select> tag.
    • If an attribute is provided with any other value, then the attribute and its value should be included inside the opening <select> tag as an attribute=value pair.
    • If the multiple attribute is set, [] should be appended to the value of the name attribute in the opening <select> tag.
    • Any attribute values or option text that contains special characters such as < or & should be rendered with encoded HTML entities such as &lt; or &amp;.
  4. The HTML5 forms specification lists, in great detail, the specific attributes that are allowed for each form element. The complete set of possible attributes is mighty and numerous. Some attributes are relatively constrained, though. For example, the <button> tag supports only three possible values for its type attribute: submit, reset, and button.

    Without first modifying FormHelper, write some new tests that check the value of a type attribute provided for a <button> tag. The attribute is optional, but if it’s provided, it must be one of the three allowable values.

    After you’ve completed your tests, write the new code for FormHelper that makes the tests pass.