PHPUnit Tests for WordPress Plugins: wp_redirect() and expected PHP errors

by Daniel Convissor at 2012-11-09 20:47:00

This third post in my series on testing WordPress plugins with PHPUnit will cover the real world dilemma I ran into combining wp_logout(), wp_redirect(), and continuing a given test's execution after PHP produces expected errors.

The Handling of PHP Errors By PHPUnit

When a PHP error/warning/whatever is encountered inside a test, PHPUnit throws an exception, immediately stopping execution of that test. It shows the problem in errors section of the results once all tests have completed. But what if you want to test the fact that PHP is supposed to produce an error? PHPUnit lets you do that with the setExpectedException('PHPUnit_Framework_Error', 'some message') method or with docblock annotations. It's great stuff.

But there's one big limitation. What I said about PHPUnit "immediately stopping execution of that test" still applies. Any code after the PHP error will not be run (and it's easy to never even realize it). Here's an example.

/**
 * @expectedException PHPUnit_Framework_Error
 * @expectedExceptionMessage Notice: Undefined variable: nada
 */
public function test_error() {
    echo "$nada\n";

    // Execution stops above.  The rest of this stuff will not be run.
    $actual = some_procedure();
    $this->assertFalse($actual);
}

That test will pass, even if there's a problem with the return from some_procedure(). Don't despair! This post explains how to deal with the problem.

The Terms

Some of the following concepts are similar to those discussed for testing wp_mail(). Please bear any slight repetition.

First, a bit of background. In this post, when I say "parent class," I mean the class that holds helper properties and methods that all test classes can reference:

abstract class TestCase extends PHPUnit_Framework_TestCase {}

And when I say "test class," I mean the class containing the test methods that PHPUnit will execute:

class LoginTest extends TestCase {}

The Redirects

Monitoring wp_redirect() itself is pretty easy. WordPress lets users override the one built into the core. All users have to do is declare it before WP does. My version calls a static method in the "parent class."

function wp_redirect($location, $status = 302) {
    TestCase::wp_redirect($location, $status);
}

The redirect method in the "parent class" writes the location to a property for later comparison. But before doing that, it makes sure that wp_redirect() was called at an expected time by checking that the $location_expected property has been set.

public static function wp_redirect($location, $status) {
    if (!self::$location_expected) {
        throw new Exception('wp_redirect() called at unexpected time'
                . ' ($location_expected was not set).');
    }
    self::$location_actual = $location;
}

Here's the plugin's method we'll be testing. It logs the user out and redirects them to the password reset page.

protected function force_retrieve_pw() {
    wp_logout();
    wp_redirect(wp_login_url() . '?action=retrievepassword');
}

The Hitch

The hitch is wp_logout() calls setcookie(), which sends headers, but PHPUnit has already generated some output, meaning (you guessed it) PHP will produce a warning: Cannot modify header information - headers already sent by (output started at PHPUnit/Util/Printer.php:172). That means I won't be able to make sure the redirect worked because PHPUnit will stop execution when that warning is thrown.

Thought bubble... I just realized wp_logout() and wp_redirect() are "pluggable" too. So I could override them with methods that don't set cookies and even makes sure the function gets called when I want it to. But that would obviate the need for the cool part of this tutorial. So, uh, let's imagine I never said anything. (Eventually I'll modify the unit tests in the Login Security Solution.)

The Error Handlers

So, let's set up the error handlers we'll need in the "parent class." The first method is used by the tests to say an error will be coming. It stores the expected error messages in a property and sets an error handler for PHP.

protected function expected_errors($error_messages) {
    $this->expected_error_list = (array) $error_messages;
    set_error_handler(array(&$this, 'expected_errors_handler'));
}

When PHP produces an error, my error handler makes sure the message is one we anticipated. If so, the $expected_errors_found property is set to true so we can check it later.

public function expected_errors_handler($errno, $errstr) {
    foreach ($this->expected_error_list as $expect) {
        if (strpos($errstr, $expect) !== false) {
            $this->expected_errors_found = true;
            return true;
        }
    }
    return false;
}

Then there is the method that tests can call to make sure the expected PHP error actually happened and to pull my error handler off of the stack.

protected function were_expected_errors_found() {
    restore_error_handler();
    return $this->expected_errors_found;
}

The Test

Finally! Here's the test for PHPUnit to execute.

public function test_redirect() {
    // Establish the error handler with the expected message.
    $expected_error = 'Cannot modify header information';
    $this->expected_errors($expected_error);

    // Let the customized wp_redirect() know it's okay to be called now.
    self::$location_expected = wp_login_url() . '?action=retrievepassword';

    // Call the plugin method we want to test.
    self::$o->force_retrieve_pw();

    // Make sure the error happened.
    $this->assertTrue($this->were_expected_errors_found(),
            "Expected error not found: '$expected_error'");

    // Check that the redirect URI matches the expected one.
    $this->assertEquals(self::$location_expected, self::$location_actual,
            'wp_redirect() produced unexpected location header.');
}

The Beer

Well, we made it through. If this was helpful, buy me a beer some time. After figuring this out in the first place and pulling this post together, I can sure use one.

Tags: wordpress, phpunit, php

View all posts

Email me a comment:

(I'll append it here when I get a chance.)