Shrink the Untestable Code With App Actions And Effects

Back to Cypress blog

This blog post shows how to shrink untestable code and pushes it to the edges of the applications. The core of the web application will remain easy to check from our tests, while external effects can be observed or stubbed.

The Cypress.io test runner has limitations. For example, when controlling the network requests, Cypress can spy and stub XHR requests only. We will remove this limit when issue #687 lands, but for now if your application is using fetch protocol to access external data - you are out of luck. You cannot see the requests, or wait for them, or stub them, unless you force the application to drop to XMLHttpRequest. Or can you?

During a typical end-to-end test, the test code loads the page, which triggers the application code. The web app could load some data from remote server so it would execute fetch request to the server. By using App Actions your tests can access parts of the application’s inner code. If the web application is structured so that all calls to fetch are placed at the boundary of the application, with minimal logic inside of them, then stubbing these calls from the tests really only stubs the fetch call.

Overmind.js

Let me show how this works out in practice. Recently a new state management library called Overmind.js came out from the maestro of state management Christian Alfoni. The library has very interesting pragmatic API, works with multiple frameworks and has a very clear separation between state manipulation and external effects. External effects are methods that do network calls, access storage, ask users for permission to access location, etc - all the things that are hard to test, even with native events.

Here is a short example taken from the Overmind.js examples page. I am using the React version, but the same principles apply to every framework - we are going to work with the state directly using App Actions.

Source code

You can find the final source code for this blog post in the bahmutov/overmind-demo-1-react repo.

The application is rendering a list of posts fetched from JSON Placeholder server. You can find the view component in Posts.jsx below. It is a “regular” React component that gets everything from this.overmind instance.

import React from 'react'
import { connect } from './overmind'

