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
- read the blog post Sliding Down the Testing Pyramid that explains the principles behind component testing using E2E test runner
- watch the AssertJS video (slides) where I talk about this approach
- take a look at cypress-vue-unit-test repo which has a typical adaptor for Vue.js framework.
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 | import ButtonCounter from '../../components/ButtonCounter.vue' |
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 | it('opens the page', () => { |
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.
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.
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 | let foo = 'bar' |
We can see the spec script with this bundled code linked from the "Your Spec ..." iframe document
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 | // instead of this |
we are going to just "direct" Cypress to bundle the component and evaluate it.
1 | // do this |
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 | // in test |
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 | // cypress/plugins/index.js |
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 | // cypress/integration/spec.js |
The loaded test shows the style
What is the root file "text-app.js" that we have bundled? Here it is
1 | // cypress/integration/text-app.js |
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
.
1 | // components/view.js |
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!