Convert Cypress Specs from JavaScript to TypeScript

While Cypress test is running you cannot insert or add new test commands from "outside"

Let's say you have a project with Cypress end-to-end tests. You might be thinking of converting the specs from JavaScript to TypeScript language. This blog post describes how I have converted one such project in my repo bahmutov/test-todomvc-using-app-actions.

Step 1: Decide why you want to convert

The most important step is to decide what benefits you are seeking from the conversion. Does the team use TS to code and is used to its static types? Do you want to see the intelligent code completion when coding Cypress tests? Do you want to share code and types between the application and the tests? Do you plan to check the static types in the spec files on CI? During pre-commit hook?

Conversion might take some time and effort, so it is better be worth it. I mostly use just JavaScript Cypress specs together with JSDoc types for simplicity. But I definitely see why someone might want to use TS to code the E2E tests. Luckily, it is not "either / or" proposition. You can bring the static types into your Cypress project gradually and immediately enjoy some of the static typing benefits. Then you can progress through the rest of the code at your own pace.

Step 2: Configure the intelligent code completion

The first benefit of static types in Cypress specs is the intelligent code completion (IntelliSense) that pops up when you type Cypress cy.* commands, like cy.visit, etc. Without IntelliSense, when you hover over the cy.visit command, all you see is "any". Your code editor cannot help you write this or any other Cypress command (pun intended)

No IntelliSense for you, Cypress globals

You can read the Cypress IntelliSense guide on how to set it up. In most modern code editors, I recommend starting with a special comment that tells the code editor to load TypeScript definitions for the global objects cy and Cypress.

cypress/e2e/adding-spec.js
1
/// <reference types="cypress" />

