&

Henric Trotzig

Founder, Happo.io

@henrictrotzig

WEBCAST

Gleb Bahmutov

Vp of Engineering

@bahmutov

Q & A: use Sli.do event code

#cyhappo

or direct link https://admin.sli.do/event/41izpjbk

Gleb Bahmutov

Vp of Engineering

@bahmutov

WEBCAST

Cypress & Happo.io

Henric Trotzig

Founder, Happo.io

@henrictrotzig

Contents

  1. Visual testing with Happo

  2. Why visual testing is difficult

  3. Music Example App

  4. Tips & Tricks

  5. Pull Request Demo

  6. Q & A

Visual testing with Happo.io

  • Shorten feedback cycles
  • Catch visual regressions/bugs
  • Avoid expensive manual testing

Visualizing diffs

What's the difference between these images?Β 

.general-item-container {
  margin-left: 5px;
}

button {
  border-radius: var(--button-broder-radius);
}

Subtle CSS changes can cause the UI to break

Small regressions accumulate over time and cause larger issues

Brief history of Happo

  • 2013 - Started as an open-source project
  • 2014 - First presentation, at Camp Sass
  • 2017 - Launched as a SAAS product

Happo is component-centric

Basic Happo flow

  1. Run test suite to generate a set of screenshots

  2. Compare those screenshots with a known base

  3. Review diffs manually, approve or reject βœ…

  4. Merge PR/change with the confidence of a Swedish person buying furniture at IKEA πŸ‡ΈπŸ‡ͺ πŸ›‹ πŸ‘Œ

⚠️ Why This Is Hard To Do Yourself

⚠️ Why This Is Hard To Do Yourself

producing consistent images and storing them is difficult 😞

review workflow is difficult 😞

Example: Apple Music clone

Example: Apple Music clone

Tour of main features

describe('apple-music-js app', () => {
  // a tour of app's features without any visual tests
  it('works', () => {
    cy.visit('/');
    cy.get('[data-test="welcome-closing"]')
      .should('be.visible');
    cy.get('[data-test="welcome-closing"]')
      .should('not.be.visible');

    cy.log('**picking an album**')
    cy.contains('Artists').click();
    cy.contains('Coldplay').click({ force: true });
    cy.contains('A Head Full of Dreams').click();
    cy.contains('Hymn for the Weekend').click();

    cy.log('**mini player controls**')
    cy.get('[data-test="mini-pause"]').click();
    cy.get('[data-test="mini-controls"]').click();

    cy.log('**large player controls**')
    cy.get('[data-test=play]').click()
    cy.get('[data-test=pause]').should('be.visible')
    cy.get('[data-test=pause]').click()
    cy.get('[data-test="close-controls"]').click()
  });
});
describe('apple-music-js app', () => {
  // a tour of app's features without any visual tests
  it('works', () => {
    cy.visit('/');
    cy.get('[data-test="welcome-closing"]')
      .should('be.visible');
    cy.get('[data-test="welcome-closing"]')
      .should('not.be.visible');

    cy.log('**picking an album**')
    cy.contains('Artists').click();
    cy.contains('Coldplay').click({ force: true });
    cy.contains('A Head Full of Dreams').click();
    cy.contains('Hymn for the Weekend').click();

    cy.log('**mini player controls**')
    cy.get('[data-test="mini-pause"]').click();
    cy.get('[data-test="mini-controls"]').click();

    cy.log('**large player controls**')
    cy.get('[data-test=play]').click()
    cy.get('[data-test=pause]').should('be.visible')
    cy.get('[data-test=pause]').click()
    cy.get('[data-test="close-controls"]').click()
  });
});

⚠️ Changes we made to the app

  • Replaced API calls with local data

  • Disabled some animations

  • Added `data-test` attributes

Let's run tests (in CI)

# .circleci/config.yml
version: 2.1
orbs:
  cypress: cypress-io/cypress@1
workflows:
  build:
    jobs:
      - cypress/run:
          start: npm run start

Happo build status

Setting up happo in a cypress project

npm install --save-dev happo.io happo-cypress
// At the top of cypress/support/commands.js
import 'happo-cypress';
// In cypress/plugins/index.js
const happoTask = require('happo-cypress/task');

module.exports = (on, config) => {
  on('task', happoTask);
};

.happo.js configuration

// https://docs.happo.io/docs/cypress
const { RemoteBrowserTarget } = require('happo.io')

module.exports = {
  apiKey: process.env.HAPPO_API_KEY,
  apiSecret: process.env.HAPPO_API_SECRET,
  targets: {
    chrome: new RemoteBrowserTarget('chrome', {
      viewport: '1024x768',
    }),
  },
}

firefox, chrome
internet explorer (version 11),
edge, safari, ios-safari (runs on iPhone 7)

Using multiple browsers

module.exports = {
  targets: {
    // The first part ('chrome-mobile' in this case) is just a name we give
    // the specific browser target. You'll see this name in the reports generated
    // as part of a happo run.
    'chrome-mobile': new RemoteBrowserTarget('chrome', {
      viewport: '320x640',
    }),
    'chrome-medium': new RemoteBrowserTarget('chrome', {
      viewport: '800x600',
    }),
    'firefox-desktop': new RemoteBrowserTarget('firefox', {
      viewport: '1024x768',
    }),
    'internet-explorer': new RemoteBrowserTarget('internet explorer', {
      viewport: '800x600',
    }),
    'ios-safari': new RemoteBrowserTarget('ios-safari', {
      viewport: '375x667',
    }),
  },
};

