Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 18 / 18 45 MIN

Building an extension

Automated Tests and Release

How WooCommerce's test suite differs from WordPress's, what an extension should test, and the release chores that keep a plugin updatable and removable.

A courier extension works on the developer's machine and fails on the store that has been running since 2013. The difference is order storage: newer sites have High-Performance Order Storage switched on, older ones still read orders from posts, and the extension was written against whichever one the developer happened to install.

Tests and release chores are the part that happens after the interesting code is written. A test that creates an order through the CRUD layer tells you whether your code survives storage changes, and a release process that bumps three version strings is what lets a customer update at all. Both fail late and loudly when missing. The mechanics below are from WooCommerce 11.2.1.

WooCommerce's tests are not the WordPress test suite with extra files

A WordPress plugin's tests usually mean the WordPress test library, a bootstrap that loads wp-tests-config.php, and PHPUnit. WooCommerce keeps that foundation and adds a suite of its own.

PieceWhat it is
plugins/woocommerce/tests/legacy/bootstrap.php WooCommerce's own bootstrap. It loads the WordPress test library from WP_TESTS_DIR, then installs WooCommerce, loads the test framework and re-initialises the dependency injection container.
plugins/woocommerce/tests/php and tests/legacy Two PHPUnit test suites, declared in phpunit.xml as wc-phpunit-legacy and wc-phpunit-main. New tests go in tests/php, which is namespaced under Automattic\WooCommerce\Tests.
WC_Unit_Test_Case The base class. It extends WP_HTTP_TestCase and adds WooCommerce helpers, a shared factory, and storage-aware setup.
PHPUnit 9.6 plus yoast/phpunit-polyfills and Mockery From plugins/woocommerce/composer.json's require-dev. The polyfills are wired into the bootstrap through WP_TESTS_PHPUNIT_POLYFILLS_PATH.

The release archive does not contain the tests. I checked the published 11.2.0 plugin zip and the only tests/ entries belong to a bundled Composer dependency. Running the suite means a checkout, composer install, the WordPress test library and a database the tests can drop; the library boots its own copy of WordPress rather than loading the installed one. My lab did not have it, so I did not run PHPUnit. I loaded the framework files the bootstrap loads and ran the helper code directly, which is what the next two sections report.

The framework ships the fixtures your extension actually needs

Before writing a fixture of your own, read what the framework includes: the mock session handler, two mock payment gateways, a payment token stub, a REST test server subclass and a directory of helpers. The two classes an extension test uses most are the fixture builders.

<?php
$product = WC_Helper_Product::create_simple_product();

$order = WC_Helper_Order::create_order();
$order->set_payment_method( 'sifero_stub' );
$order->set_transaction_id( 'ch_test_1' );
$order->save();

WC_Helper_Product::create_simple_product() saves a published product; mine came back as id=13 type=simple price=10 status=publish. WC_Helper_Order::create_order() builds an order with one line item, a flat-rate shipping charge, a full US billing address and a customer id, and returns a WC_Order; mine printed id=15 status=pending total=50.00 items=1. Those defaults show up in your assertions, so a total of 50.00 is not a bug in your extension. The helper also sets $_SERVER['REMOTE_ADDR'], which wc_create_order() requires, and that is the first thing that bites if you write your own fixture.

A gateway is tested through its declared support, not through your HTTP client

The gateway half of a test is smaller than people expect. The framework's WC_Mock_Payment_Gateway and WC_Mock_Enhanced_Payment_Gateway both extend WC_Payment_Gateway, and the assertions that matter are about the contract a gateway declares. WooCommerce's own tests/legacy/unit-tests/gateways/gateways.php is the reference: it asserts supports() against the feature constants and toggles can_refund_order() by appending to $supports.

<?php
$gateway = new WC_Mock_Payment_Gateway();

$this->assertTrue( $gateway->supports( 'products' ) );
$this->assertFalse( $gateway->supports( 'made-up-feature' ) );
$this->assertFalse( $gateway->can_refund_order( $order ) );

$gateway->supports[] = 'refunds';

$this->assertTrue( $gateway->can_refund_order( $order ) );

Run outside PHPUnit, the same calls printed supports products: true, supports made-up: false, can_refund_order: false, then true after appending refunds. The payment half is payment_complete(): I called it on the helper order and the status moved from pending to processing with date_paid set. Neither fact needs a fake HTTP server. Your provider call does, and the framework's WP_HTTP_TestCase ancestor replaces the transport so wp_remote_post() returns a prepared response.

Test your extension's decisions, not WooCommerce's

