How-to › Maintain libraries across languages

How to test a plugin through every lifecycle stage#

Drive one plugin from registration through activation to deactivation inside a test, and assert what it released, without touching a real network.

Audience
Library maintainer
Level
intermediate
Topic
Design a plugin system
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A lifecycle host with status assertionsThe host tracks status, so teardown and failure paths have something to assertA host that has to expose its state, and more test cases to writeThe plugin model has no state beyond the hook
pluggy PluginManager in a testA Python host, where check_pending also catches hooks that match nothingHook calls without a lifecycle, so resources are yours to trackYou need to assert what a plugin released
A real build in a testA bundler plugin, where only a real build exercises the hooks in orderA full build per test, which is slower than a unit testThe plugin logic can be exercised without a build
Testing only the hookA pure transform with no resources and no teardownNothing until the plugin takes a handle, then a suite that hangsThe 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.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. 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

  2. pluggy describes itself as the crystallized core of plugin management and hook calling for pytest. The docs add that pytest is itself composed as a set of pluggy plugins, so the host is a plugin of its own plugin system. Its check_pending verifies that every hook not matched to a specification is marked optional, and raises PluginValidationError otherwise. That is a check on names, and a name cannot leak a timer. ↩︎ Back to text

  3. Jest’s --detectOpenHandles is documented as an attempt to collect and print the open handles preventing Jest from exiting cleanly, for use when --forceExit has become necessary and the reason is wanted. It implies --runInBand, so the tests run serially, and it is implemented with async_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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.