April 24th, 2019

Functional End-to-end Tests for RealWorld App

  • Creating a test user
  • Logging in via API
  • Test shortcuts
  • Clearing the data before tests

Functional End-to-end Tests for RealWorld App

Add your questions during this webinar

"The mother of all demo apps" — Exemplary fullstack Medium.com clone

Conduit (RealWorld) App

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

Conduit (RealWorld) App

Write new posts using Markdown

Conduit (RealWorld) App

Read each post, comment

Web app at port 4100

API at port 3000

git clone
npm install
npm start

Functional tests

User can log in

User can write a blog post

User can read posts

User features covered in order of importance

$ npm install -D cypress

For starters:

Webinar app + tests:

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

{
  "baseUrl": "http://localhost:4100",
  "viewportHeight": 1000,
  "viewportWidth": 1000
}

cypress.json file with all Cypress settings

{
  "scripts": {
     "start": "concurrently npm:start:client npm:start:server",
     "start:client": "cd client && npm start",
     "start:server": "cd server && npm start",
     "cypress:open": "cypress open",
     "cypress:run": "cypress run"
  }
}
npm start

starts the application

npm run cypress:open

User can NOT log in

Without a valid account

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

User can NOT log in

describe('Conduit Login', () => {
  beforeEach(() => {
    cy.visit('/')
  })
  it('does not work with wrong credentials', () => {
    cy.contains('a.nav-link', 'Sign in').click()

    cy.get('input[type="email"]').type('wrong@email.com')
    cy.get('input[type="password"]').type('no-such-user')
    cy.get('button[type="submit"]').click()

    // error message is shown and we remain on the login page
    cy.contains('.error-messages li', 'User Not Found')
    cy.url().should('contain', '/login')
  })
})

User can log in

Hmm, we need a test account

  • Could create a test user manually 😑
  • Create a test user from tests ✅

Tip: observe what the application does during new user sign up

XHR call

We can do the same before the test

Cypress.Commands.add('registerUserIfNeeded', () => {
  cy.request({
    method: 'POST',
    url: 'http://localhost:3000/api/users',
    body: {
      user: {
        username: 'testuser',
        image: 'https://robohash.org/6FJ.png?set=set3&size=150x150',
        // email, password from cypress.json
        // or from environment variables
        ...Cypress.env('user'),
      },
    },
    // ignore duplicate user error
    failOnStatusCode: false,
  })
})

Custom command "cy.registerUserIfNeeded" in cypress/support/index.js file

User can log in

  • register test user
  • log in via UI
  • check the page

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

User can log in

describe('Conduit Login', () => {
  before(() => cy.registerUserIfNeeded())
  beforeEach(() => { cy.visit('/') })
  it('logs in', () => {
    cy.contains('a.nav-link', 'Sign in').click()

    const user = Cypress.env('user')
    cy.get('input[type="email"]').type(user.email)
    cy.get('input[type="password"]').type(user.password)
    cy.get('button[type="submit"]').click()

    // when we are logged in, there should be two feeds
    cy.contains('a.nav-link', 'Your Feed').should('have.class', 'active')
    cy.contains('a.nav-link', 'Global Feed')
      .should('not.have.class', 'active')
    cy.url().should('not.contain', '/login')
  })
})
Username and password
{
  "baseUrl": "http://localhost:4100",
  "env": {
    "user": {
      "email": "tester@test.com",
      "password": "password1234"
    }
  },
  "viewportHeight": 1000,
  "viewportWidth": 1000
}

cypress.json file with all Cypress settings

// from tests
const user = Cypress.env('user')
cy.get('input[type="email"]').type(user.email)
cy.get('input[type="password"]').type(user.password)
User can write a blog post

1. Login

     * via API ✅✅✅

Organizing Tests, Logging In, Controlling State

https://www.youtube.com/watch?v=5XQOK0v_YRE

2. Add new blog post

3. Check if it appears in the feed

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

Observe what your web application is doing during sign in

Web application makes a POST request to log in

We can do the same before each test

The response includes a token

This token is saved in local storage under "jwt" key

Login via API
Cypress.Commands.add('login', () => {
  cy.request('POST', 'http://localhost:3000/api/users/login', {
    user: Cypress.env('user'),
  })
    .its('body.user.token')
    .should('exist')
    .then((token) => {
      localStorage.setItem('jwt', token)
    })
  // visit will run AFTER cy.request
  // finishes successfully
  cy.visit('/')
})

login command in cypress/support/index.js

User can write a blog post
describe('New post', () => {
  before(() => cy.registerUserIfNeeded())
  beforeEach(() => {
    cy.login()
  })

  it('writes a post and comments on it', () => {
    cy.contains('a.nav-link', 'New Post').click()

    // editor is displayed
    ...
  })
})

