Functional testing of a hybrid PixiJS / React app

Scott Balay
6 min readAug 28, 2020

--

Here’s a process I’m using for enabling Cypress automation tests in a hybrid PixiJS 5 / React project. PixiJS operates on HTML Canvas elements, and of course React operates on all the other DOM elements. I’m using react-pixi-fiber to bridge between the PixiJS and React worlds, which works nicely to keep everything in the same mode of thinking.

Cypress is great for testing web apps and has some nice features to automatically wait and retry operations. Normally, Cypress can’t see into a canvas element, which can contain an entire “app” itself, so I was hoping to open up the PixiJS side of a project to better testability. Let’s make it happen!

Basic Idea

Provide a mechanism where arbitrary data can be attached to any node in a PixiJS object rendering tree, and then inspected and interacted with by a Cypress test. It’ll be able to wait for a certain state to be present (or not present) in a way that works well with asynchronous code.

Exposing the PixiJS Application

The first step is to save a reference to the PixiJS Application object onto the window object so that Cypress can get to the good stuff while it’s running. This happens as the result of a ref callback (note that I’m using React Hooks).

I’m using TypeScript. Things should work in a JavaScript project by removing types and the like. Speaking of TypeScript, it can be useful to define types for the object we attach to and read from window to avoid some uses of any. For example, the declaration below will prevent errors above when it says that is not a thing.

The exposed PIXI.Application object includes a whole tree of objects that correspond to the structure of the PixiJS rendering — primarily, Containers and DisplayObjects of various types. PixiJS Containers have children that may be other Containers themselves, or types of DisplayObjects, that we can traverse.

Adding Test Metadata to PixiJS Nodes

You can use a hyphen in a prop name to identify it as a custom attribute that will be passed through to the underlying object without getting prop errors. I decided to set my arbitrary test metadata on a pixi-data object. Here’s how you can indicate that a particular react-pixi-fiber component is a cat:

In these pixi-data objects, type is required. We have enough information above to determine whether the cat is rendered (technically, if this node exists in the PixiJS rendering tree) given the current app state.

If the app renders more than one cat, or you want to test more aspects of the cat, you can add arbitrary fields to pixi-data :

While this doesn’t change our ability to simply test if the cat exists, we have more options now. Is Meowsers Wowsers alive or dead? Is there a cat owned by Schrödinger?

Creating Cypress Helper Functions

Let’s start making our Cypress functions to tie this all together. Somewhere in the cypress/support files, start with:

To avoid TypeScript errors with accessing win.pixiApp , you can duplicate the declare global block from earlier, or set up a reference to those window typings so they will be available here.

Anyway, first we define the structure of the pixi-data object that will optionally exist on any PIXI.Container or PIXI.DisplayObject node. Then, a Cypress-chainable function that will get the PixiJS stage object from window.pixiApp object we added in the first bit of code, or blow up if it doesn’t exist at the time of calling. This is the “root” of the tree of objects in the render tree.

Next, a function that determines if a given set of required data matches a given PixiJS node:

I added a bit of nuance here that makes things more convenient. If pixi-data is set on the given object, we will look at each bit of required data, and see if it exists in our custompixi-data object or the PixiJS Container or DisplayObject itself. The main benefit I see here is asserting on rendered text. If you use a react-pixi-fiber Text component, for example <Text text='wheee' pixi-data={{ type: 'fun-times' }}/> we can avoid duplicating the text to assert into the pixi-data object, and instead will allow the code above to match in both “layers of the onion”. Just be aware of potential naming collisions, but I’m not worried about it, but you can make this more strict. See “Asserting on PixiJS node properties” below for more info.

Next is a function that uses pixiObjectMatches() from above and will walk down the PixiJS tree until it finds the object (if any) that matches our requirement:

If the current PixiJS node matches, it returns, and otherwise will recursively go into any children and retry until it finds a match.

Because my project has asynchronous behavior driven by a server, my tests will be flaky unless I wait until the desired conditions exist, rather than failing at the first try. The functions below require cypress-wait-until so make sure that is installed.

The only difference between these is that waitForNoPixiObject() has a not (!) in front of findPixiObject() . Call waitForPixiObject() to wait for, and return, the PixiJS node that matches the provided data. It will retry within the timeout period until it succeeds, or the test will fail. The same is true for waitForNoPixiObject() , but it will retry until no node matches the provided data.

That’s basically it! With the functions above, “wait for” is synonymous with “assert existence”. If you want to make a clearer distinction between waiting for (and returning) a node, and simply doing an assertion, you can add wrapper functions, or custom Cypress commands to that effect. The naming might give clearer intent:

Examples

  • Assert that no “cat” is rendered. Note that if a “cat” exists initially, it will retry until none is found, so if we are waiting for an asynchronous effect that will remove the cat, this should work as intended:

assertNoPixiObjectExists({ type: 'cat' })

  • Assert that a cat named “Meowsers Wowsers” is rendered, again with the possibility of waiting until this is the case. If only cat(s) with other names are rendered, this assertion will fail:

assertPixiObjectExists({ type: 'cat', name: 'Meowsers Wowsers' })

  • Assert that Schrödinger’s cat, if it exists, is not observed to be dead:

assertNoPixiObjectExists({ type: 'cat', owner: 'Schrödinger', isAlive: false })

Notice how changing the amount of provided data corresponds to how specific we want the match (either positive or negative) to be.

Asserting on PixiJS node properties

As mentioned above, the matcher will look at fields in both our custom pixi-data object as well as the PixiJS node object that it is attached to. This allows us to do an assertion such as:

assertPixiObjectExists({ type: 'username-display', _text: 'ZenBlender' })

This assertion will find a PixiJS node that is driven by a react-pixi-fiber Text component like <Text text='ZenBlender' pixi-data={{ type: 'username-display' }}/> . Notice that we aren’t defining _text in the pixi-data object, but our matcher will check its parent, where the text prop translates to an object attribute of _text . You can likely assert on other attributes that are populated by PixiJS.

Limitations

This is a simple example that can surely be expanded upon. It isn’t able to return a count or an array of matching objects, or report on differences that prevented matches from being made, or to define any sort of parent / child relationships that should signify a match.

That’s all for now!

I hope someone finds this useful!

--

--