Rolling for a Test

How to bundle individual components using Rollup for each Cypress test

End-to-end test tool like Cypress.io usually loads a complete page from a web server and executes a series of test commands against it. Recently I have introduced a series of framework-specific adaptors that allow running end-to-end tests against individual components rather than web pages. You can find more details about this approach from these links

A typical component is loaded by the spec file and is "mounted" inside Cypress and becomes "live" mini web application. Here is a typical 'counter' example for Vue framework.

1
2
3
4
5
6
7
8
9
import ButtonCounter from '../../components/ButtonCounter.vue'
const mount = require('cypress-vue-unit-test')
describe('ButtonCounter', () => {
beforeEach(mount(ButtonCounter))

it('starts with zero', () => {
cy.contains('button', '0')
})
})

Notice that even with framework-specific mount function, the rest of interaction with the component and the testing goes through the standard browser interfaces: DOM, network, events. This makes Cypress tests very portable - because they really are not tied to a specific implementation.

But there is a problem here, and it is that import Button from '...' statement. But first, let me explain how the test code is separated from the application's code.

E2E vs Component

Let me explain the iframing in Cypress. Here is a typical end-to-end test that loads a page "localhost:3000"

1
2
3
it('opens the page', () => {
cy.visit('http://localhost:3000')
})

When this test executes, Cypress runs the website "localhost:3000" inside an iframe called "Your App ...". Here is the app iframe highlighted in the browser. Notice different JavaScript contexts in the DevTools.

App iframe and separate JavaScript contexts

The test JavaScript runs in a separate iframe called "Your Spec ..." and thus is a walled off garden away from the application's code. Because there is no DOM in the specs (the command reporter lives in the "top" context), the "Your Spec ..." iframe is zero pixels in size.

Spec iframe

Good. But what happens to the JavaScript inside our spec.js file? It gets bundled by Cypress and loaded by the "Your Spec ..." iframe. Here is a typical closure with a variable foo in the spec.js and how it looks when running the spec file

1
2
3
4
5
let foo = 'bar'
debugger
it.only('opens the page', () => {
cy.visit('http://localhost:3000')
})

Bundled spec code

We can see the spec script with this bundled code linked from the "Your Spec ..." iframe document

Spec script loaded by the spec iframe

Bundling component

Now that you understand the 2 iframes in Cypress, we can inspect what happens when you do import Button from '...' from the spec file. It bundles the component code (and possible the framework code) and executes the code in the "Your Spec ..." iframe. Hmm. That might be ... a problem. And it is! Often your component loads styles, which creates style sheets, which ... get attached to the WRONG document element! Thus the mount function for example copies style nodes from the "Your Spec ..." iframe to "Your App ..." iframe. There are other problems like this - styles are extremely problematic because the bundling logic often executes immediately once per spec file, and we need it to execute before each test.

I had such hard time making CSS-in-JS libraries like picostyle work with components, that this is still an open issue #6 in cypress-hyperapp-unit-test. We need to find a way to bundle components differently. I need the bundling to have these two features

  • bundle before each test. Because we want to really isolate each test from the other tests. Brian Mann did an excellent "Best Practices" presentation explaining this best practice in this AssertJS presentation
  • execute the component bundle in the context of "Your App ..." iframe and NOT "Your Spec ...". Which means you will NOT be able to easily access the component's JavaScript, but that is an acceptable tradeoff to me.

So we are going to do the following: instead of importing the component and getting evaluated code like this

1
2
3
4
5
6
7
// instead of this
import ButtonCounter from '../../components/ButtonCounter.vue'
const mount = require('cypress-vue-unit-test')
describe('ButtonCounter', () => {
beforeEach(mount(ButtonCounter))
// tests
})

we are going to just "direct" Cypress to bundle the component and evaluate it.

1
2
3
4
5
6
// do this
const mount = require('bundle-in-app')
describe('ButtonCounter', () => {
beforeEach(mount('../../components/ButtonCounter.vue'))
// tests
})

The function mount will load the passed in file, bundle it (somehow) and then will evaluate the component, but this will be done inside the "Your App ..." frame. Any styles will thus be in the "place", and will be attached to the right document, making the code work as intended.

cy.task + Rollup

To bundle code on demand, I will use new Cypress command cy.task introduced in Cypress v3. This command "closes the gap" and allows your test code (running in the real browser) call and execute "task" code that runs in Node context. Cypress is an Electron application and the Node v8 comes included!

