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 codeYou 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.