Developing a Local addon – 06

Testing with Enzyme.

Part of a series

This post is the sixth post in a series where I’m chronicling my attempt at a Local addon; you can find the other posts here:

  1. What I’m trying to build
  2. Understanding Local stack for the task at hand
  3. Enter React
  4. Enter testing
  5. Mocking with Sinon.js

The high-level idea is to be able to control the XDebug configuration of the PHP version used to serve the site. This would allow me to activate and deactivate XDebug and set some relevant fields in the process.
As any one of my projects it’s a discovery process while trying to “scratch an itch”; along the way, I will make amateur mistakes in all the technological fields involved (Docker and React in this case) and I know it.
Getting it “done right” has more value for me, in this context, than “getting it done”.
I’ve put a repository online that contains the code in its current state on the master branch; it has more the release state of a leak than that of an alpha though; the code I show in this post can be found here.

Setting up for Enzyme

In my last post, I’ve refined the classes adopting a first test-driven developed approach to the code. I’ve used Mocha, Chai and Sinon to write unit tests for the Docker and Container classes and I want to move, now, to some higher level of testing.
I’ve ended the last article adding the code that would allow “graceful” management of errors thrown from lower levels of the addon:

And while I can confirm and check this visually I’d like to put in place “functional” tests for the behavior.
I say “functional” testing as I lack the knowledge, in the JavaScript testing domain, to use the correct words, and because it “looks” like functional testing: I’m checking on something as a user would while manipulating and checking the internals too. I will stick to this definition for the time being and move on.
The test I set out to write sound like this in plain English:

If some error happens in any phase of the top-level component (XDebugControl) rendering process, and that error is an error generated by the Container or Docker classes, then capture the error and display the error to the user.

The Docker and Container classes I’ve previously tested are not React components; they do not extend the React.Component class.
I could apply what I know about testing from the PHP domain as unit testing simple JavaScript classes is about the same as testing PHP classes.
In the same way, I could test the XDebugControl class, but since I’m developing this project for its teaching value, I think throwing into it as much testing techniques as I can will prove beneficial.
I will not get to write that test in this post and will, instead, lay the base that will allow me to do so in the next.

Setting Enzyme up

The Enzyme library is a set of “JavaScript Testing utilities for React”.
The simple statement covers a fair amount of complexity and I will tap into that, before any testing is done, setting up my environment; Enzyme was born to be used with Mocha and the instructions are easy enough to follow.
As I write this React 16 has just been released but I’m playing chicken and sticking to the more tested version 15 for now; Enzyme documentation provided a good starting point.
While running in the context of Local Electron app, the addon is provided the React object by the context variable; in the scope of the tests there is no context set up for me though; the first step is installing React as a developer dependency:

npm install --save-dev [email protected] [email protected] [email protected] [email protected]

Cutting it extremely rough testing with Enzyme relies on rendering a component in a DOM and checking its state, output or behavior; since there is no “real” DOM to speak of in a test I have to use a virtual one, for this I will install JSDOM:

npm install --save-dev jsdom jsdom-global

Finally, it’s time to install Enzyme and the React adapter:

npm install --save-dev enzyme-adapter-react-15 

Again from a quick and introductory inspection of the documentation I need an “adapter” as a middleware between how React expects to be fed information and how Enzyme provides it.
Or something like that, have a look at Enzyme documentation for a deeper understanding.
A last required step is to update Mocha running options to include a new testing directory, test/render, and requiring a test/boostrap.js file to set up JSDOM, Babel and the adapter before the tests, think of PhpUnit bootstrap file:

test/**/*.spec.js
--recursive
--require babel-register
--require jsdom-global/register
--require test/bootstrap.js
// file test/bootstrap.js

const configure = require( 'enzyme' ).configure
const Adapter = require( 'enzyme-adapter-react-15' )

configure( {adapter: new Adapter()} )

It’s now time to write the first test.

The first Enzyme test

I’ve spent quite some time to make the first test even run without errors, and it’s nothing fancy: I just wanted to be able to run one successfully. It means it’s a failure but not error.
Commenting the code line by line here is what is happening:

  • I require React and React.Component as there is no application providing them for me
    const React = require( 'react' )
    const Component = require( 'react' ).Component
    
  • I need Chai to make assertions.
    javascript
    const expect = require( 'chai' ).expect
  • I will use the mount method from the Enzyme library.
    javascript
    const mount = require( 'enzyme' ).mount
  • I mock the context; this is the object that the XDebugControl component will use to get hold of the React object.
    const context = {
        'React': React,
    }
    
  • I include the file defining the component under test.
    const XDebugControl = require( './../../../lib/Component/XDebugControl' )( context )
    
  • Here I define the first group of tests as one checking on the status string.
    describe( '<XDebugControl/> status', function () {
    
  • This function will run before each test method and it allows me to set up a standard starting fixture for each test; in this case, I’m modeling the bare minimum parameters needed to simulate a running custom installation.
        before( function () {
            this.props = {
                params: {
                    siteID: 'foo',
                },
                sites: {
                    foo: {
                        environment: 'custom',
                        container: '123344',
                    },
                },
                environment: {
                    dockerPath: '/app/docker',
                    dockerEnv: {key: 'value'},
                },
                siteStatus: 'running',
            }
        } )
    
  • Finally the first test; here I change the site status to “not running” to make sure the correct message is shown to the user.
        it( 'renders correctly when machine is not running', function () {
            this.props.siteStatus = 'not running'
            const wrapper = mount( <XDebugControl {...this.props}/> )
            expect( wrapper.find( '.xdebugStatus' ).text() ).to.equal( 'Machine not running!' )
        } )
    } )
    

