Extending test cases and factories in WordPress integration tests

Exploiting Codeception flexibility to easily create test cases and factories for WordPress integration tests.

The context

While I’ve praised the power of WordPress integration tests factories before there are times when the complexity required to set up an integration test fixture might make the use of the default factories repetitive and inconvenient.
The code I’m showing can be found in the WordPress integration tests demonstration plugin I’ve put together.

The problem

The plugin will register three additional post types: books, authors and reviews.
The logic constraint is that there can be authors without books, a book must have at least one author and a review is always associated with a book.
The plugin code can be found on GitHub:

<?php
/**
 * Plugin Name:     WP Integration Tests Example Plugin
 * Plugin URI:      http://theaveragedev.com
 * Description:     An ongoing collection of WordPress integration tests
 * examples and practices. Author:          Luca Tumedei Author URI:
 * http://theaveragedev.com Text Domain:     acme Domain Path:     /languages
 * Version:         0.1.0
 *
 * @package         Acme
 */

namespace Acme;

class Plugin {

    public function init() {
        $this->register_installation_type();
        $this->register_post_types();
    }

    protected function register_installation_type() {
        if ( is_multisite() ) {
            update_network_option( null, 'acme_wp_installation', 'multisite' );
        } else {
            update_option( 'acme_wp_installation', 'single' );
        }
    }

    protected function register_post_types() {
        register_post_type( 'book' );
        register_post_type( 'author' );
        register_post_type( 'review' );

        register_post_status( 'good' );
        register_post_status( 'bad' );
    }

    public function get_top_three_books( $good = true ) {
        $status = $good ? 'good' : 'bad';

        /** @var \wpdb $wpdb */
        global $wpdb;

        $target_reviews = $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'review' and post_status = '{$status}'" );

        if ( empty( $target_reviews ) ) {
            return [];
        }

        $review_ids = implode( ',', $target_reviews );

        $query
            = "SELECT p.ID, COUNT(m.post_id) AS 'count' FROM {$wpdb->postmeta} m
            RIGHT JOIN {$wpdb->posts} p
            ON p.ID = m.post_id
            WHERE p.post_type = 'book' AND p.post_status = 'publish'
            AND m.meta_value IN ({$review_ids}) AND m.meta_key = 'review'
            GROUP BY(m.post_id)
            ORDER BY COUNT(m.post_id) DESC LIMIT 3";

        $top_three = $wpdb->get_results( $query );

        return empty( $top_three ) ? [] : array_combine( wp_list_pluck( $top_three, 'ID' ), wp_list_pluck( $top_three, 'count' ) );
    }
}


add_action( 'init', [ new Plugin(), 'init' ] );

function acme_get_top_three_books($good = true) {
    return ( new Plugin )->get_top_three_books( $good );
}

Building all that relation

Without resorting to new factories building the fixture needed for testing the acme_get_top_three_books function would require the setup to happen in each test method:

<?php


class NoFactoryTest extends \Codeception\TestCase\WPTestCase {

    /**
     * getting the first top three good books
     */
    public function test_getting_the_first_top_three_good_books() {
        $books = $this->factory()->post->create_many( 4, [ 'post_type' => 'book' ] );

        $reviews_count = array_combine( $books, [
            [ 10, 1 ],
            [ 5, 2 ],
            [ 3, 3 ],
            [ 1, 20 ]
        ] );

        array_walk( $books, function ( $book ) use ( $reviews_count ) {
            $author = $this->factory()->post->create( [ 'post_type' => 'author' ] );
            wp_update_post( [ 'ID' => $book, 'post_parent' => $author ] );
            $good = $this->factory()
                ->post->create_many( $reviews_count[ $book ][0], [ 'post_type' => 'review', 'post_status' => 'good'] );
            $bad = $this->factory()
                ->post->create_many( $reviews_count[ $book ][1], [ 'post_type' => 'review', 'post_status' => 'bad' ] );
            foreach ( array_merge( $good, $bad ) as $review_id ) {
                add_post_meta( $book, 'review', $review_id );
            }
        } );

        $expected = ( [ $books[0] => 10, $books[1] => 5, $books[2] => 3 ] );

        $top_three = \Acme\acme_get_top_three_books( );

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }

    /**
     * getting the first top three bad books
     */
    public function test_getting_the_first_top_three_bad_books() {
        $books = $this->factory()->post->create_many( 4, [ 'post_type' => 'book' ] );

        $reviews_count = array_combine( $books, [
            [ 10, 1 ],
            [ 5, 2 ],
            [ 3, 3 ],
            [ 1, 20 ]
        ] );

        array_walk( $books, function ( $book ) use ( $reviews_count ) {
            $author = $this->factory()->post->create( [ 'post_type' => 'author' ] );
            wp_update_post( [ 'ID' => $book, 'post_parent' => $author ] );
            $good = $this->factory()
                ->post->create_many( $reviews_count[ $book ][0], [ 'post_type' => 'review', 'post_status' => 'good'] );
            $bad = $this->factory()
                ->post->create_many( $reviews_count[ $book ][1], [ 'post_type' => 'review', 'post_status' => 'bad' ] );
            foreach ( array_merge( $good, $bad ) as $review_id ) {
                add_post_meta( $book, 'review', $review_id );
            }
        } );

        $expected = ( [ $books[3] => 20, $books[2] => 3, $books[1] => 2 ] );

        $top_three = \Acme\acme_get_top_three_books( false );

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }
}

The set up phase covers most of the test method and subsequent iterations will lead to extending the base test case:

<?php

namespace Acme\TestCase;

use Codeception\TestCase\WPTestCase;

class MyFirstTestCase extends WPTestCase {

    public function create_book_with_author_and_reviews( $good, $bad ) {
        $book = $this->factory()->post->create( [ 'post_type' => 'book' ] );

        $author = $this->factory()->post->create( [ 'post_type' => 'author' ] );

        wp_update_post( [ 'ID' => $book, 'post_parent' => $author ] );

        $good = $this->factory()->post->create_many( $good, [
            'post_type'   => 'review',
            'post_status' => 'good'
        ] );
        $bad = $this->factory()->post->create_many( $bad, [ 'post_type' => 'review', 'post_status' => 'bad' ] );

        foreach ( array_merge( $good, $bad ) as $review_id ) {
            add_post_meta( $book, 'review', $review_id );
        }

        return $book;
    }

    public function create_many_books_with_authors_and_reviews( $reviews_count ) {
        return array_map( function ( $review_count ) {
            return $this->create_book_with_author_and_reviews( $review_count[0], $review_count[1] );
        }, $reviews_count );
    }
}

Rewriting the tests case

Before I can safely extend the Acme\TestCase\MyFirstTestCase with my test cases I will need to tell Codeception where to look to autoload the new class; I’ve created the test case in the tests/_support/TestCase folder and will hence edit the tests/integration/_bootstrap.php file to this:

<?php
// Here you can initialize variables that will be available to your tests

use Codeception\Configuration;
use Codeception\Util\Autoload;

Autoload::addNamespace( 'Acme\TestCase', Configuration::supportDir() . 'TestCase' );

Now that Codeception knows where to look for MyFirstTestCase it’s time to put it to good use:

<?php

use Acme\TestCase\MyFirstTestCase;

class ExtendingTestCaseTest extends MyFirstTestCase {

    /**
     * getting the first top three good books
     */
    public function test_getting_the_first_top_three_good_books() {
        $reviews_count = [
            [ 10, 1 ],
            [ 5, 2 ],
            [ 3, 3 ],
            [ 1, 20 ]
        ];

        $books = $this->create_many_books_with_authors_and_reviews( $reviews_count );

        $expected = ( [ $books[0] => 10, $books[1] => 5, $books[2] => 3 ] );

        $top_three = \Acme\acme_get_top_three_books();

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }


    /**
     * getting the first top three bad books
     */
    public function test_getting_the_first_top_three_bad_books() {
        $reviews_count = [
            [ 10, 1 ],
            [ 5, 2 ],
            [ 3, 3 ],
            [ 1, 20 ]
        ];

        $books = $this->create_many_books_with_authors_and_reviews( $reviews_count );

        $expected = ( [ $books[3] => 20, $books[2] => 3, $books[1] => 2 ] );

        $top_three = \Acme\acme_get_top_three_books( false );

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }
}

The test setup code is now more lean and ablative improving the test overall readability.

Extending the built in factories

A further step is to extend the built-in factories to allow for even more test code reusability not just for this specific test case and tests methods but for the, hopefully, many more to come.
The first step is to create three new factories extending the post one; I’m showing only the book one here but the code for the others can be found on the acme plugin repo:

<?php
namespace Acme\Factory;

