Extending the Cypress Config File

June 18, 2020

By Gleb Bahmutov

Many tools allow one config file to extend another one. For example, ESLint allows one to apply recommended settings and then add several more rules:

{
    "plugins": [
        "react"
    ],
    "extends": [
        "eslint:recommended",
        "plugin:react/recommended"
    ],
    "rules": {
       "react/no-set-state": "off"
    }
}

Similarly, the TypeScript compiler allows you to extend the configuration from the base file using the extends keyword. If we place most of the settings in configs/base.json

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}

Then we can write tsconfig.json extending the base config:

{
  "extends": "./configs/base",
  "files": ["main.ts", "supplemental.ts"]
}

Cypress does not support extends syntax in its configuration file. If you want to apply different settings, you need to write a complete second configuration file and use it via the --config-file <filename> command line argument.

For example, when working with tests locally, we might use the default cypress.json file:

{
  "baseUrl": "http://localhost:8080",
  "defaultCommandTimeout": 2000,
  "video": true
}

When running tests against staging server, we might use a few different settings placed in staging.json

{
  "baseUrl": "http://localhost:8080",
  "defaultCommandTimeout": 5000,
  "video": false,
  "env": {
    "staging": true
  }
}

Then the continuous integration server would use the command npx cypress run --config-file staging.json to use the later configuration file.

Note: make sure the top-level configuration values like baseUrl, video, and others are placed at the top-level of the configuration files and do not accidentally end up in the env object. They won't work correctly inside the env object!

Hmm, seems like most settings would be duplicated. We could still use the cypress.json file and override the config values and the environment variables via command line arguments:

npx cypress run --config defaultCommandTimeout=5000 --env staging=true

I do not like the above approach, because it hides the intent and spreads the related settings across multiple files. In this blog post I will show how to implement extends syntax in the Cypress JSON configuration file without waiting for the Cypress team to add support.

Plugins file

When Cypress loads your project, you can change any configuration or environment value programmatically from the plugins file. For example, in the bahmutov/config-extends-example repository we first print the current configuration from cypress/plugins/index.js

/**
 * @type {Cypress.PluginConfig}
 */
module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  console.log(config)
  return config
}

The list of settings is long ...

{
  animationDistanceThreshold: 5,
  fileServerFolder: '/Users/gleb/git/config-extends-example',
  baseUrl: null,
  fixturesFolder: '/Users/gleb/git/config-extends-example/cypress/fixtures',
  blacklistHosts: null,
  chromeWebSecurity: true,
  modifyObstructiveCode: true,
  ...
  experimentalGetCookiesSameSite: false,
  experimentalSourceRewriting: false,
  experimentalComponentTesting: false,
  projectRoot: '/Users/gleb/git/config-extends-example',
  configFile: '/Users/gleb/git/config-extends-example/cypress.json'
}

The very last setting configFile is what we are after. It was added in Cypress v4.1.0 to allow the plugins file to "know" what configuration file has been loaded. Let's add a second configuration file extending the cypress.json.

// cypress.json
{
  "defaultCommandTimeout": 2000,
  "video": true
}
// staging.json
{
  "extends": "./cypress.json",
  "video": false
}

Let's run Cypress using the staging.json configuration file.

npx cypress run --config-file staging.json
...
long list of resolved config values

Notice the list of configuration values does NOT include the extends property, because Cypress whitelists config keys. Here is where the plugins code can help. Because it "knows" which configuration file has been loaded, it can load it again as JSON, find the extends property and load the base config file, then merge the two configs:

const deepmerge = require('deepmerge')
const path = require('path')
module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  console.log(config)

  const configJson = require(config.configFile)
  if (configJson.extends) {
    const baseConfigFilename = path.join(config.projectRoot, configJson.extends)
    const baseConfig = require(baseConfigFilename)
    console.log('merging %s with %s', baseConfigFilename, config.configFile)
    return deepmerge(baseConfig, configJson)
  }

  return config
}

Notice that we return merged base config plus config JSON (not the config argument passed into the plugins file). Cypress will automatically re-merge the resolved config with the returned result. Thus we only need to "worry" about our settings and not the full config.

We can see the final configuration values by running Cypress in the interactive mode

npx cypress open --config-file staging.json

Open the Settings / Configuration tab and notice that the defaultCommandTimeout: 2000 comes from plugins - this is the result of the merge returned from the cypress.json base file. The value video: false came from the config which in our case was staging.json configuration file.

We can load and merge configs recursively to allow severals extends levels.

const deepmerge = require('deepmerge')
const path = require('path')

function loadConfig(filename) {
  const configJson = require(filename)
  if (configJson.extends) {
    const baseConfigFilename = path.join(
      path.dirname(filename), configJson.extends)
    const baseConfig = loadConfig(baseConfigFilename)
    console.log('merging %s with %s', baseConfigFilename, filename)
    return deepmerge(baseConfig, configJson)
  } else {
    return configJson
  }
}

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  return loadConfig(config.configFile)
}

I have placed the recursive config load into bahmutov/cypress-extends and published it as the @bahmutov/cypress-extends NPM package. Give it a try:

npm i -D @bahmutov/cypress-extends
# or
yarn add -D @bahmutov/cypress-extends

Then use it from the plugins file

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config
  return require('@bahmutov/cypress-extends')(config.configFile)
}

Tip: add a schema URL to your Cypress configuration file to get code completion for config settings

{
  "$schema": "https://on.cypress.io/cypress.schema.json"
}