Skip to main content

Unit Testing

JsPlumb has a test suite consisting of almost 12 000 unit tests, covering every aspect of the library including its integrations with Angular, React, Vue and Svelte. We like having this many unit tests: it gives us confidence both in the quality of the library and in making updates and adding new features.

To assist us in testing JsPlumb, we use jsPlumbToolkitTestHarness - a class which takes a Toolkit and Surface as argument, and then exposes a wide number of methods you can use to simulate user activity in the UI. We've included the full list of methods below, but first we'll just give you a quick overview.

Example

For reference, we'll use this setup:

We want to run a few tests on this, specifically:

  • We'll drag an edge from Node 1 to Node 2
  • We'll drag Node 200 pixels to the right
  • We'll click on Node 2 and edit its label

In order to do this, this is the code (using Vanilla JS):

import { jsPlumbToolkitTestHarness, newInstance } from "@jsplumbtoolkit/browser-ui"

const toolkit = newInstance()
const surface = toolkit.render(...)

const tks = new jsPlumbToolkitTestHarness(toolkit, surface)
tks.dragConnection(["1", ".connect"], "2")
tks.dragVertexBy("1", 250, 0)
tks.tapOnNode("2")

Here's the code in action - click Run Tests to kick things off:

Angular Testing

The test harness integrates nicely with Angular's TestBed.

Component to test

Imagine we have some AppComponent that has a surface and a controls component in it:


import {Component, ViewChild} from '@angular/core'

import { AbsoluteLayout, EVENT_TAP } from "@jsplumbtoolkit/browser-ui";
import {NodeComponent} from "./node.component"
import {jsPlumbSurfaceComponent} from "@jsplumbtoolkit/browser-ui-angular"

@Component({
template:`<div>
<jsplumb-surface [renderParams]="renderOptions" [view]="viewOptions" toolkitId="testing" surfaceId="testing"></jsplumb-surface>
<jsplumb-controls surfaceId="testing"></jsplumb-controls>
`,
selector:"app-component"
})
export class AppComponent implements AfterViewInit {

@ViewChild(jsPlumbSurfaceComponent) surfaceComponent!:jsPlumbSurfaceComponent

tapCount = 0

renderOptions = {
layout:{
type:AbsoluteLayout.type
}
}

viewOptions = {
nodes:{
default: {
component: NodeComponent,
events: {
[EVENT_TAP]: () => this.tapCount++
}
}
}
}

ngAfterViewInit(): void {
this.surface.toolkit.load({
data:{
nodes:[
{ id:"1", left:50, top:50 },
{ id:"2", left:250, top:250 }
]
}
})
}
}

Test code

To use a test harness in the test spec for this component is straightforward. The trick is to add a beforeEach which appropriately configures the TestBed and sets up a jsPlumbToolkitTestHarness.

import {ComponentFixture, TestBed} from '@angular/core/testing'
import { AppComponent } from './app.component';
import {BrowserModule} from "@angular/platform-browser"
import {BrowserUIAngular, jsPlumbToolkitModule} from "@jsplumbtoolkit/browser-ui-angular"
import {CUSTOM_ELEMENTS_SCHEMA} from "@angular/core"
import {jsPlumbToolkitTestHarness, Surface} from '@jsplumbtoolkit/browser-ui'

describe('AppComponent', () => {

let fixture: ComponentFixture<AppComponent>;
let el:HTMLElement
let app:AppComponent
let surface:Surface
let toolkit:BrowserUIAngular
let harness:jsPlumbToolkitTestHarness

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent],
imports: [BrowserModule, jsPlumbToolkitModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges()
el = fixture.nativeElement as HTMLElement
app = fixture.componentInstance;

surface = fixture.componentInstance.surface.surface
toolkit = fixture.componentInstance.surface.toolkit
harness = new jsPlumbToolkitTestHarness(toolkit, surface)

});