use WP_UnitTest_Factory_For_Post;
use WP_UnitTest_Generator_Sequence;

class BookFactory extends WP_UnitTest_Factory_For_Post {

    public function __construct( $factory = null ) {
        parent::__construct( $factory );
        $this->default_generation_definitions = array(
            'post_status'  => 'publish',
            'post_title'   => new WP_UnitTest_Generator_Sequence( 'Book title %s' ),
            'post_content' => new WP_UnitTest_Generator_Sequence( 'Book content %s' ),
            'post_excerpt' => new WP_UnitTest_Generator_Sequence( 'Book excerpt %s' ),
            'post_type'    => 'book'
        );
    }

    public function create_with_author_and_reviews( $goodReviews, $badReviews ) {
        $author = ( new AuthorFactory() )->create();
        $good = ( new ReviewFactory() )->create_many_good( $goodReviews );
        $bad = ( new ReviewFactory() )->create_many_bad( $badReviews );

        $bookId = $this->create( [ 'post_parent' => $author ] );

        $allReviews = array_merge( $good, $bad );

        array_walk( $allReviews, function ( $id ) use ( $bookId ) {
            add_post_meta( $bookId, 'review', $id );
            add_post_meta( $id, 'forBook', $bookId );
        } );

        return $bookId;
    }
}

As before I will tell Codeception where to find the new factories adding a line to the tests/integration/_bootstrap.php file:

<?php
// Here you can initialize variables that will be available to your tests

use Codeception\Configuration;
use Codeception\Util\Autoload;

Autoload::addNamespace( 'Acme\TestCase', Configuration::supportDir() . 'TestCase' );
Autoload::addNamespace( 'Acme\Factory', Configuration::supportDir() . 'Factory' );

and finally rewrite the test case to use the new book factory:

<?php

use Acme\TestCase\MySecondTestCase;

class FactoryTest extends MySecondTestCase {

    /**
     * my factory works
     */
    public function test_my_factory_works() {
        $this->factory()->book->create();

        $this->assertCount( 1, get_posts( [ 'post_type' => 'book' ] ) );
    }

    /**
     * book factory can create books with authors and reviews
     */
    public function test_book_factory_can_create_books_with_authors_and_reviews() {
        $bookId = $this->factory()->book->create_with_author_and_reviews( 3, 3 );

        $this->assertInternalType( 'int', $bookId );

        $book = get_post( $bookId );

        $this->assertInstanceOf( WP_Post::class, $book );
        $this->assertEquals( 'book', $book->post_type );
        $this->assertNotEquals( 0, $book->post_parent );

        $author = get_post( $book->post_parent );

        $this->assertInstanceOf( WP_Post::class, $author );
        $this->assertEquals( 'author', $author->post_type );

        $reviewsIds = get_post_meta( $bookId, 'review' );
        $this->assertCount( 6, $reviewsIds );

        $reviews = array_filter( array_map( 'get_post', $reviewsIds ), function ( $post ) {
            return is_a( $post, WP_Post::class )
                   && $post->post_type === 'review';
        } );

        $this->assertCount( 6, $reviews );
    }

    /**
     * get top three good books
     */
    public function test_get_top_three_good_books() {
        $first = $this->factory()->book->create_with_author_and_reviews( 10, 1 );
        $second = $this->factory()->book->create_with_author_and_reviews( 5, 1 );
        $third = $this->factory()->book->create_with_author_and_reviews( 2, 1 );
        $fourth = $this->factory()->book->create_with_author_and_reviews( 0, 20 );

        $expected = ( [ $first => 10, $second => 5, $third => 2 ] );

        $top_three = \Acme\acme_get_top_three_books();

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }

    /**
     * get top three bad books
     */
    public function test_get_top_three_bad_books() {
        $first = $this->factory()->book->create_with_author_and_reviews( 10, 1 );
        $second = $this->factory()->book->create_with_author_and_reviews( 5, 5 );
        $third = $this->factory()->book->create_with_author_and_reviews( 2, 2 );
        $fourth = $this->factory()->book->create_with_author_and_reviews( 0, 20 );

        $expected = ( [ $fourth => 20, $second => 5, $third => 2 ] );

        $top_three = \Acme\acme_get_top_three_books( false );

        $this->assertInternalType( 'array', $top_three );
        $this->assertEquals( $expected, $top_three );
    }
}

On GitHub

All the code shown here can be found on the acme plugin GitHub repository.

I appreciate your input