Developing a Local addon – 05

Mocking objects in JavaScript tests with Sinon.js.

This is the fifth part

This post is the fifth one (following a first, a second, a third one and a fourth one) in a series where I’m chronicling my attempt at a Local addon.
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. In the previous posts I’ve exposed my understanding of Local technology stack and how to use it to make it do, through “raw” terminal commands, what I want it to do and put in place a first React-based UI for my addon.
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, Electron 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.

Better mocking with Sinon.js

In my previous post I put in place a first test for the Container class containing just one test, or “expectation” in Chai terms,
to verify the Container::exec method would behave as intended. In the best of cases, the one where the command execution goes as planned and returns the expected value, I’ve mocked the two objects dependencies, site and docker, replacing them with simple JavaScript objects:

const expect = require( 'chai' ).expect
const Container = require( './../../../src/System/Container' )()

describe( 'Conteainer::exec', function () {
    it( 'returns what Docker::runCommand returns', function () {
        const dockerMock = {
            runCommand: function () {
                return 'bar'
            },
        }
        const siteMock = {
            container: 'foo-container',
        }

        const container = new Container( dockerMock, siteMock )

        expect( container.exec('some-command') ).to.be.equal( 'bar' )
    } )
} )

While this approach to mocking works, I’m used to the “flow” of chained methods and method verbiage Prophecy provides and found a similar tool in the sinon.js package.
I’ve installed it using this command:

npm install --save sinon

and I’ve updated the test code to perform the same check but using sinon.js methods:

const expect = require( 'chai' ).expect
const Docker = require( './../../../src/System/Docker' )()
const Container = require( './../../../src/System/Container' )()
const sinon = require( './../../../node_modules/sinon' )

describe( 'Container::exec', function () {
    it( 'returns what Docker::runCommand returns', function () {
        const dockerMock = sinon.createStubInstance( Docker )
        dockerMock.runCommand.returns( 'bar' )

        const siteMock = {
            container: 'foo-container',
        }

        const container = new Container( dockerMock, siteMock )

        expect( container.exec( 'some-command' ) ).to.be.equal( 'bar' )
    } )
} )

Running the test will yield the same result:

I’m using sinon.js to stub an instance of the Docker object where one of the methods will be “hijacked” to behave as intended. I want to be sure the runCommand method, when called, will return bar; not that different from before but way more readable.
I’ve not changed how I mock the site object as that is just a value object and there is no reason to complicate things.

A small gotcha to test exceptions

Sticking to the “one test, one assertion” rule where possible, I will add one more test to make sure the error generated by the node.child_process.execSync method used in Docker::runCommand is not handled by the Container class; the reason being I would like the top-level class, XDebugControl, to handle any low-level exceptions in a graceful way.
The top-level error handler will need some contextual information about the thrown exceptions; to that end I’ve created the src/Error/DockerError.js file to define the custom DockerError error class: this will allow the handler to know, looking up the error type, where the error was generated.

// file src/Errors/DockerError.js