You write your "tasks" in cypress/plugins/index.js file. You can then call tasks by name, pass argument and receive result from the spec files. Common use - file creation, database access, etc.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// in test
cy.task('readJson', 'cypress.json').then((data) => {
// data equals:
// {
// projectId: '12345',
// ...
// }
})
// in plugins/index.js file
on('task', {
readJson () {
// reads the file relative to current working directory
return fsExtra.readJson(path.join(process.cwd(), arg)
}
})

Super - but how are we going to bundle actual code? For this I will use Rollup - because it is a ⭐️️️️⭐️️️️⭐️️️️⭐️️️️⭐️️️️ tool and just keeps getting better! So here is my bundling code (with details omitted, you can find the full code here)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// cypress/plugins/index.js
const rollup = require('rollup')
const postcss = require('rollup-plugin-postcss')
const buble = require('rollup-plugin-buble')
const resolve = require('rollup-plugin-node-resolve')
const { join } = require('path')

// filenames should be from the integration folder
const root = join(__dirname, '..', 'integration')

function bundleRollup (file) {
const inputOptions = {
input: file,
plugins: [
postcss(),
buble({
jsx: 'h' // for Hyperapp
}),
resolve()
]
}

// create a bundle
return rollup.rollup(inputOptions).then(bundle => {
const outputOptions = {
dir: 'dist',
file: 'out.js',
format: 'iife'
}
return bundle.generate(outputOptions).then(({ code, map }) => {
// or write the bundle to disk
// ? how to avoid writing to disk?
return bundle.write(outputOptions)
})
})
}

module.exports = (on, config) => {
on('task', {
roll (filename) {
filename = join(root, filename)
console.log('file to bundle %s', filename)

return bundleRollup(filename)
}
})
}

This code is good for bundling Hyperapp JSX components / applications that use Picostyle, and the important detail - the bundler produces a stand alone IIFE source that just needs to be included as a <script>...</script> element to work. Thus the rest of the "mount" function is simple - I will just run this before each test directly in the spec file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// cypress/integration/spec.js
beforeEach(() => {
cy.task('roll', './text-app.js').then(({ code }) => {
// grab the references to the "Your App ..." document
const doc = cy.state('document')
const script_tag = doc.createElement('script')
script_tag.type = 'text/javascript'
script_tag.text = code
doc.body.appendChild(script_tag)
})
})

const checkFont = size =>
cy
.contains('.p0', 'Picostyle')
.invoke('css', 'fontSize')
.should('be.equal', size)

it('uses smaller font on smaller screen', () => {
cy.viewport(200, 200)
cy.wait(1000)
checkFont('32px')
})

The loaded test shows the style

Picostyle working

What is the root file "text-app.js" that we have bundled? Here it is

cypress/integration/text-app.js
1
2
3
4
5
6
7
8
// cypress/integration/text-app.js
import { app } from 'hyperapp'
import { view } from '../../components/view'
const state = {
text: 'Picostyle'
}
const actions = {}
app(state, actions, view, document.body)

It is a full web application that we construct just for the test. The view function comes from the components folder though - this is our code that we want to exercise - we just combine view with test actions and state.

components/view.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// components/view.js
import { h } from 'hyperapp'
import picostyle from 'picostyle'

const ps = picostyle(h)

export const view = state => {
const keyColor = '#f07'

const Text = ps('span')({
fontSize: '64px',
cursor: 'pointer',
color: '#fff',
padding: '0.4em',
transition: 'all .2s ease-in-out',
textDecoration: 'none',
':hover': {
transform: 'scale(1.3)'
},
'@media (max-width: 450px)': {
fontSize: '32px'
}
})

const Wrapper = ps('div')({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100vw',
height: '100vh',
backgroundColor: keyColor
})

return (
<Wrapper>
<Text>{state.text}</Text>
</Wrapper>
)
}

So our bundler creates a combination of cypress/integration/text-app.js with components/view.js (and hyperapp and picostyle) and we pass this bundled JavaScript as a resolved file from cy.task back to the code running inside the "Your Spec ..." iframe which gets the reference to the "Your App ..." document object and adds a script tag, which evaluates the bundled script, mounting the application! Easy peasy (if you remember which iframe should execute what that is)!

You can find this code in rolling-task repository.

Happy Testing!