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.
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.
# Assuming phpunit.phar is in the current directory, this # makes it executable chmod a+x phpunit.phar # And this runs it ./phpunit.phar --version
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
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.
functionrestaurant_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().
include'restaurant-check.php';classRestaurantCheckTestextendsPHPUnit_Framework_TestCase{publicfunctiontestWithTaxAndTip(){$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.
include'restaurant-check.php';classRestaurantCheckTestextendsPHPUnit_Framework_TestCase{publicfunctiontestWithTaxAndTip(){$meal=100;$tax=10;$tip=20;$result=restaurant_check($meal,$tax,$tip);$this->assertEquals(130,$result);}publicfunctiontestWithNoTip(){$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.
include'restaurant-check.php';classRestaurantCheckTestextendsPHPUnit_Framework_TestCase{publicfunctiontestWithTaxAndTip(){$meal=100;$tax=10;$tip=20;$result=restaurant_check($meal,$tax,$tip);$this->assertEquals(130,$result);}publicfunctiontestWithNoTip(){$meal=100;$tax=10;$tip=0;$result=restaurant_check($meal,$tax,$tip);$this->assertEquals(110,$result);}publicfunctiontestTipIsNotOnTax(){$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.
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.
functionvalidate_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.";}returnarray($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.
// validate_form() is defined in this fileinclude'isolate-validation.php';classIsolateValidationTestextendsPHPUnit_Framework_TestCase{publicfunctiontestDecimalAgeNotValid(){$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);}publicfunctiontestDollarSignPriceNotValid(){$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);}publicfunctiontestValidDataOK(){$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().
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.)
publicfunctiontestTipShouldIncludeTax(){$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);}publicfunctiontestTipShouldNotIncludeTax(){$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.
functionrestaurant_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.
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:
zend-form component and the zend-validator component on GitHub. The popular Monolog package has its tests on on GitHub as well.This chapter covered:
$this->assertEquals(2, 1 + 1);) and run PHPUnit on your test class.Write tests to verify the behavior of the select() function from Example 7-29. Be sure to consider the following situations:
<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>.<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>.<select>.true value, then only the attribute’s name should be included inside the opening <select> tag.false value, then the attribute should not be included inside the opening <select> tag.<select> tag as an attribute=value pair.multiple attribute is set, [] should be appended to the value of the name attribute in the opening <select> tag.< or & should be rendered with encoded HTML entities such as < or &.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.