it('Should allow user to drag an edge', () => {
// no edges at first
expect(toolkit.getAllEdges().length).toBe(0)

// drag a connection from the ".connect" element of node 1 to node 2.
harness.dragConnection(["1", ".connect"], "2", {})

// ensure the edge was added.
expect(toolkit.getAllEdges().length).toBe(1)
})

it('Should support node drag', () => {
const node1 = toolkit.getNode("1")
expect(node1.data['left']).toBe(50)
expect(node1.data['top']).toBe(50)

harness.dragVertexBy("1", 150, 350)

// ensure the node was dragged in X
expect(node1.data['left']).toBe(200)
// ensure the node was dragged in Y
expect(node1.data['top']).toBe(400)

})

it('Should allow tap on a node', () => {
harness.tapOnNode("1")
// ensure the tap was detected.
expect(app.tapCount).toBe(1)
})

Example project

The code shown here is available on Github at https://github.com/jsplumb-demonstrations/angular-testing.


React / NextJS Testing

It is straightforward to use the test harness alongside Cypress to perform component testing in a React / NextJS app. The only trick is getting access to the component inside which JsPlumb is rendering - but we'll show you how you can do that below.

Component to test

Imagine you have this canvas.component.tsx that you want to test:

import {JsPlumbToolkitSurfaceComponent, newInstance} from "@jsplumbtoolkit/browser-ui-react"
import {MutableRefObject, useEffect, useRef, useState} from "react"

import {AbsoluteLayout, EVENT_CANVAS_CLICK, EVENT_TAP} from "@jsplumbtoolkit/browser-ui"

export default function() {

const toolkit = newInstance()
const [tapCount, setTapCount] = useState(0)
const initialized = useRef(false)

const surfaceRef:MutableRefObject<JsPlumbToolkitSurfaceComponent> = useRef(null as unknown as JsPlumbToolkitSurfaceComponent)

const view = {
nodes:{
default:{
jsx:(ctx) => <div className="test-node" data-jtk-target="true">
<h2>{ctx.data.label}</h2>
<div className="connect" data-jtk-source="true"/>
</div>,
events: {
[EVENT_TAP]: (p) => toolkit.setSelection(p.obj)
}
}
}
}

const renderParams = {
layout:{
type:AbsoluteLayout.type
},
events:{
[EVENT_CANVAS_CLICK]: () => setTapCount(tapCount + 1)
}
}

useEffect(() => {
if (!initialized.current) {
initialized.current = true

toolkit.load({
data:{
nodes:[
{ id:"1", left:50, top:50, label:"Node 1" },
{ id:"2", left:250, top:250, label:"Node 2" }
]
}
})
}
})

return <div className="container">
<div className="canvas">
<JsPlumbToolkitSurfaceComponent toolkit={toolkit} renderParams={renderParams} view={view} ref={surfaceRef}/>
</div>
</div>
}

Test code

The key piece of the code shown below are the two methods at the top - getJsPlumbContext and createTestHarness. Inside our test we call createTestHarness and pass in a function to invoke once the test harness has been created. Together, these methods performs a few steps:

  1. First, createTestHarness executes cy.get(".jtk-surface"), which instructs Cypress to look for a DOM element configured as a surface.
  2. We then pass the DOM element found into the getJsPlumbContext method.
  3. getJsPlumbContext attempts to find an appropriate React fiber declaration on the Surface's DOM element. This approach is not our preference, needless to say, but as far as we know, there is no straightforwards means of retrieving a rendered component from Cypress. Ideally it'd be something like a useRef, but we're unaware of such a thing. If you've got suggestions, let us know!
  4. Assuming we managed to retrieve a React fiber, we extract the associated ref from it. That object is of type JsPlumbToolkitSurfaceComponent.
  5. We can then create a jsPlumbToolkitTestHarness from the toolkit and surface inside the retrieved component.
import {jsPlumbToolkitTestHarness} from "@jsplumbtoolkit/browser-ui"
import CanvasComponent from "./canvas-component";
/* eslint-disable */
// Disable ESLint to prevent failing linting inside the Next.js repo.
// If you're using ESLint on your project, we recommend installing the ESLint Cypress plugin instead:
// https://github.com/cypress-io/eslint-plugin-cypress

