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

Source: https://voxgig.com/howto/test-a-plugin-through-every-lifecycle-stage

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://github.com/voxgig/plugin) 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](https://fastify.dev/docs/latest/Reference/Plugins/) | 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](https://pluggy.readthedocs.io/en/stable/) | 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](https://rollupjs.org/javascript-api/) | 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](https://nodejs.org/api/test.html) | 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.

```js title="host.mjs"
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.

```js title="plugin.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test lifecycle.test.mjs
```

```text output
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](https://jestjs.io/docs/cli#--detectopenhandles), 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

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

- [Contract test an API you do not own](/howto/contract-test-an-api-you-do-not-own)

## Last verified

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

[^1]: The [Fastify plugin reference](https://fastify.dev/docs/latest/Reference/Plugins/) says
that error handling during plugin loading is done by `avvio`. That package's
[registry record](https://registry.npmjs.org/avvio) 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.

[^2]: [pluggy](https://pluggy.readthedocs.io/en/stable/) 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`](https://pluggy.readthedocs.io/en/stable/api_reference.html#pluggy.PluginManager.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.

[^3]: Jest's [`--detectOpenHandles`](https://jestjs.io/docs/cli#--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.