Looking at the test method itself it’s clear I’ve made some modifications to the XDebugControl class to allow for the injection of its properties; much of the work required to write “testable code” is usually about transforming implicit dependencies into explicit dependencies. The former being those read from constants, globals or created in the class method itself and the latter being those injected via a constructor or a “setter” method.
I’ve modified the XDebugControl class code to allow for that and here is the code that’s passing the test:

module.exports = function ( context ) {

    const React = context.React
    const Component = context.React.Component
    const Container = require( './../System/Container' )()
    const Docker = require( './../System/Docker' )()

    return class XDebugControl extends Component {
        constructor( props ) {
            // fill from the context if missing information
            if(undefined === props.environment){
                props.environment = context.environment
            }

            super( props )

            this.state = {
                siteStatus: 'off',
                xdebugStatus: 'n/a',
            }

            this.site = this.props.sites[this.props.params.siteId]
            this.docker = new Docker( props.environment, childProcess )
            this.container = new Container( this.docker, this.site )
        }

        componentWillReceiveProps( nextProps ) {
            let newState = null

            if ( nextProps.siteStatus === 'running' ) {
                try {
                    newState = {
                        siteStatus: 'running',
                        xdebugStatus: this.container.getXdebugStatus(),
                    }
                }
                catch ( e ) {
                    if ( e.name === 'DockerError' || e.name === 'ContainerError' ) {
                        newState = {
                            siteStatus: 'running',
                            xdebugStatus: e.message,
                        }
                    } else {
                        throw e
                    }
                }
            } else {
                newState = {
                    siteStatus: 'stopped',
                    xdebugStatus: 'n/a',
                }
            }

            this.setState( newState )
        }

        render() {
            let statusString = null
            let isCustom = this.site.environment === 'custom'
            let xdebugStatus = null
            let statusStyle = {'text-transform': 'uppercase'}
            let isRunning = this.props.siteStatus === 'running'

            if ( ! isCustom ) {
                statusString = 'Only available for custom installations!'
            } else {
                xdebugStatus = this.state.xdebugStatus

                if ( xdebugStatus === 'active' ) {
                    statusStyle['color'] = '#1FC37D'
                } else {
                    statusStyle['color'] = '#FF0000'
                }


                if ( isRunning ) {
                    statusString = (
                        <span style={statusStyle}>XDebug is {xdebugStatus}</span>
                    )
                } else {
                    statusString = 'Machine not running!'
                }
            }


            return (
                <div style={{display: 'flex', flexDirection: 'column', flex: 1, padding: '0 5%'}}>
                    <h3>XDebug Controls</h3>
                    <h4 className='xdebugStatus'>{statusString}</h4>
                    {button}
                </div>
            )
        }
    }
}

The noteworthy change I made is that, in the constructor method, I’m using the props argument to set up the instance properties. Using ES6 syntax I’m injecting those properties, in the test method above, using

const wrapper = mount( <XDebugControl {...this.props}/> )

Getting back to the test method it’s worth spending some time to consider what is happening in it.

Mount and shallow

I’ve skimmed over the test method in the paragraph above for the sake of unity but the code, in few lines, does a lot.

it( 'renders correctly when machine is not running', function () {
    this.props.siteStatus = 'not running'
    const wrapper = mount( <XDebugControl {...this.props}/> )
    expect( wrapper.find( '.xdebugStatus' ).text() ).to.equal( 'Machine not running!' )
} )

As I’ve mentioned before Enzyme will test React components rendering them in a virtual DOM. In that DOM Enzyme defines a “wrapper” block that will contain any output generated by the component.
The mount method renders the component and appends it to the wrapper.
The wrapper object will then provide the find method, and others, to allow querying the DOM in a JQuery-like DOM fashion.
But Enzyme defines another method, shallow, to render components just “one level deep”.
I’m showing, in this post, a trimmed down version of the code that does not include any secondary component used by the XDebugControl one; in reality the current addon look is this:

And the XDebugControl component is rendering two more components: Button and FieldList.
While my lack of imagination for naming shines the names pretty much tell the tale: the Button component renders the “Activate XDebug”, “Deactivate XDebug” (not visible in the screenshot) and “Apply settings” buttons; the FieldList component renders the list of fields.
The hierarchy looks like this:

<XDebugControl>
    <Button />
    <FieldList>
        <Field />
        <Field />
        <Field />
        ...
        <Button />
    </FieldList>
</XDebugControl>

I’ve added a test to make sure that the initial state for a running custom site will show the XDebug status as “not available”; fetching the XDebug status from the site is not a task of the initial rendering; here is the test:

it( 'renders correctly when installation is custom and XDebug is active', function () {
    this.props.siteStatus = 'running'
    this.props.sites.foo.environment = 'custom'

    const wrapper = mount( <XDebugControl {...this.props}/> )

    expect( wrapper.find( '.xdebugStatus' ).text() ).to.equal( 'XDebug is n/a yet...' )
} )

If I use the mount method to run the test above the secondary components will complain about the missing data:

If I use, intstead shallow like this:

it( 'renders correctly when installation is custom and XDebug is active', function () {
    this.props.siteStatus = 'running'
    this.props.sites.foo.environment = 'custom'

    const wrapper = shallow( <XDebugControl {...this.props}/> )

    expect( wrapper.find( '.xdebugStatus' ).text() ).to.equal( 'XDebug is n/a yet...' )
} )

The secondary containers will not be rendered and will not, hence, complain:

To find out more about the mount and flow method read Enzyme documentation.

Next

I will write one more post to detail how mocking with Sinon and testing with Enzyme play together on a higher level of testing; all the while I’m moving toward the conclusion of the addon and will details more findings and “gotchas” along the way.
The code shown in this post can be found on GitHub tagged step-6.

I appreciate your input