← Back to blog

How to test that your WordPress plugin activation routine actually ran

The SandyWP team 7 min read

Activate the plugin on a WordPress install that has never seen it before, then assert on the state it was supposed to create: the table, the option row, the capability, the scheduled event. If you only ever activate on your own dev site, the table already exists and the option row is already there, so the test passes whether or not your code ran.

That is the whole problem with testing activation. The success condition and the leftover state from last week look identical.

What "activation" actually is

Activation is a separate admin request that WordPress performs on your plugin, and four things happen in it.

WordPress loads the plugin once in a scrape request to see whether it explodes, loads it again for real, fires the activation hooks, and then redirects you to the Plugins screen with a green box.

The scrape step is plugin_sandbox_scrape(), documented as a function that "loads a given plugin attempt to generate errors". activate_plugin() itself is documented as attempting "activation of plugin in a 'sandbox' and redirects on success".

The consequence is that a fatal error during activation does not produce a stack trace on your screen. The redirect never happens, and you get a generic admin message instead of the line number.

Four reasons the activation hook silently does not run

None of these throw anything you will notice. The plugin activates, the site works, and the state you needed is missing.

1. The hook was registered too late

register_activation_hook() has to be called when the main plugin file is parsed, not from inside another hook.

The reference is blunt about it: the function "will not work if called at the time of triggering any hook, such as plugins_loaded, init". Wrapping your bootstrap in add_action( 'plugins_loaded', ... ), which is otherwise good practice, is enough to disable activation entirely.

The second version of this is the file path. The handbook notes the first parameter refers to "your main plugin file, which is the file in which you have placed the plugin header comment", and that if you call it from any other file "you must update the first parameter to correctly point to the main plugin file".

A modern plugin with src/Bootstrap.php and an autoloader gets this wrong often, because __FILE__ inside that class file is not the plugin's main file.

2. Activation runs in a different scope from every other request

During activation the main plugin file is included inside activate_plugin(), not in the global scope.

So a variable that behaves like a global on every normal page load is a local variable inside a function during this one request. Code that reads it gets null, the routine takes the wrong branch, and nothing errors.

This is also why an activation routine that calls a function defined in a file loaded on init fails. init has not fired yet.

3. Reactivation is not activation

Deactivating and reactivating is the test everyone runs, and it is the one test guaranteed to pass.

add_option() does nothing if the option already exists. A CREATE TABLE IF NOT EXISTS does nothing if the table is there. A capability added to a role stays on the role after deactivation, because deactivation does not remove it.

So the second activation is a no-op that looks exactly like a success. The only meaningful activation test is the first one on a site that has never had the plugin installed.

Deleting the plugin does not fix this either unless its uninstall routine is complete, which is a separate thing worth testing on its own.

4. Updates do not activate anything

The activation hook has fired "only during plugin activation" and not during automatic updates since WordPress 3.1.

Users who already have your plugin will never run your activation code again, no matter how many versions ship. Anything the new version needs has to go in an upgrade routine, and that routine is tested differently.

Core's own activate_plugin() carries a $silent parameter documented as "whether to prevent calling activation hooks", which is how bulk and programmatic activations skip your code deliberately.

The routine

Do this on a throwaway install so that the "never had this plugin" precondition is real, not something you hope is still true.

1. Start a clean sandbox and turn debugging on. Enable WP_DEBUG and WP_DEBUG_LOG from debug mode before you install anything, so activation notices land in debug.log rather than disappearing behind the redirect.

2. Record the before state. Over SSH, with WP-CLI already installed:

wp db query "SHOW TABLES LIKE '%yourplugin%'"
wp option list --search='yourplugin_*' --format=table
wp cron event list

All three should be empty. If they are not, the sandbox is not clean and the test is already worthless.

3. Activate through the admin at least once. This is the path your users take, and it is the only one that runs the sandbox scrape. Watch for the green success box, and specifically for the message about the plugin generating characters of unexpected output during activation.

That message means something in your plugin printed before the redirect headers. A stray blank line after ?>, a var_dump left in, or a warning with display enabled will all produce it, and it is a real bug even though the plugin appears to work.

4. Then repeat it from the command line, because the admin hides the error.

wp plugin activate your-plugin

WP-CLI activates in the same process it is running in, so a fatal in your activation routine prints the actual PHP error and file path instead of the admin's generic failure notice. That difference is worth the second run on its own.

5. Assert on the state, not on the green box. Re-run the three commands from step two and check each thing your routine claims to do:

wp db query "DESCRIBE wp_yourplugin_events"
wp option get yourplugin_settings --format=json
wp option get yourplugin_db_version
wp cron event list | grep yourplugin
wp cap list administrator | grep yourplugin

An empty result here with a green success box in the admin is exactly the failure mode this whole post is about.

6. Check the second activation separately. Deactivate, reactivate, and confirm you have one scheduled event rather than two, one row rather than a duplicate, and no reset of settings the user had changed.

That is the reactivation bug in the other direction, and it is common in plugins that schedule cron without checking wp_next_scheduled() first.

Multisite is a different code path

If your plugin supports networks, the activation callback receives a $network_wide boolean, and a network activation fires your hook once for the whole network rather than once per site.

Everything above still applies, but the assertions have to run against a subsite that is not the one the super admin happened to be on. That case has its own routine.

Make it repeatable

The expensive part of this test is getting back to a genuinely clean install every time, and that is the part a sandbox removes.

Pin the starting state as a blueprint or a template so every run starts from the same WordPress version, PHP version and plugin set. If your plugin lives on GitHub, the PR preview gives you a fresh install per pull request, which means the branch that changed the activation routine gets tested on a site that has never run it.

There is more on the wider workflow on our plugin developers page.

What a sandbox will not catch

Three activation failures are environmental, and testing on a clean sandbox will not reproduce them.

Database privileges. Activation routines that create tables fail on hosts where the WordPress database user cannot run CREATE TABLE. Your sandbox user can, so this always passes here and fails for a customer on shared hosting.

MySQL version and mode differences. A CREATE TABLE statement that is fine on one server can be rejected on another for strict mode or default behaviour reasons. Test the SQL against the versions you claim to support, not just the one you develop on.

Scale and time. An activation routine that loops existing posts, or a network activation that loops sites, finishes instantly on an empty install and times out on a real one. If you loop anything, build a large data set at least once.

Those limits are worth stating plainly, because a routine that passes every check above can still fail on the first customer who activates it.

The short version

Activation only genuinely happens once per site, so it can only genuinely be tested on a site that has never had your plugin.

Assert on the table, the option, the capability and the cron event afterwards. The green success box in wp-admin is not evidence that your code ran.