An extension interacts with code that has its own tests. Re-asserting that wc_get_order() returns an order is work that never fails, and a test that never fails gives no signal. Test where your code decides something and can get it wrong quietly.

  • Idempotency. Send the same webhook or retry the same payment twice through the CRUD layer and assert one order transition, not two.
  • Ambiguity. Feed your gateway a timeout, a 500 and a response body missing its charge id, and assert the order stays unpaid in all three cases.
  • Calculations. A fee, a discount or a rounding rule at a boundary value, asserted against the order total rather than against an intermediate variable.
  • Permissions. The same REST route called as a customer and as a shop manager, asserting the status code differs.
  • Storage. Anything that reads order data goes through the CRUD layer, and if the suite has an HPOS toggle trait, run the test with storage on and off.

Raw postmeta reads return nothing on a store with High-Performance Order Storage enabled, and such a store is not exotic: WooCommerce has recommended HPOS for years. A test that creates its order through WC_Helper_Order and reads it back through wc_get_order() runs against either storage. On my store, with HPOS off, the helper order lived in wp_posts and its meta round-tripped through update_meta_data() and get_meta().

A release means three version strings and a build that excludes files

WordPress reads the plugin header to decide whether an update is newer, your code reads a constant, and your readme declares the Stable tag. All three have to agree, and the failure mode when they do not is quiet: WordPress never offers the update and the store blames your licence server.

#!/bin/bash
# Bump the version in the header, the constant and readme.txt.
set -euo pipefail
new="${1:?usage: bump-version.sh 1.2.3 [dir]}"
dir="${2:-.}"
cd "$dir"

sed -i '' -E "s/^( \* Version: ).*/\1${new}/" sifero-extension.php
sed -i '' -E "s/(define\( 'SIFERO_VERSION', ')[^']*(' \);)/\1${new}\2/" sifero-extension.php
sed -i '' -E "s/^(Stable tag: ).*/\1${new}/" readme.txt

I ran that script on a fixture at 1.0.0, bumped to 1.2.3, and all three locations read 1.2.3 afterwards. WooCommerce needs the same discipline: its header says 11.2.1, WC_WooCommerce::$version holds 11.2.1, and WC_VERSION is defined from that property in define_constants(). The readme.txt shipped in the same tree still says Stable tag: 11.2.0 for that release, which is a fair warning that this is easy to get wrong.

The build has to drop the development leftovers. WooCommerce's own plugins/woocommerce/.distignore is the useful starting point: dotfiles, lock files, *.md, *.map, /tests/, /build/, /node_modules/, phpunit.xml, package.json and the PHPStan configuration. The build itself is one rsync that reads those patterns.

#!/bin/bash
# Build a distributable copy of the plugin, honouring .distignore.
set -euo pipefail
src="${1:-.}"
out="${2:-build/sifero-extension}"

rm -rf "$out"
mkdir -p "$(dirname "$out")"

excludes=()
while IFS= read -r line; do
	case "$line" in
		''|'#'*) continue ;;
	esac
	excludes+=(--exclude="$line")
done < "$src/.distignore"
excludes+=(--exclude='.distignore')

rsync -a "${excludes[@]}" "$src/" "$out/"

My fixture contained a tests/ directory, a node_modules/ tree, a build/ directory, a .github/ folder, package.json and phpunit.xml. The built plugin held six files: the main plugin file, readme.txt, src/plugin.php, assets/icon.svg, vendor/foo/src/Foo.php and composer.json. The composer file stayed because WooCommerce's exclusion file keeps composer files, and the comment in it explains why.

uninstall.php removes your data and leaves the store's orders alone

WordPress runs uninstall.php when a plugin is deleted, if the file exists. It guards itself with defined( 'WP_UNINSTALL_PLUGIN' ) || exit;, and the two rules that matter are both about restraint. Delete what your extension created, and delete nothing that the store considers its own records.

WooCommerce's own uninstall.php is the model. It clears its scheduled hooks, requeues Action Scheduler jobs, and then wraps anything destructive in defined( 'WC_REMOVE_ALL_DATA' ) && true === WC_REMOVE_ALL_DATA: dropping tables, deleting posts and removing roles only happen when the site owner has put that constant in wp-config.php. Product and order data survive a plugin deletion otherwise, which is the correct default for a store.

<?php
/**
 * Runs when the extension is deleted from the plugins screen.
 */

defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

// Settings belong to the extension; remove them for every site.
delete_option( 'sifero_licence_key' );
delete_option( 'sifero_debounce_seconds' );

// Cached licence responses are derived data.
delete_transient( 'sifero_licence_status' );

// The extension's own table, dropped only when the owner opts in.
if ( defined( 'SIFERO_REMOVE_ALL_DATA' ) && true === SIFERO_REMOVE_ALL_DATA ) {
	global $wpdb;

	$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}sifero_deliveries" ); // phpcs:ignore WordPress.DB
}