πŸ’‘ Use the same viewport

{
  "baseUrl": "http://localhost:3000",
  "viewportWidth": 400,
  "viewportHeight": 700
}

cypress.json

// https://docs.happo.io/docs/cypress
const { RemoteBrowserTarget } = require('happo.io')

// use the same resolution as cypress.json
const cypressConfig = require('./cypress.json')
const width = cypressConfig.viewportWidth || 1000
const height = cypressConfig.viewportHeight || 660
const viewport = `${width}x${height}`

module.exports = {
  apiKey: process.env.HAPPO_API_KEY,
  apiSecret: process.env.HAPPO_API_SECRET,
  targets: {
    chrome: new RemoteBrowserTarget('chrome', {
      viewport,
    }),
  },
}

.happo.js

Using happo in your Cypress tests

describe('city page', () => {
  it('is functional', () => {
    cy.visit('/stockholm');

    cy.get('nav').happoScreenshot({ component: 'Navbar' });

    cy.get('nav [data-test="dropdown-button"]').click();
    cy.get('nav').happoScreenshot({
      component: 'Navbar',
      variant: 'Dropdown open',
    });

    cy.get('[data-test="subscribe-form"]').happoScreenshot({
      component: 'Subscribe form',
    });
  });
});

πŸ’‘ Check then snapshot

describe('city page', () => {
  it('is functional', () => {
    cy.visit('/stockholm');

    cy.get('nav').happoScreenshot({ component: 'Navbar' });
    cy.get('nav [data-test="dropdown-button"]').click(); // action
    cy.get('nav').happoScreenshot({
      component: 'Navbar',
      variant: 'Dropdown open',
    });

    cy.get('[data-test="subscribe-form"]').happoScreenshot({
      component: 'Subscribe form',
    });
  });
});

Has the app updated in response to click?

πŸ’‘ Check then snapshot

describe('city page', () => {
  it('is functional', () => {
    cy.visit('/stockholm');

    cy.get('nav').happoScreenshot({ component: 'Navbar' });
    cy.get('nav [data-test="dropdown-button"]').click(); // action
    cy.get('nav [data-test="dropdown-open"]').should('be.visible');
    cy.get('nav').happoScreenshot({
      component: 'Navbar',
      variant: 'Dropdown open',
    });

    cy.get('[data-test="subscribe-form"]').happoScreenshot({
      component: 'Subscribe form',
    });
  });
});

Real-world example from Lightstep codebase

  it('renders a chart with deployment marker, timeseries data', () => {
    const options = { timeout: CHART_LOADING_TIMEOUT };
    cy.getByTestId('latency-chart')
      .assertChartHasDeployMarker({
        options,
      })
      .getByTestId('latency-chart')
      .happoScreenshot();
    cy.getByTestId('error-chart')
      .assertChartHasDeployMarker({
        options,
      })
      .getByTestId('error-chart')
      .happoScreenshot();
    cy.getByTestId('rate-chart')
      .assertChartHasDeployMarker({
        options,
      })
      .getByTestId('rate-chart')
      .happoScreenshot();
  });
describe('apple-music-js app', () => {
  it('can be used to play a song', () => {
    cy.visit('/');

    cy.log('**loading screen**');
    cy.get('[data-test="welcome-closing"]').should('be.visible');
    cy.get('body').happoScreenshot({ component: 'Loading screen' });
    cy.get('[data-test="welcome-closing"]').should('not.be.visible');
    cy.get('body').happoScreenshot({ component: 'Main screen' });

    cy.log('**picking an album**');
    cy.contains('Artists').click();
    cy.contains('Coldplay');
    cy.get('body').happoScreenshot({ component: 'Artists screen' });
    cy.contains('Coldplay').click({ force: true });
    cy.contains('A Head Full of Dreams').click();
    cy.get('[data-test="album-button"]').happoScreenshot({
      component: 'Album button',
    });
    cy.get('[data-test="mini-controls"]').happoScreenshot({
      component: 'Mini controls',
      variant: 'no track selected',
    });

    cy.log('**picking a song**');
    cy.contains('Hymn for the Weekend').click();

    cy.log('**mini player controls**');
    cy.get('[data-test="mini-pause"]').click();
    cy.get('[data-test="mini-controls"]').happoScreenshot({
      component: 'Mini controls',
      variant: 'track selected',
    });
    cy.get('[data-test="mini-controls"]').click();

    cy.log('**large player controls**');
    cy.get('[data-test=play]').click();
    cy.get('[data-test=pause]').should('be.visible');
    cy.get('[data-test=pause]').click();
    cy.get('[data-test="controls"]').happoScreenshot({
      component: 'Large controls',
    });
    cy.get('[data-test="close-controls"]').click();
  });
});

Demo time πŸš€

πŸ’‘ Hide dynamic elements

cy.get('.game__cell').each(
  $cell => $cell.css('opacity', '0'))
// take snapshot of the entire board

πŸ’‘ Use mock data

const str = '713.94528294851637568...914871935246425186379936472185.8..4.7...57..849..4....8..'
const sudoku = getSudoku()
cy.stub(sudoku, 'generate').returns(str)
cy.stub(Math, 'random').returns(0.5)

cy.clock()
mount(<App />)
cy.get('.game__cell--filled')
  .should('have.length', 45)
// take snapshot of the entire board

πŸ’‘ Just add wait

cy.wait(500)
// take snapshot

Time for Q & A