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:
- First,
createTestHarness
executescy.get(".jtk-surface")
, which instructs Cypress to look for a DOM element configured as a surface. - We then pass the DOM element found into the
getJsPlumbContext
method. 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!- Assuming we managed to retrieve a React fiber, we extract the associated
ref
from it. That object is of typeJsPlumbToolkitSurfaceComponent
. - We can then create a
jsPlumbToolkitTestHarness
from thetoolkit
andsurface
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
Method | Description | |
---|---|---|
addGroup | Add a Group to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit. | |
addNode | Add a Node to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit. | |
clear | Shortcut to the underlying clear method of the toolkit. | |
clickOnCanvas | Simulates a clicks on the canvas | |
clickOnEdge | Clicks on the given edge | |
clickOnElement | Clicks on a DOM element | |
clickOnElementInsideVertex | Clicks on an element inside the node/group with the given ID. | |
clickOnGroup | Clicks on the group with the given ID. | |
clickOnNode | Clicks on the node with the given ID. | |
clickOnOverlay | Clicks on the overlay with the given ID, on the given Edge. | |
clickOnPort | Clicks 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. | |
clickOnVertex | Clicks on the node/group with the given ID. | |
connect | Connect 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 . | |
contextmenuOnEdge | Trigger a right click event on the edge with the given id. | |
dblClickOnEdge | Double clicks on the given edge | |
dblClickOnElementInsideVertex | Double clicks on an element inside the node/group with the given ID. | |
dblClickOnVertex | Double clicks on the node/group with the given ID. | |
dblTapOnEdge | Double taps on the given edge | |
dblTapOnElementInsideVertex | Double taps on an element inside the node/group with the given ID. | |
destroy | Destroy the underlying Surface and remove the test harness container from the DOM. | |
detachEdge | Use the mouse to drag the target endpoint of the given edge and drop it on distant whitespace, causing the edge to be detached. | |
dragAnElementAround | Randomly drag a DOM element around. | |
dragANodeAround | Randomly 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. | |
dragConnection | Drags 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. | |
dragElementBy | Drag the given DOM element by the given x/y amounts. | |
dragElementByInStages | Drag a DOM element over a number of stages, advancing a little each time. | |
dragElementToCanvas | Drag the given element onto the canvas, optionally at a specific x,y. Use this when you want to test drag/drop from some palette. | |
dragNodeIntoGroup | Drag the given node into the given group. | |
dragVertexBy | Drag the given Node/Group by the given x/y amounts. | |
dragVertexByInStages | Drag a Node/Group over a number of stages, advancing a little each time. | |
dragVertexTo | Drag the given Node/Group to the given [x,y], which are canvas coordinates. | |
getAllEdges | Gets all edges in the underlying Toolkit. | |
getDOMPosition | Gets the position in the DOM of the element representing the given Node or Group | |
getEdge | Gets an Edge. | |
getEdgeCount | Returns the count of Edges in the underlying Toolkit. | |
getGroup | Gets a Group from the underlying Toolkit. | |
getNode | Gets a Node from the underlying Toolkit. | |
getRenderedConnection | For the given edge id, find and return the underlying Connection used to render it. | |
getRenderedElement | For the given argument, find and return the corresponding DOM element. | |
getRenderedPort | Gets the DOM element that was rendered for some port. May be null. | |
getToolkitObject | Find the corresponding Toolkit object for the given input. | |
isAtPosition | Returns whether or not the DOM element representing the given vertex is at the given point p . | |
load | Shortcut to the underlying load method of the toolkit. | |
makeEvent | Synthesize 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. | |
makeEventAt | Make an event at the given page location. | |
mousedownOnEdge | Trigger a mousedown event on the edge with the given id. | |
mouseoutOnEdge | Trigger a mouseout event on the edge with the given id. | |
mouseoverOnEdge | Trigger a mouseover event on the edge with the given id. | |
mouseupOnEdge | Trigger a mouseup event on the edge with the given id. | |
querySelector | Execute querySelector(...) on the current container and return the results. | |
querySelectorAll | Execute querySelectorAll(...) on the current container and return the results. | |
rightClickOnCanvas | Right-clicks on the canvas. | |
rightClickOnVertex | Right-clicks on the node/group with the given ID. | |
startEditing | Start editing the given Edge. Your surface must be configured for editable edge paths for this method to have any effect. | |
stopEditing | Stop editing an Edge. Your surface must be configured for editable edge paths for this method to have any effect. | |
tapOnEdge | Taps on the given edge | |
tapOnElement | Taps on a DOM element (mousedown followed by mouseup) | |
tapOnElementInsideVertex | Taps on a DOM element inside the node/group with the given ID. | |
tapOnGroup | Simulates 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. | |
tapOnNode | Simulates 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. | |
tapOnVertex | Simulates 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. | |
trigger | Trigger 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. | |
triggerEventOnElement | Trigger the provided event on the given DOM element. | |
updateEdge | Updates an Edge. | |
updateGroup | Update a Group in the Toolkit. A convenience wrapper around the same method on the underlying Toolkit. | |
updateNode | Update a Node in to the Toolkit. A convenience wrapper around the same method on the underlying Toolkit. | |
updateVertex | Update a Vertex in the Toolkit. A convenience wrapper around the same method on the underlying Toolkit. | |
withConsole | Run 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. |