Skip to main content

Unit Testing with JsPlumb

· 10 min read
Simon Porritt
JsPlumb core team

At the time of writing, JsPlumb has a test suite consisting of almost 11 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. When we develop new features we first add a bunch of tests that break and then we update the code until our new tests - and all of the tests we had previously - work.

How do you test a UI built with JsPlumb, though? What are the sorts of things you might like to test? A non-exhaustive list:

  • Are my nodes/groups being rendered the way I expect?
  • Can users drag edges in the way I expect them to be able to?
  • Is node dragging behaving the way I expect?
  • Can users edit nodes with an inspector?
  • Are events firing in the way I expect?
  • Is the integrity of my dataset ok?

Consider the UI below. We have a canvas, a controls component, and - when you click on a node - an inspector. We've already clicked on Node 1 so that it's selected and showing in the inspector:

Now consider the list of testing requirements from above. Some of the requirements can be tested reasonably easily with a standard unit testing library. For instance, are your nodes being rendered the way you expect? For that you can load some data into JsPlumb and then query the DOM to see if what you're expecting to find is there. But other types of tests are less easy to achieve: how would you go about testing that a user can drag an edge in the way you expect? How can you drag a node around and make sure the experience is what you expect for your users?

Since we've faced issues like this from day one we have a full set of functionality to assist, and this functionality is actually shipped with JsPlumb, and you can use it right now.

jsPlumbToolkitTestHarness

To run the vast majority of our unit tests we use an instance of jsPlumbToolkitTestHarness. Our unit tests run outside of an integration such as Angular, React, etc, but you can use the test support class in Vanilla JS or with any of our integrations, and in this article we'll show you how.

Here's the app from above again, but this time nothing has been selected. We're going to use the test support class to manipulate this app in a few ways:

  • 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

To do that our code looks like this:


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")

This is vanilla JS code, of course. We'll show you below how to use this class with our library integrations.

Click the Run Tests button to kick off the tests:

What's cool here is that the API jsPlumbToolkitTestHarness provides for you is focused on the model - when you call tapOnNode("2"), for example, you're effectively directly invoking the tap event listener mapped for nodes inside your view. We define the node in this test app like this:

view:{
nodes:{
default:{
template:`<div class="some-test-node" data-jtk-target="true">
<h6>{{label}}</h6>
<div class="connect" data-jtk-source="true"></div>
</div>`,
events:{
[EVENT_TAP]:(p) => {
toolkit.setSelection(p.obj)
}
}
}
}
}

And so tapOnNode("2") translates into toolkit.setSelection("2") - without you having to mess around in the DOM. Note that JsPlumb itself does mess around in the DOM to get this to happen: we simulate all the DOM events that need to occur in a real life situation, so this is a true test of the UI.

Simulating edge dragging

A quick explanation on the dragConnection call shown above:

tks.dragConnection(["1", ".connect"], "2")

What's happening with that first argument? Take a look at the template shown above that we are using for nodes. You'll see that the node's main div element declares data-jtk-target="true", meaning that it can be the target of an edge drag. But we don't use the entire node as a drag source, because that would get confused with dragging the node itself. Instead we have a little .connect div in the template, which declares data-jtk-source="true". Users can drag new edges from this element.

The first argument to dragConnectionBetweenVertexElements is:

[ "1", ".connect" ]

which instructs JsPlumb to find the element for node 1, and then use querySelector(".connect") on it for the DOM element that will be the drag source.

Simulating node dragging

This is easily achieved:

tks.dragVertexBy("1", 250, 0)

Tapping and clicking on vertices

We show a tap on a node here:

tks.tapOnNode("2")

The test harness offers a long list of variants to this:

  • clickOnNode
  • dblClickOnNode
  • dblTapOnNode
  • clickOnVertex
  • ...etc

Angular

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)
})

Unit testing with Angular - JsPlumb - When you've reached the limit with ReactFlow, we can help!

Example project

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


React / NextJS

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 {};

Unit testing with React/NextJS - JsPlumb - JavaScript and Typescript diagramming library that fuels exceptional UIs

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.


Further Reading


Start a free trial

Sending your evaluation request...

Interested in the concepts discussed in this article? Start a free trial and see how JsPlumb can help bring your ideas to market in record time.


Get in touch!

If you'd like to discuss any of the ideas/concepts in this article we'd love to hear from you - drop us a line at hello@jsplumbtoolkit.com.