I ran that file against an option, a transient, an order and an item of order meta. Before: option SIF-1234-ABCD, transient valid, meta CT-9042, order present. After: the option and transient gone, the order and its meta untouched. If your extension keeps a reconciliation record on an order, deleting it on uninstall destroys evidence the store may still need.

What uninstall.php must not do is reference code that is not loaded. WordPress deactivates the plugin before running it, so your classes are not autoloaded and WooCommerce's classes exist only if WooCommerce is still active. A wc_get_orders() call in this file fatals on a site where WooCommerce was deleted first. Direct $wpdb calls and delete_option() are safe.

Declaring HPOS compatibility is a one-hook promise

Undeclared is treated as incompatible. I confirmed that for custom_order_tables: FeaturesController::get_default_plugin_compatibility() returns incompatible, and an undeclared plugin lands in the uncertain bucket that PluginUtil::get_items_considered_incompatible() merges into the incompatible list. Declaring compatibility removes it from that list, and declaring incompatibility puts it there deliberately: WooCommerce renders a warning like 1 Incompatible plugin detected (Hello Dolly) and disables the order-storage setting while such a plugin is present.

<?php
add_action(
	'before_woocommerce_init',
	function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
		}
	}
);

Three details decide whether this works. The hook is before_woocommerce_init, not init: anywhere else, the call returns false and logs a doing_it_wrong notice, which I reproduced from a script after WooCommerce had loaded. The feature id is custom_order_tables, not custom_orders_table; the second string is refused without a message. And the call runs on every request, because WooCommerce reads the declaration at load time. A plugin declaring both features returned compatible: cart_checkout_blocks, custom_order_tables.

The limit of the declaration is that it is a promise, not a proof. WooCommerce does not verify that your extension works on the orders table, so declaring compatibility while reading postmeta directly makes a broken extension look healthy.

One observation closes this course. Every problem across the eighteen lessons had the same shape: something that used to be a local detail of a WordPress site moved, and code that kept its old assumption still returned a value. Orders moved from posts to a table, checkout moved from a form post to the Store API, and a version number moved into three files that nothing keeps in sync. The extension that survives those moves is the one whose assumptions are written down where a test can read them, in a declaration, a hook or a factory, instead of in a habit.

Lab 50 minutes

Take the extension from the previous two labs and give it a release.

  1. Add the HPOS and blocks declarations to the main plugin file and print FeaturesUtil::get_compatible_features_for_plugin().
  2. Call declare_compatibility() from a script that runs after WooCommerce is loaded, and record what it returns.
  3. Write a bump-version.sh updating the header, a version constant and the Stable tag, and run it from 1.0.0 to 1.1.0.
  4. Write a .distignore and a build script, then compare the built directory with the source tree.
  5. Write an uninstall.php that deletes two options and a transient. Seed them with an order and one order meta value, require the file with WP_UNINSTALL_PLUGIN defined, and print what survived.
  6. Using the test framework files, call WC_Helper_Product::create_simple_product() and WC_Helper_Order::create_order(), then print the order id, status and total.

Then answer in one sentence: which of the three version strings a customer would notice if it disagreed, and which one WordPress uses to offer the update.

Show the solution and the reasoning

WordPress compares the header's Version against the version it recorded when the plugin was last updated, so a header that lags means the update is never offered. A readme.txt tag that lags makes the directory listing disagree with what is installed, and a constant that lags makes your own code report the wrong version in a support ticket. Doing this by hand once a month guarantees one gets missed.

$ ./bump-version.sh 1.1.0 ./sifero-extension
$ grep -n "Version:\|SIFERO_VERSION\|Stable tag" \
    sifero-extension/sifero-extension.php sifero-extension/readme.txt
sifero-extension/sifero-extension.php:4: * Version: 1.1.0
sifero-extension/sifero-extension.php:6:define( 'SIFERO_VERSION', '1.1.0' );
sifero-extension/readme.txt:2:Stable tag: 1.1.0

The declarations gave the cleanest signal. With the plugin active and declaring both features, get_compatible_features_for_plugin() returned compatible: cart_checkout_blocks, custom_order_tables. Calling it from a script after WooCommerce had loaded returned false and a doing_it_wrong notice naming before_woocommerce_init.

The uninstall run is worth repeating until the shape is automatic. Mine deleted the option and the transient and left the order and its meta where they were. A DELETE against wp_posts or wc_orders in an extension's uninstall.php is a bug: those tables are WooCommerce's.

Two limits on the testing half. The framework asserts nothing about your HTTP client, so the response-classification tests are yours to write and the ones that fail in production when missing. And the suite needs the WordPress test library and its own database, a real cost for a small extension; the cheap substitute is a script that creates a product and an order through the CRUD layer. Not a test suite, but it catches the storage assumption.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of WooCommerce Extension Development. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.