Visual regression testing

Plan

  1. Take a screenshot of a component, on baseline version. Store it locally.
  2. Take a new screenshot of the same component, on updated version. Store it locally.
  3. Merge both immages into a single one.

Setup

  1. Install the packages

    1
    2
    npm i cypress
    npm i cypress-plugin-snapshots

Generate images

  1. Create configuration

    • Add it to cypress.json file.

      1
      2
      3
      4
      {
      "baseUrl": "http://localhost:8000/",
      "video": false
      }
  2. Create the test file.

    • Commands and APIs.

      • cy.screenshot(): it can, out of the box, create individual images of components.
      • After Screenshot API: it allows us to rename files, change directories, and distinguish visual regression runs from standard ones.
    • Extend the plugins/index.js file in our plug-ins directory to support the two new run types (baseline and comparison) and keep history. Then, we set the path for our images according to the run type:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      const fs = require('fs')
      const path = require('path')
      module.exports = (on, config) => {
      // Add to config, so they can be accessed in your tests
      config.env.baseline = process.env.BASELINE || false
      config.env.comparison = process.env.COMPARISON || false

      on('after:screenshot', details => {
      // only modify the behavior of baseline and comparison runs
      if (config.env.baseline || config.env.comparison) {
      // keep track of file name and number to make sure they are saved
      // in the proper order and in their relevant folders.
      let lastScreenshotFile = ''
      let lastScreenshotNumber = 0

      // append the proper suffix number, create the folder, and move
      const createDirAndRename = filePath => {
      if (lastScreenshotFile === filePath) {
      lastScreenshotNumber++
      } else {
      lastScreenshotNumber = 0
      }
      lastScreenshotFile = filePath
      const newPath = filePath.replace(
      '.png',
      ` #${lastScreenshotNumber}.png`
      )

      return new Promise((resolve, reject) => {
      fs.mkdir(path.dirname(newPath), { recursive: true }, mkdirErr => {
      if (mkdirErr) {
      return reject(mkdirErr)
      }
      fs.rename(details.path, newPath, renameErr => {
      if (renameErr) {
      return reject(renameErr)
      }
      resolve({ path: newPath })
      })
      })
      })
      }

      const screenshotPath =
      `visualComparison/${config.env.baseline ? 'baseline' : 'comparison'}`

      return createDirAndRename(details.path
      .replace('cypress/integration', screenshotPath)
      .replace('All Specs', screenshotPath)
      )
      }
      })
      return config
      }
    • We may create a separate custom command and then have that new command take a screenshot after finding the element.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      Cypress.Commands.add("getAndScreenshot", (selector, options) => {
      // Note: You might need to tweak the command when getting multiple elements.
      return cy.get(selector).screenshot()
      });

      it("get overwrite", () => {
      cy.visit("https://example.cypress.io/commands/actions");
      cy.getAndScreenshot(".action-email")
      })
  3. Trigger tests.

    • Update package.json.

      1
      2
      3
      4
      "scripts": {
      "cypress:baseline": "BASELINE=true yarn cypress:open",
      "cypress:comparison": "COMPARISON=true yarn cypress:open"
      }

Merge the screenshots

Setup

imagediff

  • example

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    const imagediff = require('imageDiff')

    var imageA = new Image();
    ImageA.src = "image1-file.png";

    var imageB = new Image();
    ImageB.src = "image2-file.png";

    // Create the image that shows the difference between the images
    var diff = imagediff.diff(imageA, imageB);

    // Create a canvas with the imagediff method (with the size of the generated image)
    var canvas = imagediff.createCanvas(diff.width, diff.height);

    // Retrieve the 2d context
    var context = canvas.getContext('2d');

    // Draw the generated image with differences on the canvas
    context.putImageData(diff, 0, 0);

    // Now you can do whatever you want with the canvas
    // for example render it inside a div element:
    document.getElementById("some-div-id").appendChild(canvas);

Best practices

Recognize the need for visual testing

  • Assertions that verify style properties.

  • If E2E tests become full of assertions checking visibility, color and other style properties, it might be time to start using visual diffing to verify the page appearance.

  • ❌ Incorrect usage:

    1
    2
    3
    4
    // avoid this
    cy.get('.completed').should('have.css', 'text-decoration', 'line-through')
    .and('have.css', 'color', 'rgb(217,217,217)')
    cy.get('.user-info').should('have.css', 'display', 'none')

