DEV Community

Cover image for Cypress — Easy Multi-Environment Configuration
David Ingraham
David Ingraham

Posted on

Cypress — Easy Multi-Environment Configuration

Whether running a subset of tests against a local environment, a regression suite against a hosted, UAT environment, or anything in between, testing against multiple environments is an important and common practice across the development life cycle.

In this article I’ll demonstrate an easy, clean and scaleable pattern for running against any environment with Cypress.

Before diving into the implementation, let’s explore some alternative solutions, break down why they are limited, and build upon their benefits until we define the most optimal solution.

Alternative Solutions:

Manually Update cypress.config.js.

The least optimal approach would be to manually update the cypress.config.js each time a different environment is required.

Looking at a simple config file below, for a local environment, the user would need to change the baseUrl and both env-specific variables only to have to change them back if they wish to run locally again. As this file grows and more settings and variables differ across environments, manually updating this file each time is not only taxing but impractical.

const { defineConfig } = require('cypress')
require('dotenv').config({ path: '../.env' })

module.exports = defineConfig({
    e2e: {
        setupNodeEvents(on, config) {
            return require('./cypress/plugins/index')(on, config)
        },
        baseUrl: 'http://127.0.0.1:8009/',
    },
    env: {
        MY_USER: process.env.MY_LOCAL_USER,
        MY_PASSWORD: process.env.MY_LOCAL_PASSWORD,
    },
})
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Quick for a one-time solution

Cons:

  • Merging changes impacts everyone
  • Repetitive and time consuming
  • Limits CI/CD execution

Command Line Options.

Another approach would be to take advantage of the CLI by overriding any individual default configurations or variables with parameters. This solution allows users to define multiple run commands, each one with specific overrides for that environment.

In the example below, two script commands are defined in the package.json file. Since the cypress.config.js file is configured for the local environment, the cy:run command doesn’t need to modify anything. However, the cy:run:dev command overrides both the baseUrl and the environmental variables using the --config and --env options.

"cy:run": "cypress run",
"cy:run:dev": "cypress run --config baseUrl=https://myDevURL, --env MY_USER=some-dev-user,MY_PASSWORD=some-dev-password"
Enter fullscreen mode Exit fullscreen mode

This allows users to simply change which environment they are running against by switching up the command they run. This is not only faster than the previous approach but less manual. Doing this is optimal when there are small differences between environments so the run commands stay small. For environments with larger amounts of differences, passing in multiple parameters can be tedious and hard to maintain as the deviations grow.

Additionally, it’s not recommend to define secrets in a package.json so this approach limits what environmental variables can be overridden.

Pros:

  • Useful for small environmental differences
  • Support fast environment switching
  • Less manual

Cons:

  • Doesn’t scale well as environmental differences grow
  • Forced to override secrets in a public file

Test Conditionals

While both solutions above work for small environmental differences, what about environments that have tons of variations between them?

One way to handle this would be to remove all specific overriding from the package.json commands and instead pass in a single environment, defining variable such as ENVIRONMENT

"cy:run": "cypress run",
"cy:run:dev": "cypress run --env ENVIRONMENT=development",
"cy:run:qa": "cypress run --env ENVIRONMENT=qa",
"cy:run:uat": "cypress run --env ENVIRONMENT=uat",
Enter fullscreen mode Exit fullscreen mode

Now conditional logic can be implemented in the test suite by performing Cypress.env('ENVIRONMENT') to access the variable. Let’s take a look at a basic test attempting to login and break it down.

const getUrl = () => {
    switch (Cypress.env('ENVIRONMENT')) {
        case 'local':
            return 'http://127.0.0.1:8009/'
        case 'development':
            return 'my-dev-url/'
        case 'qa':
            return 'my-qa-url/'
        case 'uat':
            return 'my-uat-url/'
        default:
            break
    }
}
const setEnvVariables = () => {
    switch (Cypress.env.ENVIRONMENT) {
        case 'local':
            Cypress.env('MY_USER', 'some user')
            Cypress.env('MY_PASSWORD', 'some password')
        case 'development':
            Cypress.env('MY_USER', 'some dev user')
            Cypress.env('MY_PASSWORD', 'some dev password')
        case 'qa':
            Cypress.env('MY_USER', 'some qa user')
            Cypress.env('MY_PASSWORD', 'some qa password')
        case 'uat':
            Cypress.env('MY_USER', 'some uat user')
            Cypress.env('MY_PASSWORD', 'some uat password')
        default:
            break
    }
}

