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.
| Piece | What 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.
Take the extension from the previous two labs and give it a release.
- Add the HPOS and blocks declarations to the main plugin file and print
FeaturesUtil::get_compatible_features_for_plugin(). - Call
declare_compatibility()from a script that runs after WooCommerce is loaded, and record what it returns. - Write a
bump-version.shupdating the header, a version constant and theStable tag, and run it from 1.0.0 to 1.1.0. - Write a
.distignoreand a build script, then compare the built directory with the source tree. - Write an
uninstall.phpthat deletes two options and a transient. Seed them with an order and one order meta value, require the file withWP_UNINSTALL_PLUGINdefined, and print what survived. - Using the test framework files, call
WC_Helper_Product::create_simple_product()andWC_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.