DOM state

  • Best Practice: take a snapshot after you confirm the page is done changing.

    • ❌ Incorrect usage:

      1
      2
      3
      4
      // the web application takes time to add the new item,
      // sometimes it takes the snapshot BEFORE the new item appears
      cy.get('.new-todo').type('write tests{enter}')
      cy.mySnapshotCommand()
    • ✅ Correct usage:

      1
      2
      3
      4
      5
      6
      7
      // use a functional assertion to ensure
      // the web application has re-rendered the page
      cy.get('.new-todo').type('write tests{enter}')
      cy.contains('.todo-list li', 'write tests')
      // great, the new item is displayed,
      // now we can take the snapshot
      cy.mySnapshotCommand()

Timestamps

  • Best Practice: control the timestamp inside the application under test.

    • ✅ Correct usage:

      1
      2
      3
      4
      5
      const now = new Date(2018, 1, 1)

      cy.clock(now)
      // ... test
      cy.mySnapshotCommand()

Application state

  • Best Practice: use cy.fixture() and network mocking to set the application state.

    1
    2
    3
    4
    5
    // stub network calls with intercept, ensure result
    cy.intercept('/api/items', { fixture: 'items' }).as('getItems')
    // ... action
    cy.wait('@getItems')
    cy.mySnapshotCommand()

Visual diff elements

  • Best Practice: use visual diffing to check individual DOM elements rather than the entire page.
    • ✅ Correct usage: targeting specific DOM element will help avoid visual changes from component “X” breaking tests in other unrelated components.

Component testing

  • Best Practice: use Component Testing Plugins to test the individual components functionality in addition to end-to-end and visual tests.

Features

  • Developer friendly.
  • Open source (MIT).
  • It can records snapshots and videos of your tests.
  • It has a test runner console.
  • It only currently supports Chrome variants and Firefox.
  • It doesn’t have native iframe support, though there are workarounds.
  • It has its own promise-based system that you have to use (it can’t use ES6 promises).

Functional E2E Testing

Set up

  • As dev dependency.

    1
    npm install cypress --save-dev
  • Just use it for execution.

    1
    2
    # launches the modal helper
    npx cypress open