function getJsPlumbContext(cypressEl) {
for (let k in cypressEl) {
if (k.startsWith("__reactFiber")) {
return cypressEl[k].return.ref.current
}
}
}

function createTestHarness(cb) {
cy.get(".jtk-surface").then(s => {
const f = getJsPlumbContext(s[0])
if (f != null) {
cb(new jsPlumbToolkitTestHarness(f.toolkit, f.surface))
} else {
throw new Error("Cannot create JsPlumb test harness")
}
})
}


// Cypress Component Test
describe("<CanvasComponent />", () => {
it("should render and display expected content ", () => {
// Mount the React component for the canvas
cy.mount(<CanvasComponent />);

createTestHarness((testHarness) => {

cy.get(".jtk-node").should("have.length", 2)
cy.get(".jtk-surface-selected-element").should("have.length", 0)
cy.get(".jtk-connector").should("have.length", 0)

cy.wrap(testHarness.toolkit.getNodes()).should("have.length", 2)
cy.wrap(testHarness.toolkit.getAllEdges()).should("have.length", 0)


cy.then(n => {

const node1 = testHarness.toolkit.getNode("1")
cy.wrap(node1.data.left).should("equal", 50)
cy.wrap(node1.data.top).should("equal", 50)
//
testHarness.tapOnNode("1")
cy.then(n => {

cy.get(".jtk-surface-selected-element").should("have.length", 1)

testHarness.dragConnection(["1", ".connect"], "2")
cy.then(() => {

cy.get(".jtk-connector").should("have.length", 1)
cy.wrap(testHarness.toolkit.getAllEdges()).should("have.length", 1)

testHarness.dragVertexBy("1", 250, 300)
cy.then(() => {
cy.wrap(node1.data.left).should("equal", 300)
cy.wrap(node1.data.top).should("equal", 350)
})

})
})
})



})
});
});

// Prevent TypeScript from reading file as legacy script
export {};

Example project

The code shown here is available on Github at https://github.com/jsplumb-demonstrations/react-nextjs-testing. This is a NextJS app but the concepts are the same for any React app using Cypress.


Available methods

For further details on any of these methods, consult the API docs at https://apidocs.jsplumbtoolkit.com/6.x/current/browser-ui/classes/jsPlumbToolkitTestHarness.html.

Class jsPlumbToolkitTestHarness

