Testing a plugin
TL;DR: most plugin logic is covered by fast Jest unit tests (adapters, models) and jsdom render tests (components); browser tests drive the built app.
The plugin templates ship with Jest
preconfigured, so pnpm test works out of the box.
Adapter tests
Construct the adapter from its config schema and read features. Gff3Adapter's
own test, in full:
import { firstValueFrom } from 'rxjs'
import { toArray } from 'rxjs/operators'
import Gff3Adapter from './Gff3Adapter.ts'
import configSchema from './configSchema.ts'
describe('adapter can fetch features from volvox.gff3', () => {
let adapter: Gff3Adapter
beforeEach(() => {
adapter = new Gff3Adapter(
configSchema.create({
gffLocation: {
localPath: require.resolve('../test_data/volvox.sort.gff3'),
},
}),
)
})
it('test getfeatures on gff plain text adapter', async () => {
const features = adapter.getFeatures({
refName: 'ctgB',
start: 0,
end: 200000,
})
expect(await adapter.hasDataForRefName('ctgA')).toBe(true)
expect(await adapter.hasDataForRefName('ctgB')).toBe(true)
const featuresArray = await firstValueFrom(features.pipe(toArray()))
// There are only 4 features in ctgB
expect(featuresArray.length).toBe(4)
const featuresJsonArray = featuresArray.map(f => f.toJSON())
expect(featuresJsonArray).toMatchSnapshot()
})
})
describe('discontinuous feature parsing', () => {
it('keeps every segment of a CDS that shares one ID across lines', async () => {
const adapter = new Gff3Adapter(
configSchema.create({
gffLocation: {
localPath: require.resolve('../test_data/disjoint_cds.gff3'),
},
}),
)
const features = adapter.getFeatures({
refName: 'ctgA',
start: 0,
end: 1000,
})
const featuresArray = await firstValueFrom(features.pipe(toArray()))
const gene = featuresArray[0]!.toJSON()
const mrna = gene.subfeatures![0]!
const cds = mrna.subfeatures!.filter(f => f.type === 'CDS')
expect(cds.length).toBe(3)
expect(cds.map(f => f.start)).toEqual([0, 199, 399])
})
})
require.resolve for localPath keeps the path relative to the test file
rather than the working directory. getFeatures returns an rxjs Observable,
so firstValueFrom(obs.pipe(toArray())) turns the stream into a promise of an
array. Snapshot f.toJSON() to lock the whole shape; assert on specific fields
when the point of the test is one of them, as the second block does.
Model and session tests
createTestSession from @jbrowse/web/testUtils builds a full root model with
the core plugins and a main-thread RPC driver, so you can exercise session
actions, views, widgets, and display models without a browser. Mock the worker
factory, since jsdom has no real workers:
import { createTestSession } from '@jbrowse/web/testUtils'
import { doSubmit } from './components/doSubmit.ts'
jest.mock('@jbrowse/web/makeWorkerInstance', () => () => {})
function addAsm(session: ReturnType<typeof createTestSession>, name: string) {
session.addAssemblyConf({
name,
sequence: {
trackId: `ref-${name}`,
type: 'ReferenceSequenceTrack',
adapter: {
type: 'FromConfigSequenceAdapter',
features: [
{
refName: 'ctg',
uniqueId: name,
start: 0,
end: 10,
seq: 'acgtacgtac',
},
],
},
},
})
}
test('adding a track for an assembly not open in the view notifies the user', () => {
const session = createTestSession()
addAsm(session, 'asmA')
addAsm(session, 'asmB')
const view = session.addView('LinearGenomeView', {
displayedRegions: [
{ assemblyName: 'asmA', refName: 'ctg', start: 0, end: 10 },
],
})
const widget = session.addWidget('AddTrackWidget', 'addTrackWidget', {
view: view.id,
})
widget.setTrackData({ uri: 'foo.bam', locationType: 'UriLocation' })
widget.setAssembly('asmB')
doSubmit({ model: widget })
// track is still added to the session...
expect(session.tracks.some(t => t.assemblyNames?.[0] === 'asmB')).toBe(true)
// ...but not shown in the asmA view, and the user is told why
expect(view.tracks.length).toBe(0)
expect(
session.snackbarMessages.some(
m => m.level === 'warning' && m.message.includes('asmB'),
),
).toBe(true)
})
createTestSession accepts sessionSnapshot, jbrowseConfig, adminMode, and
preloaded runtimePlugins, and returns the session model, so addView,
addWidget, showWidget, and addSessionTrackConf are all available. To test
a custom plugin's pluggable elements, pass it via runtimePlugins.
A view you add afterwards has no width, and view.width throws. There is no
layout in jsdom, so nothing sizes a view on its own: createTestSession sets
800px on the views your sessionSnapshot declares, and only those. A view from
a later session.addView(...) is unsized, and the getter throws
width undefined, make sure to check for model.initialized the moment anything
reads it — which most block and coordinate logic does. Either declare the view
in the snapshot, as the component test below does, or call view.setWidth(800)
straight after adding it, which is what the integration tests across the repo
do.
FromConfigSequenceAdapter is what keeps a session test off the network: the
assembly's sequence is inline, so nothing is fetched and the assembly is ready
immediately.
Component tests
React components render in jsdom with @testing-library/react. Build a model
with createTestSession, pass it to the component, and assert on the DOM:
import { createJBrowseTheme } from '@jbrowse/core/ui'
import { createTestSession } from '@jbrowse/web/testUtils'
import { ThemeProvider } from '@mui/material'
import { render } from '@testing-library/react'
import GridBookmarkWidget from './GridBookmarkWidget.tsx'
import type { GridBookmarkModel } from '../model.ts'
jest.mock('@jbrowse/web/makeWorkerInstance', () => () => {})
const theme = createJBrowseTheme()
function setup() {
const session = createTestSession({
sessionSnapshot: {
views: [
{
type: 'LinearGenomeView',
bpPerPx: 1,
offsetPx: 0,
displayedRegions: [
{ assemblyName: 'volvox', refName: 'ctgA', start: 0, end: 1000 },
],
},
],
},
})
const widget = session.addWidget(
'GridBookmarkWidget',
'GridBookmark',
) as GridBookmarkModel
widget.addBookmark({
assemblyName: 'volvox',
refName: 'ctgA',
start: 0,
end: 100,
})
return widget
}
function renderWidget(widget: GridBookmarkModel) {
return render(
<ThemeProvider theme={theme}>
<GridBookmarkWidget model={widget} />
</ThemeProvider>,
)
}
test('single grid renders for bookmarks/highlights, two for both', () => {
const widget = setup()
widget.setGridView('bookmarks')
const { container, rerender } = renderWidget(widget)
expect(container.querySelectorAll('.MuiDataGrid-root')).toHaveLength(1)
widget.setGridView('both')
rerender(
<ThemeProvider theme={theme}>
<GridBookmarkWidget model={widget} />
</ThemeProvider>,
)
expect(container.querySelectorAll('.MuiDataGrid-root')).toHaveLength(2)
})
Wrap in a ThemeProvider built by createJBrowseTheme as that does: JBrowse
components read the JBrowse theme, not MUI's default.
Two jsdom gotchas:
Blobhas notext()method — useFileReader.readAsText.- Virtualized trees/grids need a mocked measured height to render any rows —
mock
useMeasureto return a large height.
Browser (end-to-end) tests
Puppeteer tests in products/jbrowse-web/browser-tests/ drive the built app and
compare rendered canvases against committed PNGs. Rendering is async, so never
assert on a fixed timeout; wait on a signal:
data-testid="loading-overlay"count reaching0means all tracks in a view finished loading.- The
data-display-drawnattribute (e.g. onsynteny_canvas) gates on a display'ssettledgetter: drawn and not refetching.
Run with pnpm test:browser (builds @jbrowse/web first) or
pnpm test:browser:update to refresh snapshots. See
agent-docs/reference/TEST_INFRASTRUCTURE.md for the full harness reference.
Running tests
pnpm test <directory>- Jest for a subtree (prefer over the full suite while iterating).pnpm test:browser- build and run the Puppeteer suite.