@tapjs/create-plugin
Create a tap plugin with npm init @tapjs/plugin (or yarn create @tapjs/plugin).
This will create the basic scaffolding required to write a tap
plugin.
Writing Plugins
Tap plugins are typically a function that takes a Test object
and optionally an options object, and returns an object that is
used as the extension.
For example, a very simple "hello, world" plugin:
import { TapPlugin, TestBase } from '@tapjs/core'
export interface HelloSayer {
hello: (who?: string) => string
}
export const plugin: TapPlugin<HelloSayer> = (t: TestBase) => {
return {
hello: (who: string = 'world') => {
console.error(`${t.name} says "Hello, ${who}!"`)
},
}
}
As a more realistic example, to add a isString method to all
tests, you could define a plugin like this:
import {
TestBase,
TapPlugin,
Extra,
MessageExtra,
normalizeMessageExtra,
} from '@tapjs/core'
export class StringTester {
#t: TestBase
constructor (t: TestBase) {
this.#t = t
}
isString (
s: any,
...[msg, extra]: MessageExtra
) {
const args = [msg, extra] as MessageExtra
const me = normalizeMessageExtra('expect string', args)
if (typeof s === 'string') {
return this.#t.pass(msg, extra)
} else {
return this.#t.fail(msg, {
...extra,
value: s,
wanted: 'string',
found: typeof s,
})
}
}
}
export const plugin: TapPlugin<StringTester> =
t => new StringTester(t)
The object returned by a plugin can be any sort of thing. If you
want to use a class with private properties, that's totally fine
as well. Whatever type is expected as the second argument will
be combined with the built-in TestBaseOpts interface, and
required when tests are instantiated.
import { TestBase, TapPlugin, AssertionOpts } from '@tapjs/core'
import { cleanup, render } from '@testing-library/react'
import { ReactElement } from 'react'
import { RenderResult } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
class ReactTest {
#result?: RenderResult
constructor(node: ReactElement) {
if (node) {
this.#result = render(node)
}
}
findByText(text: string) {
return this.#result?.findByText(text)
}
}
export interface ReactTestOpts {
node?: ReactElement
}
export const plugin: TapPlugin<ReactTest, ReactTestOpts> = (t, opts) =>
new ReactTest(node)
When loaded, this plugin would make it so that every test may
provide a { node: ReactElement } option, and would then have a
t.findByText() method that returns the results.
Accessing the Constructed Plugged-In Test Object
If you need access to the constructed Test object, you can get
that after the initial plugin load, via t.t. However, it will
be undefined until all plugins are done loading, so do not rely
on it being present in your plugin function itself or any
constructors it calls synchronously.
export const plugin = (t: TestBase) => {
return {
someMethod() {
},
}
}
Plugin Requirements
- Plugins must be able to be loaded both with
require() and
import(). This maintains tap's ability to run in all
JavaScript dialects.
- Plugins must export at least one of
loader, importLoader,
plugin, or config.
- Plugins should be written in TypeScript, or failing that,
provide complete type information so that users' tests can
infer the types of added properties and methods appropriately.
Cleaning Up
A common use-case for plugins is to manage some state that needs
to be disposed at the end of the test.
In those cases, you can leverage the
@tapjs/after
plugin's functionality by testing to see whether it is loaded.
For example, if you wanted to ruin the process object for some
reason:
import { TapPlugin, TestBase } from '@tapjs/core'
import { plugin as AfterPlugin } from '@tapjs/after'
export class BreakProcess {
#restore?: () => void
#t: TestBase
#didCleanup: boolean = false
constructor (t: TestBase) {
this.#t = t
}
restoreProcess() {
if (this.#restore) {
this.#restore()
}
}
breakProcess() {
if (this.#restore) return
const originalProcess = process
global.process = { not: 'the actual process object' }
this.#restore = () => {
global.process = originalProcess
this.#restore = undefined
}
if (this.#t.t.pluginLoaded(AfterPlugin) && !this.#didCleanup) {
this.#t.t.after(() => this.restoreProcess())
}
}
}
(Don't actually write this plugin, though. Just use
@tapjs/intercept
for this, it's included with tap already, and does the sort of
cleanup described here.)
Plugin Collisions
The first plugin in a list that provides a given method or
property will be the one that "wins", as far as the object
presented in test code is concerned.
However, within a given plugin, it only sees itself and the
TestBase object it's been given. For example, if returning an
object constructed from a class defined in the plugin, this
will refer to that object, always.
export const plugin = (t: TestBase) => {
return {
myVal: 4,
getFirstPluginVal() {
return this.myVal
},
getFour() {
return 4
},
}
}
export const plugin = (t: TestBase) => {
return {
myVal: 5,
getSecondPluginValue() {
return this.myVal
},
getFour() {
return 'four'
},
}
}
Then in the test:
import t from 'tap'
console.log(t.myVal)
console.log(t.getFour())
console.log(t.getFirstPluginVal())
console.log(t.getSecondPluginVal())
While collisions like this are usually not a big deal, this
behavior can get confusing. So, it's best to name properties and
methods somewhat uniquely, so as to make collisions less likely.