Probably one of the best use cases for TypeScript in Cypress is custom commands.
I'm going to create a simple test where I would like to "create a new board".
Now, usually this test will start with a cy.visit
to visit our page, and then I'm going to select an element, click on it, and then select an element again and type in the name of the board.
Let's actually open the application and test and get our selectors.
I'll use npx cypress open
in the Terminal, open my browser, arrange my windows a little bit and run my spec.
In here, I want to type in my board, which means I made a little mistake.
I'm not going to need these two - get
and click
.
I'm just going to get the element and type in the name of the board and hit the "Enter" key.
What this means is that I need to target this element - and I have a data-cy
attribute with the value of "first-board", which is good and it's usually a preferred way of finding selectors on page, but I kind of want to hit two birds with one stone and make sure that there is an element that will contain this placeholder text that we have in here.
If I inspect this element, I'll take a look into the details.
I can actually see the placeholder
attribute over here with the value of "Name of your first board".
Now what I could do instead here is to use value as a selector.
Let's do something like [placeholder="Name of your first board"]
.
Let's see if we save the test, if we're selecting the right element, and it looks good.
Now, this kind of strategy might be useful for more than one place in my application.
Ideally I would like to create a custom command where I don't have to add this whole [placeholder="Name of your first board"]
, but I would have a custom command that would just get my element by placeholder.
So, that's what I'm going to do - I'm going to create my custom command, which will serve as a utility to select my element.
I usually like to place my custom commands in the support
folder, but I don't really use commands.ts
because as the number of commands grows, I need to have them organized.
What I like to do is to create a new folder and call it commands
, which means I'll get rid of commands.ts
.
I also need to take care of the import
in the e2e.ts
file - let's delete that and now everything is working.
In my commands folder, I'm going to create a new file and I'm going to call it getByPlaceholder.ts
so it's nice and descriptive.
It's going to be a TypeScript file, and I'm going to of course import it into my e2e.ts
file with import './commands/getByPlaceholder
and I'll just import the whole file.
Let me save it and nothing changed.
To start off, I'm going to create the command - that's going to be Cypress.commands.add()
and call my command getByPlaceholder
, and this command is going to take an input, which is going to be a string.
Inside here, I'm going to return cy.get([placeholder=${input}])
, and I have my placeholder
equal to our variable, which will be passed on as an argument to this function.
I'll save this and I'm going to go ahead and try to use it in my test.
I'll change my get
to getByPlaceholder
and I'll delete the selector and when I save my test, it's saying that getByPlaceholder
is not a function.
I guess I must have made a mistake, and I can see now that it's with a capital L - so let's fix that and save my test.
And I've got one more error to fix, and that is because this placeholder text can actually contain some spaces - I need to account for that and wrap the value inside quotation marks.
Let's do that, and now when I save my test, it's doing the exact thing as it did before.
I'm going to make an improvement of this command and I'll do Cypress.log({})
to log out some information to the timeline.
I definitely want to see the displayName
which will be getByPlaceholder
.
Then, I also wanted to have a message
, which will be our input
- so whatever we pass in is going to be printed out.
Then also inside consoleProps()
, I want to have some properties, so let's do that.
Properties is going to return an object and inside this object we can add some of the properties.
So let's do selector
, and that's going to be input
.
Let me save this now, and now I can see "getByPlaceholder Name of your first board", which is good.
If I open my console and take a look into the details of this command, I can see the command's name and also the Selector
property that I have added to the console properties, which is "Name of your first board" \
All right, now that we got everything, I'm going to hide this get
command from my log with { log: false }
and now everything is nice and clean.
I just see my getByPlaceholder
command.
But you may have noticed that during the whole time that I'm explaining this custom command, I see a TypeScript warning over here, and I also see my TypeScript warning inside my test.
This error message is saying that 'getByPlaceholder' does not exist on type
'cy & CyEventEmitter', which basically translates to the fact that the
cy` object where all of our commands are does not contain our custom command yet.
If we take a look into our visit
command, and if I hold the option key and click on it, it is going to take me into node_modules
> cypress
> types
> cypress.d.ts
, which is a definition TypeScript file where definitions and descriptions of all of our commands are.
Now, it's definitely hard to see something useful, but if you study it long enough, you might find some information about the command itself and about what it does.
What we need to do with our custom command is to add some sort of documentation to the command and make it a part of the whole Cypress command library.
The way we can do that is to go to our custom command file and add the following definition.
I'm going to go ahead and declare global
, meaning that this will be a global module that can be used anywhere in our project.
Inside this, I'm going to add namespace Cypress
and add my command to the Cypress namespace.
The namespace Cypress
contains all of the Cypress API, so that includes all of our cy
commands that we use, cy.get
, cy.visit
, etc, but also this Cypress.Commands
API, Cypress.log
, and all of the functions that Cypress contains.
The Cypress namespace is actually split into separate categories.
For example, this cy.get
command is in a different category than this Cypress.log
.
These categories in TypeScript are called interfaces.
Since I want to expand my Cypress command library, I'm going to make changes to the interface
that's called Chainable
.
This Chainable
interface contains all of the commands, like get
, visit
, should
, etc, and it's called Chainable because we can chain these commands together inside here.
I'm going to add my getByPlaceholder
function, and this function is going to take an input
of type string
, and it's going to return a Chainable
value that will be of any
type.
We are still getting some errors, and the reason for that is the fact that we declared our getByPlaceholder**
** command as a global
module.
In TypeScript, global modules either need to have a thing imported to them or they need to be exported.
The easiest way to solve this is to add an empty export {}
.
This will now get rid of all of our errors.
I know this may not make too much sense at first glance, but as a quick solution, it is good enough.
We will get rid of this later when we actually import stuff into our custom command.
When I now look at my tests, I can see that the error has disappeared, and if I want to call my getByPlaceholder
command, I can actually see it being auto suggested, which is neat.
This means that our new custom command is now part of our Cypress command library.
Now, one thing you might notice if I hover over my visit
command, I get this nice mini documentation over here.
If I hover over my getByPlaceholder
command, I get no such thing.
I'm just going to get a bunch of TypeScript texts in here.
Let's change it by adding something to our command that's called JSDoc.
JSDoc is this special kind of comment that starts with a slash and double asterisk /**
.
This JSDoc comment can be used to write a sort of a mini documentation for our custom command.
This is really valuable if you are working on a team and you are creating your own library of custom commands, because anyone who enters your repository not only gets the auto-suggestion, but will also get an explanation of what this command will do.
It is usually simple to understand these small utility commands, but the proper documentation might get really useful if you have commands calling your API or seeding your database or doing some more complex work.
The JSDoc actually comes with a couple of tags that can organize the documentation for the command into logical parts.
For example, this @param
is explaining what kind of parameters go in this function and what they are used for.
In the case of input
for our getByPlaceholder
, I'm going to add "placeholder text value".
Above @param
, I'm going to say "Get a DOM element based on placeholder value".
When I now save this and hover over my getByPlaceholder
command in my test, I can see that it shows my description and the param tag.
Another useful tag that we have in JSDoc is an @example
tag.
I can add something like this
// this command
cy.getPlaceholder('Your email')
// will select this element
<input placeholder="Your email" />
When I go over to my test and hover over, I can see my example as well.
One more tag that I would like to show you is a tag called @deprecated
, which is especially useful for when you want to stop using a certain command in your codebase.
When I add this tag into my comment, my text editor is going to cross over this command. It is still working so the tag doesn't affect the command's behavior, but it will send a signal to anyone using this command that it needs to be rewritten or substituted by a newer alternative.
I'm, of course, going to remove that since this is a brand new command.
So, my command works as expected, and if I try to use it, it will auto-complete and it'll also check for types.
If I don't add any arguments or add a wrong argument - pass an object or something else like a number - it's going to complain.
I need to pass a correct argument.
But what if I pass a correct argument, but it's still not something that my command would find?
For example, in this case, I typed "N", and there's no element that would have this sort of placeholder.
Well, I can actually make my custom command auto-complete the selectors I want.
Let me show you what I mean.
I'm going to create a new folder in support
and call it typings
.
Inside here, I'm going to create a new file called placeholders.d.ts
.
In this file, I'm going to export
a type
that will be called placeholders
.
In here, I'm just going to name every placeholder that I use in my application.
For example, "Name of your first board" or some others that I have in my application - for example, "Add board title".
I divide each value by this pipe |
character, which means that the value of placeholders can be any of these strings.
I'll save my placeholders
file and I'm going to import it into my custom command with import { Placeholders } from '..typings/placeholders'
.
I can now remove this empty export
, by the way.
What I'm going to do is take this Type and use it as a Type for my input parameter.
I'll add it into the definition and also inside my custom command.
When I save my file and go back to my test, TypeScript compiler is already complaining about the fact that this single "N" character is actually not of the type 'Placeholders'.
If I decide to change it, I can actually get some auto-complete suggestions right from my placeholders
file.
I'll add it in, and when I save my test, it has found the right element.