The suite passes and then hangs. Every assertion succeeded, the last test printed, and the runner sits there until a timeout kills it. A plugin started an interval during activation, no test ever deactivated it, and the handle keeps the process alive. In production the same plugin leaks a connection per reload.
What you get
You will end up with one test that walks a plugin through declaration, loading, activation, and deactivation, asserting at each stage. You also get a failure case that proves a broken activation leaves nothing running. This is for you if you maintain a library with a plugin interface.
Short answer
Drive the plugin through every stage in one test, not only the live path. Inject a fake for each resource it takes, activate it, deactivate it, and assert the fake was closed and the status is back where it started. Test the failure path too: an activation that throws must leave the plugin loaded rather than half live, holding a connection nobody will close.
You will need
Node 22 or later, and a plugin whose host you control or can stand in for. Any host that can be asked what state a plugin is in will do. The three stages used here, declared, loaded, and live, are the ones the Voxgig plugin library names, and the test shape is the same whatever a given host calls them.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A lifecycle host with status assertions | The host tracks status, so teardown and failure paths have something to assert | A host that has to expose its state, and more test cases to write | The plugin model has no state beyond the hook |
| pluggy PluginManager in a test | A Python host, where check_pending also catches hooks that match nothing | Hook calls without a lifecycle, so resources are yours to track | You need to assert what a plugin released |
| A real build in a test | A bundler plugin, where only a real build exercises the hooks in order | A full build per test, which is slower than a unit test | The plugin logic can be exercised without a build |
| Testing only the hook | A pure transform with no resources and no teardown | Nothing until the plugin takes a handle, then a suite that hangs | The plugin opens anything at all |
The split is between plugins that hold resources and plugins that do not. One test per input is all a pure transform needs. Anything that opens a connection, starts a timer, or registers a listener has a second contract, which is what it gives back, and only a lifecycle test can check that. Hook-only systems are quicker to test and they let a leak through, because nothing in the test ever asks the plugin to stop.2
Give every stage an assertion
Four transitions, each with something observable on the other side.
async activate(name) {
const entry = plugins.get(name)
try {
await entry.definition.activate(entry.instance)
} catch (err) {
set(name, 'loaded')
throw err
}
return set(name, 'live')
}
The catch block is the design rather than defensive code. A failed activation has to put the plugin
back where it was. The alternative is a plugin the host believes is live, whose deactivate was
never written to run against a partial start.
The host can only undo what the host did. It sets the status back to loaded, and it has no way to
know how far into activate the plugin got. So whichever half of that function acquires something
first has to release it on the way out. The plugin here starts its interval and then subscribes, and a
feed that refuses the subscription would otherwise leave a timer running under a plugin the host
reports as loaded. Nothing ever clears it, and the process will not exit.
Keep the resources on the instance rather than in module scope. A timer stored in a closure cannot be asserted on, and a second instance of the plugin overwrites the first. That is also what lets one test run two instances side by side, which is how a host with several plugins actually behaves.
Inject the resource, and assert on the fake
The fake belongs to the test, which is what makes release observable.
export function fakeConnection() {
const state = { open: true, subscriptions: [], closed: 0 }
return {
state,
subscribe: async (topic) => { state.subscriptions.push(topic) },
close: async () => { state.open = false; state.closed++ },
}
}
Counting closes rather than recording a boolean catches the other failure: a deactivate called
twice that closes twice, which throws against a real client. Count, and assert the count.
Keep the fake in the test file rather than in a shared helper, at least until a third test needs it. A fake that grows features for every caller stops being a stand-in and becomes a second implementation with its own bugs and no tests to catch them.
Check it worked
Walk both paths and print the state after every transition.
node demo.mjs
happy path
after declare status declared connection open timer stopped
after load status loaded connection open timer stopped
after activate status live connection open timer running
after deactivate status loaded connection closed timer stopped
subscriptions meters, closes 1
activation fails
raised: feed refused the subscription
after the failure status loaded connection open timer stopped
Read the last line first. The activation threw, and the plugin is back at loaded with no timer running. A host without that rollback would report live, and the next deactivation would try to clear a timer that was never set and close a subscription that was never made.
The happy path is worth reading as a table rather than as four assertions. Each row names the stage and the two resources, so a reviewer can see at a glance that the connection closes exactly once and the timer stops. That is the property a hook-only test has no way to express.
node --test lifecycle.test.mjs
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 119.810171
When it goes wrong
The suite passes but does not exit. Something started a timer or a socket that no test stopped. Run the suite with the runner’s open-handle report, and add a deactivation to the test that started it.3
Deactivation throws on a plugin that never activated. The teardown assumes resources exist. Make
deactivate safe to call from loaded as well as from live, and test that. Hosts call it during
shutdown without asking what state anything is in.
A second activation fails. The plugin kept state from the first one. Assert that activate, deactivate, activate leaves the same observable result as the first activation did.
The test passes and production leaks. The fake is more forgiving than the real client. Check the real client’s behavior for a double close once, and make the fake match it.
When not to do this
Do not build a lifecycle for a plugin that has none. A transform that takes a value and returns one needs test cases, not stages, and a host that insists on three of them adds ceremony to every plugin anyone writes.
Do not assert on internal fields the host does not publish. A test reading private state passes until a refactor, and then it fails without telling anyone what actually broke. Assert through the status the host reports, and through the fakes you injected. Those two surfaces are the ones the host promises to keep.
Do not test teardown only in the final case. Put it in every test that activates, so a leak fails the test that caused it rather than the one that ran last. A helper that activates, yields, and deactivates makes that the default rather than a discipline.
Related how-tos
Last verified
Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.
Footnotes
-
The Fastify plugin reference says that error handling during plugin loading is done by
avvio. That package’s registry record describes it as asynchronous bootstrapping of Node applications, and places its repository under the same organization as the framework. The lifecycle a host offers its plugins is, in that case, a dependency of the host. ↩︎ Back to text -
pluggy describes itself as the crystallized core of plugin management and hook calling for
pytest. The docs add thatpytestis itself composed as a set of pluggy plugins, so the host is a plugin of its own plugin system. Itscheck_pendingverifies that every hook not matched to a specification is marked optional, and raisesPluginValidationErrorotherwise. That is a check on names, and a name cannot leak a timer. ↩︎ Back to text -
Jest’s
--detectOpenHandlesis documented as an attempt to collect and print the open handles preventing Jest from exiting cleanly, for use when--forceExithas become necessary and the reason is wanted. It implies--runInBand, so the tests run serially, and it is implemented withasync_hooks. The page adds that it carries a significant performance penalty and is for debugging only. It is a flag whose documentation warns against using it, offered as the way to find out why the other flag was needed. ↩︎ Back to text