How to select these input fields?

<fieldset className='form-group'>
  <input
    className='form-control form-control-lg'
    type='text'
    placeholder='Article Title'
    data-cy='title'
    value={this.props.title}
    onChange={this.changeTitle}
  />
</fieldset>

Add selectors for testing

User can write and comment
it('writes a post and comments on it', () => {
  cy.contains('a.nav-link', 'New Post').click()

  // submit new post
  // I have added "data-cy" attributes to select input fields
  cy.get('[data-cy=title]').type('my title')
  cy.get('[data-cy=about]').type('about X')
  cy.get('[data-cy=article]').type('this post is **important**.')
  cy.get('[data-cy=tags]').type('test{enter}')
  cy.get('[data-cy=publish]').click()

  // comment on the post
  cy.get('[data-cy=comment-text]').type('great post 👍')
  cy.get('[data-cy=post-comment]').click()

  // check that comment appears
  cy.contains('[data-cy=comment]', 'great post 👍').should('be.visible')
})

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

User can write a blog post
// move long post fields into own JS file
import { title, about, article, tags } from '../fixtures/post'

it('adds a new post', () => {
  cy.contains('a.nav-link', 'New Post').click()

  cy.get('[data-cy=title]').type(title)
  cy.get('[data-cy=about]').type(about)
  ...
})

import post contents from another JavaScript file

tip: these slides are arranged in columns,

so press the Down arrow to see the next slide

User can add tags
const tags = ['code', 'testing', 'cypress.io']
cy.get('[data-cy=tags]').type(tags.join('{enter}') + '{enter}')
cy.get('[data-cy=publish]').click()

// check that each tag is displayed after post is shown
cy.url().should('match', /my-title$/)
tags.forEach(tag => cy.contains('.tag-default', tag))

set and check a list of tags: Cypress is just JavaScript™

User can add tags
// move long post fields into own JS file
import { title, about, article, tags } from '../fixtures/post'

it('adds a new post', () => {
  cy.contains('a.nav-link', 'New Post').click()

  cy.get('[data-cy=title]').type(title)
  cy.get('[data-cy=about]').type(about)
  cy.get('[data-cy=article]')
    .type(article)
})

Typing entire post is slow

import { stripIndent } from 'common-tags'
const post = stripIndent`
  # Fast tests

  > Speed up your tests using direct access to DOM elements

  You can set long text all at once and then trigger \`onChange\` event.
`

cy.get('[data-cy=article]')
  .invoke('val', post)
  .type('{enter}')

Set DOM value directly

End-to-end tests

First, test the entire page like a user

When testing other features, take shortcuts

Application shortcuts 💡

Redux events in this application

if (window.Cypress) {
  window.store = store
}
// dispatch Redux actions
cy.window()
  .its('store')
  .invoke('dispatch', {
    type: 'UPDATE_FIELD_EDITOR',
    key: 'body',
    value: article
  })

application index.js

from test

Verify the post is saved
// after submitting the new blog post
cy.request('http://localhost:3000/api/articles?limit=10&offset=0')
  .its('body')
  .should(body => {
    expect(body).to.have.property('articlesCount', 1)
    expect(body.articles).to.have.length(1)
    const firstPost = body.articles[0]
    expect(firstPost).to.contain({
      title,
      description: about,
      body: article
    })
  })

Problem: when we run the test for the second time, it fails

Before each test:

Connect to the database and clean any existing data

Cypress tests run in the browser

Database connection cannot be done directly from the browser

const { join } = require('path')
const knexFactory = require('knex')

module.exports = (on, config) => {
  on('task', {
    deleteAllArticles () {
      const filename = join(__dirname, '..', '..', 'server', '.tmp.db')
      const knex = knexFactory({
        client: 'sqlite3',
        connection: {
          filename
        },
        useNullAsDefault: true
      })
      // truncates all tables which removes data left by previous tests
      return Promise.all([
        knex.truncate('Articles'),
        knex.truncate('ArticleTags'),
        knex.truncate('Comments')
      ])
    }
  })
}

Write backend Nodejs code in the Cypress plugins file

Before each test:

Connect to the database and clean any existing data

before(() => cy.registerUserIfNeeded())
beforeEach(() => {
  cy.task('deleteAllArticles')
  cy.login()
})

cy.task calls the Cypress backend to run Nodejs code

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<!-- <link rel="stylesheet" href="/main.css" /> -->

What if CSS does not load?

Functional tests still pass!

Functional Tests 

+

Visual Tests

Take it, Gil!