Note: the source code for this blog post is in repo cypress-io/cypress-example-realworld in the pull request #55.
End-to-end browser tests do not have to go every time through the DOM interface to exercise the web application. In fact, doing so would make the end-to-end tests terribly slow and inefficient. In this post I will show a concrete example that bypasses the HTML interface for all the but the first test, yet keeps covering the same amount of code (measured by collecting end-to-end code coverage), and runs much much faster.
First test
Our target application is a clone of the Conduit blog web application, and we want to confirm that we can write a new article. Using Cypress Test Runner we can write our first "writes a post" test.
1 | describe('New post', () => { |
The test passes
The above test is already pretty solid:
- the initial state is set using
beforeEach
callback, ensuring the test starts every time from a clean slate - the test uses
data-cy
attributes to find elements following Best Practices
Because we can measure code coverage from Cypress tests, just by running this single test we get 54.2% of all front-end code statements covered.
Second test
Great, writing a new post works. Let's see if we can comment on a post. Hmm, to add a comment we need a blog post. We can copy the above test and just add a few additional Cypress commands to add a comment.
1 | it('writes a post and comments on it', () => { |
The test runs creating a new blog post and then commenting on it.
After this test finishes, the total code coverage increases by 3%
We can see the new lines covered in the "Article" reducer, the "ADD_COMMENT" action has been covered by the new commands.
Nice.
Page object
Yet, there is a problem. The second test "writes a post and comments on it" is exactly 70% line for line matching the first test "writes a post". We are creating the post by clicking and typing on the page - repeating exactly the same page actions as the first test. What have we learned from about 2 seconds it takes to type the new post (just like a real user would type character by character), that we don't know already from the first test?
Nothing.
So to remove the duplicate lines, people write Page Objects, a wrapper around the HTML and the elements of the a particular page. We avoid the code duplication by having our tests call into the Page Object wrapper. In our case, the test would be
1 | it('writes a post (via page object) and comments on it', () => { |
The above test works, and removes code duplication. Every time you need an article to test commenting on it, or to test how an article can be deleted, or how a user can like it - every test can just call editor.writeArticle()
and be done.
App action
Let's see why the Page Object is less than ideal.
- The commands going through the DOM are slow. We will see how to avoid it later.
- Opening the editor and typing into the input boxes again and again from every test does not help us test better, because it is just redundant commands that do the same thing over and over.
- The Page Object is an extra layer of code that does not benefit the users and is built on top of the HTML, and can be only tested at runtime without any static tools help
Once we have "writes a post" test that goes through the DOM to confirm that a user can write a new article, there is no point doing it again. Instead we can directly create an article by accessing the underlying application code and calling its methods - I call this approach "app actions", and argue in the blog post Stop using Page Objects and Start using App Actions that this approach saves you time and removes an unnecessary level of code sitting on top of the HTML. And this is not about code duplication - yes, you could factor out writing the post into a single reusable function (this would be a Page method). The point is that we want to avoid going through the DOM completely to perform an action that we have already tested!
In practice, this means we need to access the underlying web application to create the post somehow. This is how we do it.
First, study the application code to see how the UI components trigger actions. In our case the Editor.js
component submits an article via an "agent" object reference.
1 | import agent from '../agent' |
Ok, so if we could call agent.Articles.create
method directly from our Cypress test, we could create an article almost instantly. Let's pass this agent
reference from the application to the test via window
object.
1 | import agent from './agent' |
The test can grab the window
object, then its agent
property and call the application action.
1 | it('writes a post (via app action) and comments on it', () => { |
I can also see by inspecting the code that the Editor executes the following code after the agent submits the article
1 | case ARTICLE_SUBMITTED: |
Ok, we can do the same thing - we can use the result returned by the agent to get the new article's slug and redirect to the article url. Thus the full test should redirect to the article url like this
1 | cy.window().its('agent.Articles') |
The test runs and passes - creating the new post instantly.
If we run both tests "writes a post" and "writes a post (via app action) and comments on it" togher - we get exactly the same code coverage percentage 57.3% - because we literally covered the same statements as before - we just did not cover some of the twice.
Api action
When we bypass the DOM to set our state (we need an article) before testing a feature (like adding a new comment), we do not even have to use the application code. By studying the Network tab and the application code we can see the HTTP POST request that happens when a new article is sent to the server.
Super, we can execute the same call ourselves. Here is the custom command (for reusability) and the test
1 | Cypress.Commands.add('postArticle', fields => { |
Super, the test passes, and we have a reusable custom command cy.postArticle
we can use any time we need an article to comment on, or to like, or to delete - thus all our end-to-end tests can run fast, yet because we have already tested creating a new article, the code coverage stays complete.
For me, the rule of thumb is:
- when testing feature A, use the DOM just like a real user would.
- when testing feature B, that needs something feature A does, bypass the DOM when achieving part A. Instead call the application code directly, just like the UI component that implements feature A would. This saves a lot of time, making your tests fly, yet does not diminish the test code coverage.