class Posts extends React.Component {
  componentDidMount () {
    this.props.overmind.actions.getPosts()
  }
  render () {
    const { overmind } = this.props

    return (
      <div>
        {overmind.state.isLoadingPosts ? (
          <h4>Loading...</h4>
        ) : (
          <div>
            Show count:{' '}
            <select
              id='select-count'
              value={overmind.state.showCount}
              onChange={overmind.actions.changeShowCount}
            >
              <option value='10'>10</option>
              <option value='50'>50</option>
              <option value='100'>100</option>
            </select>
            <ul>
              {overmind.state.filteredPosts.map((post, index) => (
                <li className='post' key={post.id}>
                  <h4>
                    {index + 1}. {post.title}
                  </h4>
                  {post.body}
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    )
  }
}

export default connect(Posts)

Note that on start, the application fetches the list of posts by calling:

componentDidMount () {
  this.props.overmind.actions.getPosts()
}

Let us look at the overmind.js file.

import { Overmind } from 'overmind'
import { createConnect } from 'overmind-react'

const overmind = new Overmind({
  state: {
    isLoadingPosts: true,
    showCount: '10',
    posts: [],
    filteredPosts: state => state.posts.slice(0, state.showCount)
  },
  actions: {
    getPosts: async ({ state, effects }) => {
      state.isLoadingPosts = true
      state.posts = await effects.request(
        'https://jsonplaceholder.typicode.com/posts'
      )
      state.isLoadingPosts = false
    },
    changeShowCount: ({ value: event, state }) => {
      state.showCount = event.target.value
    }
  },
  effects: {
    request: async url => {
      const response = await fetch(url)
      return response.json()
    }
  }
})

export const connect = createConnect(overmind)

When the application starts, it calls actions.getPosts, which sets the state.isLoadingPosts = true and then calls the request effect. The effects in Overmind is where your application is accessing the outside world. In this case, this is where you fetch the posts from JSON placeholder server.

effects: {
  request: async url => {
    const response = await fetch(url)
    return response.json()
  }
}

See how simple the effects code is? It is minimal - it just executes the fetch and gets the JSON result. This code is such a thin wrapper around the elusive network fetch method, that if we spy or stub effects.request method itself, not much is going to be lost in our tests. Can we stub it?

Testing with App Actions

Before looking at the effects, let us dispense with simple tests. First, we can set the overmind instance on the window object so that our tests can easily access it. From the application code:

// overmind.js
const overmind = new Overmind({
  ...
})
if (window.Cypress) {
  window.overmind = overmind
}

To make it more convenient for testing, I have added a custom Cypress command to get to this window.overmind instance.

// adds custom command to return "window.overmind"
Cypress.Commands.add('overmind', () => {
  let overmind

  const cmd = Cypress.log({
    name: 'overmind',
    consoleProps () {
      return {
        Overmind: overmind
      }
    }
  })

  return (
    cy
      .window({ log: false })
      // instead of .its('overmind') that always logs to the console
      // use ".then" shortcut (but without retry)
      .then({ log: false }, win => {
        overmind = win.overmind
        cmd.end()
        return overmind
      })
  )
})

File cypress/integration/action-spec.js shows a typical test that dispatches an action via cy.invoke() on the Overmind instance and observes the changed DOM.

context('Overmind actions', () => {
  beforeEach(() => {
    cy.visit('/')
  })

  it('invokes an action', () => {
    cy.get('.post').should('have.length', 10)
    cy.wait(1000) // for dramatic effect
    cy.overmind()
      .its('actions')
      .invoke('changeShowCount', { target: { value: 50 } })
    cy.get('.post').should('have.length', 50)
  })
})

I have added one second wait to make the DOM change noticeable.

Just as easily we can access the state object itself to confirm its value. In test cypress/integration/get-state-spec.js we are controlling the application through the GUI and are confirming that the state changes in response.

it('changes values in state object', function () {
  cy.get('.post').should('have.length', 10)
  cy.overmind()
    .its('state.showCount')
    .should('equal', '10')

  cy.get('#select-count').select('50')
  cy.get('.post').should('have.length', 50)
  cy.overmind()
    .its('state.showCount')
    .should('equal', '50')
})

Great, so what about the effects.request call on application start? Can we control it from our tests?

The “When” Problem

Whenever a page makes a network request at startup, we need to prepare to intercept it before the page loads. For example, if we could intercept fetch requests we would write a test like this using cy.route:

it('fetches data', () => {
  cy.server()
  cy.route('https://jsonplaceholder.typicode.com/posts').as('load')
  cy.visit()
  cy.wait('@load') // asserts the request happens
})

But we cannot spy on fetch requests yet. So we need to use Sinon (bundled with Cypress) to spy on effects.request method inside overmind object.

Hmm, we cannot create a spy before cy.visit - the overmind instance has not been created yet!

it('requests data', () => {
  // NO, there is no app and no overmind yet!
  cy.overmind()
    .its('effects')
    .then(effects => {
      cy.spy(effects, 'requests').as('load')
    })
  cy.visit()
  cy.get('@load').should('have.been.calledOnce')
})

Ok, we can create a spy after cy.visit. No, this does not work either. By then it is too late, the request has already gone out.

it('requests data', () => {
  cy.visit()
  // NO, too late, the "effects.request()" has already been called
  cy.overmind()
    .its('effects')
    .then(effects => {
      cy.spy(effects, 'requests').as('load')
    })
  cy.get('@load').should('have.been.calledOnce')
})

I placed both of these tests in cypress/integration/wrong-timing-spec.js for demo purposes.

Synchronous callback

We need to set up spying on effects.request after the overmind instance has been created, but before the application fires off effects.request('https://jsonplaceholder.typicode.com') call. We cannot even register “on” event listener or set a promise, because our code will be queued up to run after the application code, and the request will go out again too quickly before we are ready. But luckily there is a solution. Cypress tests run in the same event loop as the application - the tests just load in different iframe. So we can synchronously install a spy during the application start up.

So our application code could do the following

const overmind = new Overmind({
  ...
})
if (window.Cypress) {
  window.overmind = overmind
  if (window.Cypress.setOvermind) {
    // calls spec function synchronously
    window.Cypress.setOvermind(overmind)
  }
}

Our test code should expect this call from the application. We can set our spies right away - and they will be ready when the application executes effects.request method call.

it('can spy on request method in effects', () => {
  Cypress.setOvermind = overmind => {
    cy.spy(overmind.effects, 'request').as('request')
  }
  cy.visit('/')
  cy.get('@request')
    .should('have.been.calledOnce')
    .and(
      'have.been.be.calledWithExactly',
      'https://jsonplaceholder.typicode.com/posts'
    )
})

Beautiful, we can spy on the network request effect. And we can stub it - and return fake data from a fixture.

it('can stub the request method in effects', () => {
  // load fixture with just 2 posts
  cy.fixture('posts').then(posts => {
    Cypress.setOvermind = overmind => {
      cy.stub(overmind.effects, 'request')
        .as('request')
        .resolves(posts)
    }
  })
  cy.visit('/')
  cy.get('@request')
    .should('have.been.calledOnce')
    .and(
      'have.been.be.calledWithExactly',
      'https://jsonplaceholder.typicode.com/posts'
    )
  cy.get('li.post').should('have.length', 2)
})

Elegance

The previous Cypress.setOvermind = ... code works, yet we can use a nice little trick to catch the object property set operation, since we know the name of the property. Again, the trick I am about to show only works because Cypress tests have direct access to the application under test.

// application code is back to simply
// setting "window.overmind" property
if (window.Cypress) {
  window.overmind = overmind
}

We are going to define a property on the window with a setter, and it will be called immediately when the application does:

Cypress.Commands.add('onOvermind', set => {
  expect(set).to.be.a('function', 'onOvermind expects a callback')

  // when the application's window loads
  // prepare for application calling
  // window.overmind = overmind
  // during initialization
  cy.on('window:before:load', win => {
    // pass overmind to the callback argument "set"
    Object.defineProperty(win, 'overmind', { set })
  })
})

it('catches overmind creation', () => {
  cy.onOvermind(overmind => {
    // when overmind is JUST created
    // we set the spy on effects.request
    cy.spy(overmind.effects, 'request').as('request')
  })
  cy.visit('/')
  cy.get('@request').should('have.been.calledOnce')
})

We can do more advanced things, like actually perform the request, and then modify the returned data before passing back to the application. Let us set the title of the first post to “My mock title”:

it('can transform post titles', () => {
  cy.onOvermind(overmind => {
    cy.stub(overmind.effects, 'request')
      .as('request')
      .callsFake(url => {
        // call the original method
        return overmind.effects.request.wrappedMethod(url).then(list => {
          // but change the result
          list[0].title = 'My mock title'
          return list
        })
      })
  })
  cy.visit('/')
  cy.contains('.post', 'My mock title')
})

Unfortunately, if we want to access the window.overmind instance in our tests, our defineProperty shortcut needs both set and get settings.

cy.on('window:before:load', win => {
  // pass overmind to the callback argument "set"
  let overmind
  Object.defineProperty(win, 'overmind', {
    set (value) {
      set(value)
      overmind = value
    },
    get () {
      return overmind
    }
  })
})

But now we can write interesting tests. For example, during loading, our state has isLoadingPosts = true, and the UI shows loading message. Once the effects.request finishes, the state changes, and the loading message goes away. Can we confirm it?

Yes, for example we can stub it the effect, and resolve it after a delay. While the response is delayed using Bluebird delay method (Bluebird is bundled with Cypress) we can check the state and the UI. Then delay ends, and from the test we can check the state and the DOM again to confirm that they show posts.

it('delay the response to effect', function () {
  // there are two posts in the fixture
  cy.fixture('posts').then(posts => {
    cy.onOvermind(overmind => {
      // when effect.request happens
      // we are going to delay by 2 seconds and respond with our data
      cy.stub(overmind.effects, 'request')
        .as('request')
        .resolves(Cypress.Promise.delay(2000, posts))
    })
  })
  // let's roll
  cy.visit('/')
  // page makes the request right away, nice
  cy.get('@request')
    .should('have.been.calledOnce')
    .and(
      'have.been.be.calledWithExactly',
      'https://jsonplaceholder.typicode.com/posts'
    )
  // while request is in transit, loading state
  cy.contains('Loading') // UI
  cy.overmind() // state
    .its('state.isLoadingPosts')
    .should('be.true')

  // load should finish eventually
  cy.overmind() // state
    .its('state.isLoadingPosts')
    .should('be.false')
  cy.contains('Loading').should('not.exist') // UI
  cy.get('li.post').should('have.length', 2)
})

The test reads long, but the Cypress Test Runner shows what is going on beautifully.

You can find this test in the spec file cypress/integration/visit-overmind-spec.js.

Final thoughts

To review

  • App Actions allow end-to-end tests to directly interact with the application’s inner code, rather than always going through the DOM.
  • A good state management library like Overmind.js makes it easy to put all hard to test code into very thin and isolated “effects” methods.
  • Our tests can synchronously inject spies and stubs into application code to reach into the state object, actions methods and even wrap the effects methods, shrinking the untestable code to a minimum.