Voila - the code editor goes to node_modules/cypress/package.json file, finds the "types": "types" property, and loads the TypeScript file node_modules/cypress/types/index.d.ts file that describes what Cypress and `cy are. Boom, your editor is helping you:

My VSCode gives me help every time I type a Cypress command or assertion

Step 3: use jsconfig file

Instead of adding the reference types comment to each JavaScript spec, we could use a jsconfig.json file at the root of our project. At least in VSCode editor this file can tell which types to load for all files.

jsconfig.json
1
2
3
4
5
6
{
"compilerOptions": {
"types": ["cypress"]
},
"include": ["cypress/**/*.js"]
}

Each Cypress JS spec file now automatically knows the cy, Cypress, etc.

You can watch me explaining the jsconfig.json file in the video Load Global Cypress Types In VSCode Using jsconfig.json File below:

I also explain using jsconfig.json file to load Cypress and 3rd party plugin types in my course Cypress Plugins.

Step 4: use JSDoc comments to give types

While coding our specs in JavaScript we use local variables, Cypress commands, etc. The code editor does not know the types of the variables we use. For example, the title variable in the below spec shows up as any

1
2
3
4
5
let title
cy.visit('/')
cy.title().then((t) => {
title = t
})

VSCode shows the title variable having type any

We can keep the specs in JavaScript and add a JSDoc type comment to tell our code editor what we intend for it to be.

1
2
3
4
5
6
/** @type string */
let title
cy.visit('/')
cy.title().then((t) => {
title = t
})

VSCode shows the title variable having type any

Similarly you can use a variable to get the aliased value.

1
2
3
4
5
6
cy.get('@itemName').then((s) => {
// TypeScript thinks that s is HTMLElement
// so we declare another variable with a type annotation
/** @type {string} */
const itemName = s
})

Ok, looks good. I use JSDoc types a lot, and I must admit they become cumbersome at some point. Even forcing a variable to be of a certain type is non-trivial and looks plain ugly. For example, to tell the code editor that something is really a string we need to cast it through an unknown:

1
2
3
4
/** @type {any} */
let something;
// success is a string
const success = /** @type {string} */ (/** @type {unknown} */ (something))

Ughh.

Step 5: start checking types

Once your code editor "knows" the Cypress types, you can start checking them as you edit the files by adding // @ts-check directive. Let's say we pretend the title variable is a number, while the cy.title command yields a string.

1
2
3
4
5
6
/** @type number */
let title
cy.visit('/')
cy.title().then((t) => {
title = t
})

VSCode by default does NOT warn us about the type mismatch.

Even with types, the code editor does not warn us about string to number assignment

To tell the code editor to warn us on type mismatch, we can add a special comment // @ts-check to our JavaScript files. The comment must come before any regular code.

1
2
3
4
5
6
7
8
// @ts-check
// any imports
/** @type number */
let title
cy.visit('/')
cy.title().then((t) => {
title = t <=== a type error
})

VSCode shows that we have a type error

Step 6: Check types using TypeScript

If we are checking the types while the code editor is running, let's check it from the command line and from the CI. Let's install TypeScript compiler

1
2
$ npm i -D typescript
+ [email protected]

Add the lint command to the package.json file

package.json
1
2
3
4
5
{
"scripts": {
"lint": "tsc --noEmit --pretty --allowJs cypress/**/*.js"
}
}

Let's run the lint step from the command line to find any mistakes with npm run lint

Linting JavaScript specs from the command line using TypeScript compiler

Tip: only the JS files with // @ts-check comment are checked, thus you can introduce type checking gradually into your project.

Step 7: Check types on CI

Let's run the types lint step and the sanity tests on CI using Cypress GitHub Action

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
name: ci
on: [push]
jobs:
tests:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
# https://github.com/cypress-io/github-action
- name: Cypress run
uses: cypress-io/github-action@v4
with:
# check the spec types
build: npm run lint
# start the application before running Cypress
start: npm start
# pass the grep tag to run only some tests
# https://github.com/cypress-io/cypress-grep
env: grepTags=@sanity

The CI service catches types mismatch in our specs

Checking types before running the tests on GitHub Actions

Step 8: Extend the globals types

If your Cypress project is using any custom commands, like cy.addTodo or extends the window object by storing and passing custom properties, you might need to extend the global types to pass the types checks. For adding types for custom commands, see my blog post Writing a Custom Cypress Command. In our project, the application sets the window.model property when running inside a Cypress test.

src/app.jsx
1
2
3
4
5
var model = new app.TodoModel('react-todos')

if (window.Cypress) {
window.model = model
}

This allows the test to grab the window.model and use the application's code to quickly execute application actions, giving it superpowers.

1
2
3
4
5
6
7
8
9
10
11
12
// spy on model.inform method called by the app
// when adding todos
it('calls inform', () => {
cy.window()
.its('model')
.should('be.an', 'object')
.then((model) => {
cy.spy(model, 'inform').as('inform')
})
addDefaultTodos()
cy.get('@inform').should('have.been.calledOnce')
})

To make sure our types "know" what the cy.window().its('model') yields, we need to extends the window type definition. We can create a file cypress/e2e/model.d.ts that just describes the new types

cypress/e2e/model.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Describes the TodoMVC model instance.
// Ideally it would come from the application,
// but in our example app does not have types,
// so we write method signatures ourselves.
// From out app actions we only use a couple of methods.
interface TodoModel {
todos: unknown[]
addTodo(...todos: string[])
toggle(item: unknown)
inform()
}
// During tests there we set "window.model" property
// now cy.window() returns Window instance with
// the "model" property that has TodoModel interface
interface Window {
model: TodoModel
}

To use this file during code editor's type checks include it in the list of files in the jsconfig.json file

jsconfig.json
1
2
3
4
5
6
{
"compilerOptions": {
"types": ["cypress"]
},
"include": ["cypress/**/*.js", "cypress/e2e/model.d.ts"]
}

window.model now has the correct type

Step 9: Turn the screws

Now that we have some initial types and are linting them, let's make the types stricter. For the code editor, you can turn the strict type checks using the jsconfig.json file

jsconfig.json
1
2
3
4
5
6
7
{
"compilerOptions": {
"types": ["cypress"],
"strict": true
},
"include": ["cypress/**/*.js", "cypress/e2e/model.d.ts"]
}

Tip: if you see too many errors, turn the strict option off and instead turn the checks like noImplicitAny, etc one by one.

For linting types from the command line, add the option to the NPM script command

package.json
1
2
3
4
5
{
"scripts": {
"lint": "tsc --noEmit --pretty --allowJs --strict cypress/**/*.js"
}
}

Linting the types using strict setting

Some of the errors are easy to fix. For example, the clickFilter function just needs the @param type in its existing JSDoc comment. If we add @param {string} name the TS error goes away.

1
2
3
4
5
6
/**
* Little utility function to click on a given filter on the page.
* We are testing routing links, so these tests go through the DOM.
* @param {string} name
*/
const clickFilter = (name) => cy.get('.filters').contains(name).click()

Similarly, we can add parameter types to the page object methods

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
/**
* @param {string} todo
*/
createTodo(todo) {
cy.get('.new-todo', { log: false }).type(`${todo}{enter}`, { log: false })
...
},

/**
* @param {number} k Index of the todo to toggle
*/
toggle(k) {
cy.get('.todo-list li', { log: false }).eq(k).find('.toggle').check()
}
}

TypeScript compiler is even smart enough to figure out the runtime type checks. For example, for optional k parameter, the if branch cannot have undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Returns either all todo items on the page,
* or just a given one (zero index)
* @param {number|undefined} k
*/
todos(k) {
if (k !== undefined) {
// k can only be a number here
return cy.get('.todo-list li').eq(k)
}

return cy.get('.todo-list li')
}

Finally, for anything complicated, but working in reality, I just ignore the error using the @ts-ignore directive.

1
2
// @ts-ignore
export const addTodos = (...todos) => { ... }

You can ignore specific errors instead of ignoring all possible TS errors in the next line using TS error codes like this:

1
2
// @ts-ignore TS6133
...

If the spec file has too many TS errors to be fixed right away, you can tell the TS compiler to ignore it completely using the // @ts-nocheck comment at the top:

1
2
// TODO fix the types later
// @ts-nocheck

Step 10: Move specs to TypeScript

  1. Add the .ts files to the E2E spec pattern in the cypress.config.js file
cypress.config.js
1
2
3
4
5
6
7
8
9
const { defineConfig } = require('cypress')

module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:8888',
excludeSpecPattern: ['*.page.js', 'utils.js', '*.d.ts'],
specPattern: 'cypress/e2e/**/*spec.{js,ts}',
},
})
  1. Take a spec and change its file extension to .ts. For example, I have renamed adding-spec.js to adding-spec.ts

TypeScript spec file

  1. Add the TS files to the list of included files in jsconfig.json
jsconfig.json
1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"types": ["cypress"],
"strict": true
},
"include": [
"cypress/**/*.js",
"cypress/e2e/model.d.ts",
"cypress/e2e/*spec.ts"
]
}
  1. Click on the TS spec file. You should see an error message 🤯

Trying to run the renamed TS spec leads to an error

  1. Rename the file jsconfig.json to tsconfig.json and add the options to allow JavaScript and do not emit JS
tsconfig.json
1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"types": ["cypress", "cypress-grep"],
"strict": true,
"allowJs": true,
"noEmit": true
},
"include": ["cypress/e2e/model.d.ts", "cypress/e2e/*spec.ts"],
"files": ["cypress/e2e/adding-spec.ts"]
}

Note: I could not make the tsconfig.json work without listing at least one spec in its files list. Weird.

Now we can type anything in our spec files using "normal" TypScript, which is very convenient

1
2
3
4
5
let title: string
cy.visit('/')
cy.title().then((t) => {
title = t
})

You can remove some of the JSDoc typings and use "normal" argument variable declarations

1
2
3
4
5
6
/**
* Little utility function to click on a given filter on the page.
* We are testing routing links, so these tests go through the DOM.
*/
const clickFilter = (name: string) =>
cy.get('.filters').contains(name).click()

You can now move more and more spec files to TypeScript and ensure they all have sound types.

Step 11: Fix the TS lint errors

Once the specs move to TypeScript, you can adjust the lint command in the package.json file

package.json
1
2
3
4
5
{
"scripts": {
"lint": "tsc --noEmit --pretty"
}
}

The command becomes stricter, as TypeScript now validates using only the settings specified in the tsconfig.json which seems to be stricter than using the jsconfig.json file.

More types errors discovered

We can fix the top three errors by declaring the method return types in the model.d.ts file

model.d.ts
1
2
3
4
5
6
interface TodoModel {
todos: unknown[]
addTodo(...todos: string[]): void
toggle(item: unknown): void
inform(): void
}

Let's fix the 3rd party cryptic errors like these ones

1
2
3
4
5
6
7
8
node_modules/cypress/types/bluebird/index.d.ts:795:32 - error TS2304: Cannot find name 'IterableIterator'.

795 generatorFunction: () => IterableIterator<any>,
~~~~~~~~~~~~~~~~

node_modules/cypress/types/chai/index.d.ts:850:49 - error TS2304: Cannot find name 'ReadonlySet'.

850 include<T>(haystack: ReadonlyArray<T> | ReadonlySet<T> | ReadonlyMap<any, T>, needle: T, message?: string): void;

Let's tell our TS compiler that the spec is meant to run in the browser that supports modern JavaScript and has DOM APIs. We add the lib list to the compilerOptions object:

tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"types": ["cypress", "cypress-grep"],
"lib": ["DOM", "ES2015"],
"strict": true,
"allowJs": true,
"noEmit": true
},
"include": ["cypress/e2e/*.ts"],
"files": ["cypress/e2e/adding-spec.ts"]
}
1
2
3
4
$ npm run lint

> [email protected] lint
> tsc --noEmit --pretty

No more errors

Step 12: Use JSON fixtures

📺 I have recorded a short video showing how to cast the cy.fixture JSON value, watch it at Work With Cypress JSON Fixtures Using TypeScript.

Cast data after loading using cy.fixture command

Let's say we are using the JSON fixtures to put into the application. Our JSON file has an object with the list of todos.

cypress/fixtures/todos.json
1
2
3
4
5
6
7
8
9
10
11
12
{
"todos": [
{
"title": "Write code",
"completed": true
},
{
"title": "Pass the tests",
"completed": false
}
]
}

We can import the fixture file and grab its todos property.

cypress/e2e/using-fixture-spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
describe('Use JSON fixture', () => {
it('adds todos following the fixture', () => {
cy.visit('/')
cy.fixture('todos.json')
.its('todos')
.should('be.an', 'array')
.then((todos) => {
todos.forEach((todo, k) => {
cy.get('input.new-todo').type(todo.title + '{enter}')
cy.get('.todo-list li').should('have.length', k + 1)
if (todo.completed) {
cy.get('.todo-list li').last().find('.toggle').click()
cy.get('.todo-list li').last().should('have.class', 'completed')
}
})
})
})
})

Unfortunately, cy.fixture yields Cypress.Chainable<any>, which means the todos argument has any type.

Data loaded using cy.fixture has type any

We can fix the callback function by adding an explicit type to the argument. I will add an interface Todo

1
2
3
4
5
6
7
8
9
10
11
interface Todo {
title: string
completed: boolean
}

cy.fixture('todos.json')
.its('todos')
.should('be.an', 'array')
.then((todos: Todo[]) => {
...
})

Adding type to the data loaded by the cy.fixture command

We can move such common types to the model.d.ts and export what is necessary:

cypress/e2e/model.d.ts
1
2
3
4
export interface Todo {
title: string
completed: boolean
}
cypress/e2e/using-fixture-spec.ts
1
import type { Todo } from './model'

Import JSON fixtures and cast the type

If our fixture data is static JSON, we could simply import the data in our specs. We need to allow TypeScript to resolve JSON files

tsconfig.json
1
2
3
4
5
{
"compilerOptions": {
"resolveJsonModule": true
}
}

If we import the data into the TS spec, it gets whatever the type the compiler can infer. Thus I like creating another variable to cast the imported object.

cypress/e2e/import-fixture-spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import type { Todo } from './model'
import { todos } from '../fixtures/todos.json'

const initialTodos: Todo[] = todos

describe('Import JSON fixture', () => {
it('adds todos following the fixture', () => {
cy.visit('/')
initialTodos.forEach((todo, k) => {
cy.get('input.new-todo').type(todo.title + '{enter}')
cy.get('.todo-list li').should('have.length', k + 1)
if (todo.completed) {
cy.get('.todo-list li').last().find('.toggle').click()
cy.get('.todo-list li').last().should('have.class', 'completed')
}
})
})
})

Cast yielded value from cy.fixture

In the code fragment below, we yield any from the cy.fixture command to the cy.its command, which yields any to the .then() callback. We know what cy.fixture loads, let's tell the compiler that. We know the JSON file has an object with "todos" property, and its value is a list of Todos. Let's tell the compiler that using the expression cy.fixture<{ todos: Todo[] }>:

cypress/e2e/cast-fixture-spec.ts
1
2
3
4
5
6
7
import type { Todo } from './model'
cy.fixture<{ todos: Todo[] }>('todos.json')
.its('todos')
.should('be.an', 'array')
.then((todos) => {
...
})

If you inspect the commands after cy.fixture, you can see that cy.its for example yields the list of Todo objects, since it already "knows" the correct type of its subject. Nice.

Adding type to the value yielded by the cy.fixture command makes the entire command chain correct

Cast cy.task value

1
2
3
4
// this particular cy.task yields a number
cy.task<number>('getNumber').then((n) => {
expect(n).to.be.a('number')
})

Cast aliased value

1
2
3
4
5
// this particular alias keeps a number
cy.wrap(42).as('magic')
cy.get<number>('@magic').then((n) => {
expect(n).to.be.a('number').and.to.equal(42)
})

Step 13: Move to cypress.config.ts file

By default, I use cypress.config.js to configure the Cypress project. Let's move this file to TypeScript. We can now use import and export keywords, but TS complains about unknown top-level properties.

TypeScript catches unknown config properties

We should move those properties (used by the plugin cypress-watch-and-reload) to e2e.env object

1
2
3
4
5
6
7
8
9
10
11
12
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
env: {
'cypress-watch-and-reload': {
watch: 'js/*',
},
},
...
}
})

You might get an error "[ERR_UNKNOWN_FILE_EXTENSION] Unknown file extension .ts" when you open Cypress for the first time.

Cypress fails to load the cypress.config.ts file

I found the simplest solution is to add type: module to your package.json file.

package.json
1
2
3
{
"type": "module"
}

Step 14: Define custom commands

Let's say in our specs we use custom Cypress commands like cy.addTodo defined in the Cypress E2E support file

cypress/support/e2e.ts
1
2
3
4
5
Cypress.Commands.add('addTodo', (text: string) => {
cy.get('.new-todo').type(text + '{enter}')
// check when the new todo appears in the list
cy.contains('.todo-list li', text)
})

We can declare the type for the custom command in the index.d.ts file

cypress/e2e/index.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
// extend Cypress "cy" global object interface with
// our custom commands defined in E2E support file
declare namespace Cypress {
interface Chainable {
/**
* Enters a new todo
* @param text string
* @example
* cy.addTodo('Write more tests')
*/
addTodo(text: string): void
}
}

Now we can use custom commands in our specs without a problem

cypress/e2e/adding-spec.ts
1
2
3
4
it('adds new items using a custom command', () => {
cy.addTodo(TODO_ITEM_ONE)
allItems().eq(0).find('label').should('contain', TODO_ITEM_ONE)
})

Step 15: use shared TS code via path aliases

As I covered in Using TypeScript aliases in Cypress tests, we can conveniently import source code from our application into our TS specs using path aliases.

tsconfig.json
1
2
3
4
5
6
7
{
"compilerOptions": {
"paths": {
"@src/*": ["./js/*"]
}
}
}

So we are pointing @src/... prefix at the js folder. Now let's import a type from our application's source code and use it in our spec file

cypress/e2e/path-alias-spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import type { Todo } from '@src/Todo'

describe('Source path alias', () => {
it('checks the application todos', () => {
cy.visit('/')
cy.get('.new-todo')
.type('one{enter}')
.type('two{enter}')
.type('three{enter}')
cy.window()
.its('model.todos')
.should('have.length', 3)
.then((todos: Todo[]) => {
todos.forEach((todo) => {
// each todo has id which is an uuid
expect(todo).to.have.property('id').and.to.be.a('string')
})
})
})
})

Using Todo type from application source code in our specs

Tip from Murat Ozcan - use path aliases to quickly import Cypress JSON fixtures

tsconfig.json
1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"paths": {
"@src/*": ["./js/*"],
"@support/*": ["support/*"],
"@fixtures/*": ["fixtures/*"]
}
}
}

My thoughts

At my Mercari US we are currently at Step 8. We use JavaScript + a few TypeScript definition files for our custom commands. We lint all spec files on CI and keep the lint step green. We probably should move to full TypeScript, as our fixtures and mock object become hard to type using JSDoc.

See also