MethodDescription
addGroupAdd a Group to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit.
addNodeAdd a Node to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit.
clearShortcut to the underlying clear method of the toolkit.
clickOnCanvasSimulates a clicks on the canvas
clickOnEdgeClicks on the given edge
clickOnElementClicks on a DOM element
clickOnElementInsideVertexClicks on an element inside the node/group with the given ID.
clickOnGroupClicks on the group with the given ID.
clickOnNodeClicks on the node with the given ID.
clickOnOverlayClicks on the overlay with the given ID, on the given Edge.
clickOnPortClicks on the port with the given ID on the node with given node id. The port may be represented by an Endpoint or by a DOM element.
clickOnVertexClicks on the node/group with the given ID.
connectConnect the given source and target via a call on the Toolkit, ie. without using the mouse. If you want to simulate mouse activity being used to establish an edge, use dragConnection.
contextmenuOnEdgeTrigger a right click event on the edge with the given id.
dblClickOnEdgeDouble clicks on the given edge
dblClickOnElementInsideVertexDouble clicks on an element inside the node/group with the given ID.
dblClickOnVertexDouble clicks on the node/group with the given ID.
dblTapOnEdgeDouble taps on the given edge
dblTapOnElementInsideVertexDouble taps on an element inside the node/group with the given ID.
destroyDestroy the underlying Surface and remove the test harness container from the DOM.
detachEdgeUse the mouse to drag the target endpoint of the given edge and drop it on distant whitespace, causing the edge to be detached.
dragAnElementAroundRandomly drag a DOM element around.
dragANodeAroundRandomly drag a node/group around. Can be useful to ensure the model is being updated, or you're getting callbacks you expect, etc. We use this internally when we just want a node to move and we don't care where it moves to.
dragConnectionDrags a connection between two vertices. You can pass in a variety of arguments to this method for both source and target: an Endpoint, a DOM element, a node/group/port ID, or a combination of vertex id + css selector. We use this last variant in JsPlumb's own test suite to ensure that connections can be dragged from specific parts of some element, or that a connection source on an element does not cause the element to be dragged, etc.
dragElementByDrag the given DOM element by the given x/y amounts.
dragElementByInStagesDrag a DOM element over a number of stages, advancing a little each time.
dragElementToCanvasDrag the given element onto the canvas, optionally at a specific x,y. Use this when you want to test drag/drop from some palette.
dragNodeIntoGroupDrag the given node into the given group.
dragVertexByDrag the given Node/Group by the given x/y amounts.
dragVertexByInStagesDrag a Node/Group over a number of stages, advancing a little each time.
dragVertexToDrag the given Node/Group to the given [x,y], which are canvas coordinates.
getAllEdgesGets all edges in the underlying Toolkit.
getDOMPositionGets the position in the DOM of the element representing the given Node or Group
getEdgeGets an Edge.
getEdgeCountReturns the count of Edges in the underlying Toolkit.
getGroupGets a Group from the underlying Toolkit.
getNodeGets a Node from the underlying Toolkit.
getRenderedConnectionFor the given edge id, find and return the underlying Connection used to render it.
getRenderedElementFor the given argument, find and return the corresponding DOM element.
getRenderedPortGets the DOM element that was rendered for some port. May be null.
getToolkitObjectFind the corresponding Toolkit object for the given input.
isAtPositionReturns whether or not the DOM element representing the given vertex is at the given point p.
loadShortcut to the underlying load method of the toolkit.
makeEventSynthesize an event for the given object. This method is used by other parts of the test harness class and is not something a user of the API will necessarily need to make use of.
makeEventAtMake an event at the given page location.
mousedownOnEdgeTrigger a mousedown event on the edge with the given id.
mouseoutOnEdgeTrigger a mouseout event on the edge with the given id.
mouseoverOnEdgeTrigger a mouseover event on the edge with the given id.
mouseupOnEdgeTrigger a mouseup event on the edge with the given id.
querySelectorExecute querySelector(...) on the current container and return the results.
querySelectorAllExecute querySelectorAll(...) on the current container and return the results.
rightClickOnCanvasRight-clicks on the canvas.
rightClickOnVertexRight-clicks on the node/group with the given ID.
startEditingStart editing the given Edge. Your surface must be configured for editable edge paths for this method to have any effect.
stopEditingStop editing an Edge. Your surface must be configured for editable edge paths for this method to have any effect.
tapOnEdgeTaps on the given edge
tapOnElementTaps on a DOM element (mousedown followed by mouseup)
tapOnElementInsideVertexTaps on a DOM element inside the node/group with the given ID.
tapOnGroupSimulates a tap event on the group with the given id - a tap event is a mousedown followed by a mouseup whose page coordinates are identical to the mousedown event's page coordinates.
tapOnNodeSimulates a tap event on the node with the given id - a tap event is a mousedown followed by a mouseup whose page coordinates are identical to the mousedown event's page coordinates.
tapOnVertexSimulates a tap event on the node/group with the given id - a tap event is a mousedown followed by a mouseup whose page coordinates are identical to the mousedown event's page coordinates.
triggerTrigger the event with the given name on the given object. By default the event will occur in the middle of the DOM element representing the object.
triggerEventOnElementTrigger the provided event on the given DOM element.
updateEdgeUpdates an Edge.
updateGroupUpdate a Group in the Toolkit. A convenience wrapper around the same method on the underlying Toolkit.
updateNodeUpdate a Node in to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit.
updateVertexUpdate a Vertex in the Toolkit. A convenience wrapper around the same method on the underlying Toolkit.
withConsoleRun a function and capture the console output. Used internally by JsPlumb's unit tests; included in the public API on the off chance it will be useful for others.