describe('My Test Suite', () => {
    beforeEach(() => {
        setEnvVariables()
    })

    it('Basic Test', () => {
        // Visit
        cy.visit(getUrl())

        // Login
        cy.get('login-user-input').type(Cypress.env('MY_USER'))
        cy.get('login-password-input').type(Cypress.env('MY_PASSWORD'))
    })
})
Enter fullscreen mode Exit fullscreen mode

Using switch/case conditional logic we are able to check the passed-in ENVIRONMENT option and set the correct url and authentication data within the test itself.

While this approach cleans up the CLI and moves all the logic adjacent to the rest of the test code, there’s a ton of repetitious logic needed to accomplish this, and as more env-specific values grow, the repetition will only worsen. Even if these helpers were abstracted, each test would need to trigger this logic resulting in much more code quantity than the other alternatives. Finally, this faces the same issue as before in which secrets are publicly defined.

Pros:

  • Simplifies the CLI
  • Moves environment specific info close to the test code

Cons:

  • Duplicated conditional logic
  • Greater quantity of code needed to accomplish the same solution
  • Potential confusion having config data in both a config file and within the test suite.
  • Forced to define secrets in a public file

Optimal Solution

Now that several alternative solutions have been explored, let’s look at a solution that combines all of the pros from the alternative solutions in a way that supports adding a new environment incredibly easy.

This is how it works

  • The cypress.config.js file is defined for both global settings across all environments as well individual settings for the most frequently used environment (such as local).
  • The dotenv library is used to import secrets from a local .env file. These secrets are fetched using the process.env.X syntax.
const { defineConfig } = require('cypress')
require('dotenv').config({ path: '../.env' })

module.exports = defineConfig({
    e2e: {
        setupNodeEvents(on, config) {
            return require('./cypress/plugins/index')(on, config)
        },
        baseUrl: 'http://127.0.0.1:8009/',
    },
    env: {
        MY_USER: process.env.MY_LOCAL_USERNAME,
        MY_PASSWORD: process.env.MY_LOCAL_PASSWORD,
    },
})
Enter fullscreen mode Exit fullscreen mode
  • Next, additional cypress.config.X.js are created, one for each environment. These configuration files define only what is different for that environment, while the remaining settings are inherited by spreading from the base cypress.config.js file.

  • So in the example below, the base cypress.config.js file is imported, the QA baseUrl and login variables are overridden and the overall configuration is redefined by spreading everything back together.

const { defineConfig } = require('cypress')
const baseConfig = require('./cypress.config')

const e2eOverride = {
    baseUrl: 'https://my-qa-url.com/',
}
const envOverride = {
    MY_USER: process.env.MY_QA_USERNAME,
    MY_PASSWORD: process.env.MY_QA_PASSWORD,
}

module.exports = defineConfig({
    ...baseConfig,
    e2e: {
        ...baseConfig.e2e,
        ...e2eOverride,
    },
    env: {
        ...baseConfig.env,
        ...envOverride,
    },
})
Enter fullscreen mode Exit fullscreen mode
  • Finally, each environment has it’s own run command that passes in a single CLI option config-file
  • Notice that the cy:run command doesn’t pass in a configuration file, meaning the default cypress.config.js will be used.
"cy:run": "cypress run",
"cy:run:dev": "cypress run --config-file cypress.config.dev.js",
"cy:run:qa": "cypress run --config-file cypress.config.qa.js",
"cy:run:uat": "cypress run --config-file cypress.config.uat.js",
Enter fullscreen mode Exit fullscreen mode

And that’s it.

Running against QA is as simple as executing cy:run:qa or cy:run:uat for a UAT env while the base cy:run command supports local testing. I recommend also creating env-specific cy:open commands for utilizing the test runner alongside the run commands for headless, CI/CD usage.

This pattern is not only clean but adding a new environment is as easy as creating a new command and env-specific config file. Additionally, debugging or modifying any settings is simple, as global-level changes are conveniently tweaked in the base config file while all other env-specific changes occur separately. Importing important secrets from .env files is also possible.

Image description

Hopefully, this helps and simplifies your environmental configuration moving forward. Thank you for reading.

Happy Testing!

Top comments (0)