Writing Tests for a Class that Uses the ArrayAccess Interface

I am currently involved in a project that utilises the WordPress REST API. As a follower of the principles of Test Driven Development (TDD) the project comes complete with extensive unit and integration tests that were all written prior to even conceptualising any code. Some times.

As an aside, I’d place my current level of “following” TDD more on a par with the sports fan who goes to a few home games a season and watches if his team are on television than the guy who has his team’s badge tattooed onto his forehead, names all his kids after players in the team, attends every single game, home and away, as well as obscure friendlies and all training sessions, but hey, I’m working on it.

Full PHPUnit face tattoos aside, I ran into some problems when testing a method that builds arguments for a GET taxonomy terms endpoint (Yes I wrote the test after the method. Yes I am going to TDD hell). The method accepts one parameter which is an instance of a WP_REST_Request object.

To provide some backstory on WP_REST_Request, a Solo style prequel if you will: After escaping from a rough upbringing on the mean streets of Silcon Valley refusing to divulge information to anyone who didn’t know its methods and properties, the WP_REST_Request class cleaned up its act by implementing the ArrayAccess interface therefore opening up all its secrets and inner workings to anyone who asked. Although this initially led to some “hilarious consequences” as a result of this macho class revealing some of its well hidden emotions, it learned to embrace its feelings and improved the lives of all around it in this hilarious, warm emotional rollercoaster of a family movie (65% on Rotten Tomatoes).

TLDR/No Sense of Adventure: It implements the ArrayAccess interface, which results in an object that you can fetch values from as if it was an array (as you can hopefully see below).

public function get_items_args( WP_REST_Request $request ) {

    // We will always show empty terms.
    $args = [ 'hide_empty' => false ];

    $registered = $this->get_collection_params();

    $parameter_mappings = [
        'order'      => 'order',
        'orderby'    => 'orderby',
        'per_page'   => 'number',
        'page'       => 'page',
        'search'     => 'search',
    ];

    foreach ( $parameter_mappings as $api_param => $wp_param ) {

        if ( isset( $registered[ $api_param ] ) && isset( $request[ $api_param ] ) ) {
            $args[ $wp_param ] = $request[ $api_param ];
            continue;
        }

        // If nothing in the request, set to default if there is one.
        if ( isset( $registered[ $api_param ]['default'] ) ) {
            $args[ $wp_param ] = $registered[ $api_param ]['default'];
        }
    }

    return $args;
}

This is all warm and cuddly to implement, but writing a unit test looks like an impossibility on first glance. The typecasting of $request means we can’t just pass in an array of values, and picking through the source code for WP_REST_Request and working out how to set values for tests just isn’t unit testing. The temptation to write a bad test or ignore this altogether was tempting, but I knew I would wake up screaming in the night after having Chris Hartjes haunt my dreams so I reached for Stack Overflow and came up with a solution.

As we know that the ArrayAccess interface is in use, we know that we can create a test double of the WP_REST_Request class and mock the abstract offsetGet method to return values.

/**
 * Given that we have a request,
 * and collection params,
 * our get_items arguments are correctly assembled.
 */
public function test_that_items_are_correctly_built() {

    $mock_request = $this->getMockBuilder( 'WP_REST_Request' )
        ->setMethods( [ 'offsetGet' ] )
        ->getMock();

    $response_values = [
        'order'      => 'asc',
        'orderby'    => 'name',
        'per_page'   => '10',
        'page'       => '2',
        'search'     => 'foo',
    ];

    $mock_request->expects( $this->any() )
        ->method( 'offsetGet' )
        ->will( $this->returnCallback(
            function ( $key ) use ( $response_values ) {
                return $response_values[ $key ];
            }
        ) );

    // ...
}

Passing this into a test double of the get_items_args method will take care of the line of code that sets our values:

$args[ $wp_param ] = $request[ $api_param ];

Unfortunately, we won’t find this out, because our test will break on the preceding line:

if ( isset( $registered[ $api_param ] ) && isset( $request[ $api_param ] ) ) {

Fortunately a tweak to our the code in our method takes care of this. Instead of using isset we can utilise the abstract function offsetExists that comes baked into ArrayAccess:

public function get_items_args( WP_REST_Request $request ) {

    // We will always show empty terms.
    $args = [ 'hide_empty' => false ];

    $registered = $this->get_collection_params();

    $parameter_mappings = [
        'order'      => 'order',
        'orderby'    => 'orderby',
        'per_page'   => 'number',
        'page'       => 'page',
        'search'     => 'search',
    ];

    foreach ( $parameter_mappings as $api_param => $wp_param ) {

        if ( isset( $registered[ $api_param ] ) && $request->offsetExists( $api_param ) ) {
            $args[ $wp_param ] = $request[ $api_param ];
            continue;
        }

        // If nothing in the request, set to default if there is one.
        if ( isset( $registered[ $api_param ]['default'] ) ) {
            $args[ $wp_param ] = $registered[ $api_param ]['default'];
        }
    }

    return $args;
}

Finally, we complete our unit test as follows:

/**
 * Given that we have a request,
 * and collection params,
 * our get_items arguments are correctly assembled.
 */
public function test_that_items_are_correctly_built() {

    // Create test double of WP_REST_Request.
    $mock_request = $this->getMockBuilder( 'WP_REST_Request' )
        ->setMethods( [ 'offsetExists', 'offsetGet' ] )
        ->getMock();

    $response_values = [
        'order'      => 'asc',
        'orderby'    => 'name',
        'per_page'   => '15',
        'page'       => '2',
        'search'     => 'foo',
    ];

    $mock_request->expects( $this->any() )
        ->method( 'offsetExists' )
        ->will( $this->returnCallback(
            function ( $key ) use ( $response_values ) {
                return array_key_exists( $key, $response_values );
            }
        ) );

    $mock_request->expects( $this->any() )
        ->method( 'offsetGet' )
        ->will( $this->returnCallback(
            function ( $key ) use ( $response_values ) {
                return $response_values[ $key ];
            }
        ) );

    // Create test double of Our_Terms_Controller.
    $our_terms_controller = $this->getMockBuilder( 'Our_Terms_Controller' )
        ->setMethods( [ 'get_collection_params' ] )
        ->disableOriginalConstructor()
        ->getMock();

    $our_terms_controller->expects( $this->once() )
        ->method( 'get_collection_params' )
        ->will( $this->returnValue( [
            'page'		=> [ 'default' => 1 ],
            'per_page'	=> [ 'default' => 10 ],
            'order'		=> [ 'default' => 'desc' ],
            'orderby'	=> [ 'default' => 'ID' ],
            'search'	=> [],		
        ] ) );

    $this->assertEquals(
        [
            'hide_empty'	=> false,
            'order'      	=> 'asc',
            'orderby'    	=> 'name',
            'number'   		=> '15',
            'page'       	=> '2',
            'search'     	=> 'foo',
        ],
        $our_terms_controller->get_items_args( $mock_request ),
        "Get Items arguments not being correctly assembled."
    );
}

Et voila, our method is tested, maximum smug points are achieved, and our dreams are now grumpy programmer free.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.