The tests should be simple to read and understand. The tests are already a layer on top the production code that is complex, so they should not add their own quirks or gotchas. In this blog post I will show how to make the tests express their meaning by adding custom Cypress commands, and how to make assertions really simple to understand by extending the default Chai assertions.
Note: these examples come from the repository bahmutov/todo-api-with-json-schema.
The example
In my example application, the production code and the tests are using json-schemas. These schemas are created and validated using @cypress-io/schema-tools library. See these resources to learn why we are using JSON schemas
- "JSON Schemas: State of Testing Update" presentation
- "JSON Schemas are your True Testing Friend" presentation and its companion blog post
I have created a schema for request and response to the API that validates Todo objects the web application is saving and loading from the server. Here the schemas/post-todo-request.ts file that describes what the server expects to receive.
1 | import { ObjectSchema, versionSchemas } from '@cypress/schema-tools' |
Given an object, we can assert that it follows the above schema in a Jest test like this one:
1 | import { assertSchema } from '@cypress/schema-tools' |
But any object NOT following the schema will raise an error with really good explanation message.
1 | it('TODO request object missing text', () => { |
The snapshot in this case is
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP |
Nice, the errors are readable, but what about our end-to-end tests?
End-to-end tests
When we have a web application making requests to the server API, our end-to-end tests should validate two main things:
- fixture files Cypress can use to mock complex network calls. We really want the fixture files to be valid with respect to the schemas used.
- network requests and responses should confirm to the schema we expect
Validating fixtures
We can validate a fixture file using the methods provided by the @cypress-io/schema-tools function. For example in cypress/integration/fixture-spec.js we can import the api
object created by the schema utilities and use api.assertSchema
to validate a loaded object.
1 | import { api } from '../../dist/schemas' |
This is ok, but it is less than readable. Luckily we can make it better by writing a Cypress custom command. I will write a custom command in cypress/support/commands.js:
1 | /// <reference types="cypress" /> |
and will include this commands.js
file from the cypress/support/index.js file
1 | import './commands' |
Super, our fixture validation becomes readable:
1 | it('loads and asserts todo schema', () => { |
The combined command cy.fixtureSchema
lessens the overhead for anyone reading the spec code, leaving more mental capacity to actually think about the application and its logic.
If an object loaded from a fixture file does not match the schema, a good error is displayed in the Cypress GUI.
Great.
Custom assertions
In addition to the custom commands, we can also extend the Chai assertions with our own predicates. In our repository I will add file cypress/support/assertions.js
and will import this file from the cypress/support/index.js
file.
1 | import './commands' |
Let's add a custom Chai assertion that validates an object against a schema.
1 | /// <reference types="cypress" /> |
Notice there are 2 assertions in the isFollowingSchema
callback: the "real" one and an always-passing one this.assert(true, <message>)
. This is a little trick I use to have a detailed error message if an assertion fails, and a short success message when the object conforms to the schema. Here is an example test validating the response body.
1 | it('returns new item matching schema', () => { |
When the test passes, the Command Log is showing the summary thanks to that "dummy" this.assert(true, ...)
assertion trick.
Adding IntelliSense
Cypress commands and assertions come with full JSDoc comments, which allows a modern text editor like VSCode to show help during test writing. For example when hovering over cy.should
this tooltip pops up:
Note that the help box shows help for cy.should
WITH have.property
assertion! Thus we should be able to specify JSDoc for both our custom commands and for custom assertions. To do this, create a new file cypress/support/index.d.ts
. It is a TypeScript file that will describe additional cy
commands and assertions. The new commands are added to the Chainable<Subject>
interface, and the new assertions are added to the Chainer<Subject>
interface. For each command and assertion I will write a detailed JSDoc command with examples.
1 | /// <reference types="cypress" /> |
Special note: my blog's syntax highlighting does not parse the original JSDoc for the above assertion correctly. Here is a screenshot of how it SHOULD be to avoid VSCode parsing to break on @networkCallAlias
string and to preserve indentation:
I write my code examples using triple back ticks like that to preserve indentation and avoid problems caused by @
character in the network alias.
Once we have this support index.d.ts
file in place, we can include it from our JavaScript spec files (and it can replace the regular triple-slash special command importing cypress
types).
1 | // in a JavaScript spec file, instead of this: |
Hover over cy.fixtureSchema
and see documentation from the .d.ts
file
Typing a custom assertion should('followSchema', ...)
brings the following help popup
In all cases, good documentation is essential.
Conclusions
Making test code as readable as possible is a worthy goal. Each test command and assertion can express its intent, and custom commands and assertions help achieve this.
I would suggest adding custom commands and assertions to their own files in cypress/support
folder and including them from cypress/support/index.js
file
1 | import './commands' |
I would also write a TypeScript definition file like cypress/support/index.d.ts
that describes the new commands and assertions and is loaded by IntelliSense to provide pop-up help during editing.