We make

Mobile and

web apps

for

innovators

Cypress.io & Docker: the Ultimate E2E Stack (part1)
August 7, 2017 | By Dziamid Zayankouski

Cypress.io & Docker: the Ultimate E2E Stack (part1)

People love and hate End-to-End testing and for valid reasons. I've seen many projects (including my own) get fascinated with automated End-to-End testing and gradually come to a point where the test cases become flaky, slow and totally ignored. Let's see why and how to make E2E testing both a good developer experience and a solid firewall for software regressions.

For the purpose of the demo, we will be testing a React\Node.js application which is a recruitment platform for HR agencies. By the end of the article we are going to have these test cases implemented:

  • User can sign up
  • User can login
  • User can post a job ad
  • User can see a candidate appear on a job board

Use Cypress.io

For quite a while there is been one major player in the field of E2E web application testing - selenium. Most of the solutions out there were basically building their APIs on top of selenium, so they all suffered from the same problem that selenium has. Cypress.io is a new player that has no selenium dependency and is set to address the shortcoming of its predecessor.

Let's see what Cypress API can look like with our test cases.


describe('Smoke tests', () => {
  it('User can sign up', () => {
    cy
      .signup()
      .get('body').contains('Create Your First Job');
    });
 
  it('user can login', () => {
    cy
      .login()
      .get('body').contains('Create Your First Job')
  });
});

OK, there is no magical signup() or login() methods, but there is a nice API for extending the 'cy' global with custom methods:


 Cypress.addParentCommand("login", (email, password) => {
    cy
      .visit('/')
      .get('form input[name="email"]').clear().type(email)
      .get('form input[name="password"]').clear().type(password)
      .get('form button').click()
  });
 
  Cypress.addParentCommand("signup", (name, email, password) => {
    cy
      .visit('/')
      .get('body').contains('Sign Up').click()
      .submitSignupForm(name, email)
      .followLinkFromEmail()
      .submitProfileForm(name, email)
      .get('body').contains('Create Your Company')
      .submitCompanyForm()
      .get('body').contains('Add Team Members')
      .get('button').contains('Skip').click()
      .get('body').contains('Create Your First Job')
  });

Design initial state for every test case

If we are to make the testing fast, we will need to start every test case from a predefined state of the application. Let's define the initial states for every test case:

  • “User can sign up”. We don't really need any user-related data in the database. Though there can be some read-only data present to support the application. Let’s call it "empty" state.
  • “User can login” and “User can post a job ad” both suggest that user has already undergone the signup flow, so the minimal initial state is - “signed-up
  • And finally “User can see a candidate appear on a job board” needs a job to be present, hence “job-posted” state.

So, let’s update our test cases to explicitly define the states:


describe('Smoke tests', () => {
  it('User can sign up', () => {
    cy
      .state('empty')
      .signup()
      .get('body').contains('Create Your First Job');
    });
 
  it('user can login', () => {
    cy
      .state('signed-up')
      .login()
      .get('body').contains('Create Your First Job')
  });
});

The state function makes an XHR request to the API that resets its state to some predefined state.


Cypress.addParentCommand("state", (...states) => {
  cy
    .request({
      url: `${Cypress.env('BASE_API')}/integration/state`,
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ states: states })
    });
});

We do need some support code to assist in setting the state, but the effort involved pays off in performance and maintainability of your tests. On the backend we are using MongoDB native, so the code in question can look like this:


const stateLoaders = {
  'empty': require('./states/empty'),
  'signed-up': require('./states/signed-up'),
  ...
};
 
export const loadState = async (db, states = ['empty']) => {
  await clean(db);
 
  for (let state of states) { //many states? well sometimes you need to test complex scenarios
    await stateLoaders[state].load(db);
  }
};
 
const clean = async (db) => {
  const collections = await db.listCollections().toArray();
  const names = collections
    .filter(c => c.name.indexOf('system.') !== 0)
    .map(c => c.name);
 
  return Promise.all(names.map(name => db.collection(name).remove()));
};

One can argue why have many states when you can have one big state for all cases. The answer is maintainability and performance. First, you save a lot of time on not loading the data that you don't need. But what’s more important is maintainability. You application state that you are going to need for testing may conflict with each other.

For example, you want to test a case where user submitted a sign-up form but did not verify his email, so you will need a special user for that, and now we have 2 users in your database and you will have to differentiate between them in your tests. You will quickly notice that the amount of data in your state is hard to reason about. Whereas if you choose to run test case against a minimal possible state, it is easy to track state changes.

Stay tuned for more articles in this series. We will explore even more best practices that contribute to making your test reliable, maintainable and performant. We will also finish up the implementation of remaining test cases and setup the whole thing to run on CI. Let me know in the comments below if you found it useful and would like to see a working code sample on GitHub.


Written by DashBouquet Precious Diamond Dziamid Zayankouski

Subscribe & stay tuned
Back to top

CONTACT US


start your project