module.exports = function () {
    return class DockerError extends Error {
        constructor() {
            super()
            this.name = 'DockerError'
        }
    }
}```

And update the test to use it:

```javascript
it( 'should throw the error Docker::runCommand throws', function () {
    const docker = sinon.createStubInstance( Docker )
    const dockerError = new DockerError()
    dockerError.message = 'something happened'
    docker.runCommand.throws( dockerError )
    const site = {
        container: 'foo-container',
    }

    const container = new Container( docker, site )

    expect( function () {
        container.exec( 'foo' )
    } ).to.throw().satisfies( function ( e ) {
        return e.name === 'DockerError'
    } )
}    

Since I’m using [Babel](!g babel js transpiler) to transpile the ES6 code to more compatible code the same assertion, wrote like in the code below, would fail:

it( 'should throw if called with empty command', function () {
    const docker = sinon.createStubInstance( Docker )

    const container = new Container( docker, this.site )

    expect( function () {
        container.exec( '' )
    } ).to.throw( ContainerError )
} )

The catch here is that I’m writing ES6 code that is being transpiled, by Babel, to more widely supported JavaScript code; there are still issues in the way a combination of Node and Babel versions transpile extensions to the base Error class so my work-around is to simply set, and later check, the name property.
After wrestling with the error testing issue for a while, I’ve simply finished covering the two Container methods I’m currently using, exec and getXdebugStatus, with tests.

Here is the code of the test file above:

// file /test/unit/System/Container.js

const expect = require( 'chai' ).expect
const Docker = require( './../../../src/System/Docker' )()
const DockerError = require( './../../../src/Errors/DockerError' )()
const Container = require( './../../../src/System/Container' )()
const ContainerError = require( './../../../src/Errors/ContainerError' )()
const sinon = require( './../../../node_modules/sinon' )

describe( 'Container::constructor', function () {
    it( 'should throw if site.container information is missing', function () {
        const docker = sinon.createStubInstance( Docker )

        expect( function () {
            new Container( docker, {} )
        } ).to.throw().satisfies( function ( e ) {
            return e.name === 'ContainerError'
        } )
    } )
} )

describe( 'Container::exec', function () {
    before( function () {
        this.site = {
            container: 'foo-container',
        }
    } )

    it( 'should return what Docker::runCommand returns', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.returns( 'bar' )

        const container = new Container( docker, this.site )

        expect( container.exec( 'some-command' ) ).to.be.equal( 'bar' )
    } )

    it( 'should throw the error Docker::runCommand throws', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.throws( new DockerError )

        const container = new Container( docker, this.site )

        expect( function () {
            container.exec( 'foo' )
        } ).to.throw().satisfies( function ( e ) {
            return e.name === 'DockerError'
        } )
    } )

    it( 'should return nothing if Docker::run command returns nothing', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.returns( '' )

        const container = new Container( docker, this.site )

        expect( container.exec( 'foo' ) ).to.be.equal( '' )
    } )

    it( 'should throw if called with empty command', function () {
        const docker = sinon.createStubInstance( Docker )

        const container = new Container( docker, this.site )

        expect( function () {
            container.exec( '' )
        } ).to.throw().satisfies( function ( e ) {
            return e.name === 'ContainerError'
        } )
    } )
} )

describe( 'Container::getXdebugStatus', function () {
    before( function () {
        this.site = {
            container: 'foo-container',
        }
    } )

    it( 'should mark XDebug status as active if docker returns output', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.returns( 'something' )

        const container = new Container( docker, this.site )

        expect( container.getXdebugStatus() ).to.be.equal( 'active' )
    } )

    it( 'should mark XDebug status as inactive if docker returns no output', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.returns( '' )

        const container = new Container( docker, this.site )

        expect( container.getXdebugStatus() ).to.be.equal( 'inactive' )
    } )

    it( 'should throw Docker errors happening while reading XDebug status', function () {
        const docker = sinon.createStubInstance( Docker )
        docker.runCommand.throws( new DockerError )

        const container = new Container( docker, this.site )

        expect( function () {
            container.getXdebugStatus()
        } ).to.throw().satisfies( function ( e ) {
            return e.name === 'DockerError'
        } )
    } )
} )

Capturing and using exceptions

Before moving to further development I want to make sure errors, when bubbling up to the top level, will be handled and used to show the user some useful information.
To do that I’ve modified the XDebugControl::componentWillReceiveProps method to wrap the call to container and docker classes in a try/catch block and only handle the type of errors raised by the addon:

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

Once again, to check for the error type, I’m not using e instanceof ContainerError due to the issue above; I will revisit this code when and if, the issue is solved; for the time being it works.
A quick manual reveals it shows up correctly:

Next

Before extending the functionalities and tests of the code I will add one more layer of testing to the addon to make sure that last error-handling code works as intended.
It’s really an acceptance test to make sure the addon correctly displays and uses the error information.
The code found in the post can be found on GitHub, tagged step-5.

Probably related

I appreciate your input