Write tests

  1. Create configuration.
  • Add it to cypress.json file.

    1
    2
    3
    {
    "baseUrl": "http://localhost:8000/"
    }
  1. Create the runner.
  • Starts the dev server, runs all tests fro my_module, via scripts/cypress.js.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    return devServer.listen(port, HOST, err => {
    if (err) {
    return console.log(err);
    }
    if (isInteractive) {
    clearConsole();
    }
    console.log(chalk.cyan('Starting the development server...\n'));
    return cypress
    .run({
    spec: './cypress/integration/my_module/*.js',
    })
    .then(results => {
    devServer.close();
    });
    });
  1. Create the test file.
  • cypress/integration/my_module/sidebar.js.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    /* globals context cy */
    /// <reference types="Cypress" />
    context('Sidebar', () => {
    beforeEach(() => {
    cy.visit('/');
    // example: review progress bar
    cy.contains('Progress').should('exist');
    });

    // dummy example
    it('does something', () => {
    cy.contains('My_module Exercises');
    });

    it('closes when X is clicked and reopens when hamburger is clicked', () => {
    cy.get('[data-testid=closeSidebar]').click();
    cy.contains('Progress').should('not.exist');
    cy.get('[data-testid=openSidebar]').click();
    cy.contains('Progress').should('exist');
    });

    it('navigates to /up-going when Up & Going is picked', () => {
    cy.contains(/Up & Going \(/).click({ force: true });
    cy.url().should('include', '/up-going');
    cy.contains('Chapter 1: Into Programming').should('exist');
    cy.contains('Chapter 2: Into JavaScript').should('exist');
    });
    });
  1. Trigger tests.
  • Add command on scripts as via package.json.

    1
    2
    3
    "scripts": {
    "cypress": "node scripts/cypress.js"
    },

Code snippets

Visit a page

1
2
3
4
5
describe('My First Test', () => {
it('Visits the Kitchen Sink', () => {
cy.visit('https://example.cypress.io')
})
})

Query for an element

1
2
3
4
5
6
7
describe('My First Test', () => {
it('finds the content "type"', () => {
cy.visit('https://example.cypress.io')

cy.contains('type')
})
})

Click on an element

1
2
3
4
5
6
7
describe('My First Test', () => {
it('clicks the link "type"', () => {
cy.visit('https://example.cypress.io')

cy.contains('type').click()
})
})

Make an assertion

1
2
3
4
5
6
7
8
9
10
describe('My First Test', () => {
it('clicking "type" navigates to a new url', () => {
cy.visit('https://example.cypress.io')

cy.contains('type').click()

// Should be on a new URL which includes '/commands/actions'
cy.url().should('include', '/commands/actions')
})
})

Multi-command/multi-assertion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
describe('My First Test', () => {
it('Gets, types and asserts', () => {
cy.visit('https://example.cypress.io')

cy.contains('type').click()

// Should be on a new URL which includes '/commands/actions'
cy.url().should('include', '/commands/actions')

// Get an input, type into it and verify that the value has been updated
cy.get('.action-email')
.type('fake@email.com')
.should('have.value', 'fake@email.com')
})
})

Problem

  • Codebuild works only with a zip, not cloning the repository.
  • Tools like sonar-scanner don not have the access to the .git directory out of the box, so the git blame commands on code analysis fail.

Step by step solution

Allow CodeBuild to clone your repository

Give your CodeBuild execution role the permission codecommit:GitPull into the repository:

1
2
3
4
5
6
7
8
9
{
"Effect": "Allow",
"Action": [
"codecommit:GitPull"
],
"Resource": [
"arn:aws:codecommit:*:*:<YOUR_REPOSITORY_NAME>"
]
},

Give CodeBuild the git credential helper

On buildspec.yml, start like this:

1
2
version: 0.2env:
git-credential-helper: yes

Cloning the repository

Clone the repository at the right branch

1
2
TEMP_FOLDER="$(mktemp -d)"
git clone --quiet "$REPO_URL" "$TEMP_FOLDER"

Wind the repository back to the commit that is being built

Otherwise you will have a race condition between commits and builds, which could result in the wrong git metadata being used.

1
2
3
4
5
6
cd "$TEMP_FOLDER"
git fetch --tags
if [ ! -z "${BRANCH:-}" ]; then
git checkout "$BRANCH"
fi
git reset --hard "$CODEBUILD_RESOLVED_SOURCE_VERSION"

Copy the .git directory to the source tree.

1
mv .git "$WORKING_DIR"

Full shell example

Shell script

Cloning the repository is complex enough to be in a separate script which deals with this single concern and performs some error checking.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# called used environment variables come from the CFN stack
# - scripts/codebuild-git-wrapper.sh "$REPO_URL" "$REPO_BRANCH"

# This function prints the usage
function usage {
{
echo "Usage:"
echo " ${BASH_SOURCE[0]} <REPO_URL> <BRANCH>"
echo " REPO_URL - the URL for the CodeCommit repository"
echo " BRANCH - (optional) the branch to check out."
echo " Defaults to the default branch."
} >&2
}

# Confirm that there are at least two arguments
if [ "$#" -lt 1 ]; then
usage
exit 1
fi

# Confirm that CODEBUILD_RESOLVED_SOURCE_VERSION is set
if [ -z "${CODEBUILD_RESOLVED_SOURCE_VERSION:-}" ]; then
{
echo "Error: CODEBUILD_RESOLVED_SOURCE_VERSION is not set"
} >&2
usage
exit 1
fi

# Read arguments
REPO_URL="$1"
if [ ! -z "${2:-}" ]; then
BRANCH=$2
fi

# Remember the working directory
WORKING_DIR="$(pwd)"

# Check out the repository to a temporary directory
# Note that --quiet doesn't work on the current CodeBuild agents, but
# hopefully it will in the future
TEMP_FOLDER="$(mktemp -d)"
git clone --quiet "$REPO_URL" "$TEMP_FOLDER"

# Wind the repository back to the specified branch and commit
cd "$TEMP_FOLDER"
git fetch --tags
if [ ! -z "${BRANCH:-}" ]; then
git checkout "$BRANCH"
fi
git reset --hard "$CODEBUILD_RESOLVED_SOURCE_VERSION"

# Confirm that the git checkout worked
if [ ! -d .git ] ; then
{
echo "Error: .git directory missing. Git checkout probably failed"
} >&2
exit 1
fi

mv .git "$WORKING_DIR"

Buildspec

You may call that script on the buildspec.yml that uses the script and then prints the top of the git log.

1
2
3
4
5
6
version: 0.2env:
git-credential-helper: yesphases:
build:
commands:
- scripts/codebuild-git-wrapper.sh <REPO_URL> <REPO_BRANCH>
- git log | head -100

Generate a VScode extension template

  1. Install your template generator

    1
    npm install -g yo generator-code
  2. Run it

    1
    yo code
  3. A folder called hellovscode will appear in your home directory.

  4. Test run: press F5 and another window will popup. Press Ctrl + Shift + P and find the Hello World command. Run it, and a popup should come out in the bottom right corner.

Coding your extension

Logic in extension.js file

  • Registering your commands: block.

    1
    vscode.commands.registerCommand('hellovscode.helloWorld')
  • Usage of the VS Code API.

    1
    vscode.window.showInformationMessage('Hello World from hellovscode!');
  • Link the commands you created from extension.js with the commands that you defined.
    1
    2
    3
    4
    {
    "command": "extension.sayHello",
    "title": "Hello World"
    }
  • This file will also enable the command to be put on the right click bar. It will also filter where the command should appear (file type).
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    {
    "name": "myFirstExtension",
    "description": "",
    "version": "0.0.1",
    "publisher": "",
    "engines": {
    "vscode": "^1.5.0"
    },
    "categories": [
    "Other"
    ],
    "activationEvents": [
    "onCommand:extension.sayHello"
    ],
    "main": "./out/src/extension",
    "contributes": {
    "commands": [{
    "command": "extension.sayHello",
    "title": "Hello World"
    }]
    },
    "scripts": {
    "vscode:prepublish": "tsc -p ./",
    "compile": "tsc -watch -p ./",
    "postinstall": "node ./node_modules/vscode/bin/install",
    "test": "node ./node_modules/vscode/bin/test"
    },
    "devDependencies": {
    "typescript": "^2.0.3",
    "vscode": "^1.5.0",
    "mocha": "^2.3.3",
    "@types/node": "^6.0.40",
    "@types/mocha": "^2.2.32"
    }
    }

Packaging your extension

You may create a .vsix file, for easier distribution and tests.

  1. Install the vsce tool.

    1
    npm install -g vsce
  2. Create the package file.

    1
    vsce package --out my-extension.vsix

Publishing in open-vsx

open-vsx.org is managed by the Eclipse Foundation.

  1. Create an Eclipse account

  2. Log in and authorize the application to access your eclipse account.

  3. Create an access token.

  4. Create the namespace.

    • The publisher field in your extension’s package.json file defines the namespace in which the extension will be made available.
    • You need to create the namespace in the registry before any extension can be published to it. This is done with the ovsx CLI tool.
      1
      2
      3
      # <name> -> your extension's publisher
      # <token> -> access token value
      npx ovsx create-namespace <name> -p <token>
    • Creating a namespace does not automatically assign you as verified owner. If you want the published extensions to be marked as verified, you can claim ownership of the namespace.
  5. Package and upload

  • If you have an already packaged .vsix file

    1
    2
    3
    # <file> -> path to your extension package
    # <token> -> access token value
    npx ovsx publish <file> -p <token>
  • Build an extension from source

    1
    npx ovsx publish -p <token>

    Notes:

    • The ovsx tool uses vsce internally to package extensions, which runs thevscode:prepublish script defined in the package.json as part of that process.
    • If the ovsx tool reported that publishing was successful, you should find your extension on open-vsx.org.

Steps

  1. Which packages am I using? And for each one…
    1.1. Am I still using this package?
    1.2. Are other developers using this package?
    1.3 Am I using the latest version of this package?
    1.4. When was this package last updated?
    1.5. How many maintainers do these packages have?
    1.6. Does this package have known security vulnerabilities?

Commands for those steps

Requirements

Process

  • Simplify by cleaning up duplications

    1
    npm dedupe
  • Which packages am I using?

    1
    npm ls
  • Am I still using this package?

    1
    2
    # npm install -g depcheck
    depcheck
  • Are other developers using this package? Stat

    1
    2
    # npm install -g stats
    stats --total
  • Am I using the latest version of this package?

    1
    npm outdated
  • When was this package last updated?

    1
    npm view PACKAGE_NAME time.modified
  • How many maintainers do these packages have?

    1
    npm view PACKAGE_NAME maintainers
  • Does this package have known security vulnerabilities?

    1
    npm audit

Daily how-to

Script for getting security report

  • Get the vulnerabilities
    1
    2
    3
    4
    5
    6
    7
    8
    npm install
    # reduce dependencies
    npm dedupe
    # fix the easy ones
    npm audit fix
    # get a list fo the hard ones
    npm audit --parseable
    # solve them manually

Solve manually

For each vulnerable dependency

  • Look for the currently installed version

    1
    npm ls PACKAGE_NAME
  • Review the vulnerability report: consider updating

    Note
    Remember you will need to maintain the “forced resolutions” on the package.json file.

    1
    2
    3
    "resolutions": {
    "underscore": "^1.12.1"
    }

0%