Make mailto: links testable with Cypress
If you have written Cypress tests for an web application that allows composing emails, and open the user email client, you might have run into some issues such as:
- Cypress test can’t assert/find any more element once the user email client is opened.
- In CI, Cypress tests that include such flow has messages such as:
LaunchProcess: failed to execvp:
xdg-email
Why doesn’t Cypress support it by default?
In short, it’s not Cypress it’s you. However, Cypress too has few improvements it can offer.
In web browser, opening the email client is supported by URI. Some of the places
mailto:
URI can be used in are:
- a
href
attribute on an anchor (a
) tag, action
attribute on aform
tag,- url for
window.location.href
, [Note: Worst option if you want to test your application using Cypress] - url in
window.open(url, target)
, - simply typed in the browser’s address bar,
Even in your application you might have used one of the above approach.
Setting window.location.href to open mailto links
Let’s start with the worst option (in terms of testing with Cypress) you could try.
You can set the mailto
URI to the window.location.href
property, which signals the browser to open the email client. From the user perspective, this opens the default email client and everything works. But in Cypress world it’s a different reality.
Let’s see an implementation:
function RouteComponent() {
return (
<>
<h1>Send Email using window.location.href</h1>
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const email = form.email.value;
const subject = form.subject.value;
const body = form.body.value;
window.location.href = `mailto:${email}?subject=${encodeURIComponent(
subject,
)}&body=${encodeURIComponent(body)}`;
}}
data-testid="email-form"
>
<div>
<label>
To:
<input
type="email"
name="email"
placeholder="Recipient's email"
required
data-testid="email-input"
/>
</label>
</div>
<div>
<label>
Subject:
<input
type="text"
name="subject"
placeholder="Email subject"
required
data-testid="subject-input"
/>
</label>
</div>
<div>
<label>
Body:
<textarea
name="body"
placeholder="Write your message here..."
required
data-testid="body-textarea"
></textarea>
</label>
</div>
<button type="submit" data-testid="send-button">
Send Email
</button>
</form>
</>
);
}
And the test:
describe("Send Mail", () => {
beforeEach(() => {
cy.visit("/locationHref");
});
it("should generate the correct mailto link", () => {
// Fill out the email input
cy.get('[data-testid="email-input"]').type("[email protected]");
// Fill out the subject input
cy.get('[data-testid="subject-input"]').type("Test Subject");
// Fill out the body textarea
cy.get('[data-testid="body-textarea"]').type("This is a test email body.");
// Click the send button
cy.get('[data-testid="send-button"]').click();
// Assert stuffs
cy.url().should("include", "/locationHref");
});
});
Run the test in Cypress, and everything seems to work perfectly until the button is clicked and the email client opens.
It then seems to be stuck waiting for a new page to load and eventually times out.
This is exactly where I found myself recently. And I had no idea what and where things went wrong. For all I can see is no new page is being loaded. I had put the e.preventDefault();
in the code.
After hours of banging my head against a wall, I tried removing the line with window.location.href
and the Cypress test wasn’t getting stuck anymore.
Then I realized Cypress thought that a new page was being loaded when window.location.href
was set to the mailto:
URI. After all that’s what it’s when you set a URL to it.
Okay enough of my chattering, how can you fix it?
You might be thinking can’t you just the window.location
or window.location.href
object. The chances are you can’t stub the window.location.href
object, if you are using any type of routers in your code most likely they use it in some way or other and your application would just won’t load anymore.
beforeEach(() => {
cy.visit("/locationHref", {
onBeforeLoad(win) {
// Doesn't work as you can't stub a member variable,
// and a stub is a function.
cy.stub(win, "location").as("locationStub");
// You might try stubbing the setter for href, it also doesn't work
// as win.location is a crucial feature required by many routers.
const oldLocation = win.location as unknown as { href: string };
win.location = {
href: {
set: cy.stub().as("locationStub"),
get: () => oldLocation.href,
},
};
},
});
});
The only workaround is to replace the window.location.href
with something else in code when running a Cypress test
- either in the code itself using
window.Cypress
check - or as described by intercepting the request to the path that loads the JavaScript file and replacing the response content.
And in my opinion both aren’t desirable, the first one introduces code related to test in the application itself, and the second one is just too complicated to get this simple thing working.
My suggestion is to not use window.location.href
at all, instead use window.open
which enables testability by stubbing the window.open
function.
Using window.open to open the mailto links
Using window.open
enables testability without major hacks, you can easily stub it in Cypress and even assert the generated mailto
URI.
// In the code example on using window.location.href,
// replace the line that set's window.location.href with the following.
window.open(
`mailto:${email}?subject=${encodeURIComponent(
subject,
)}&body=${encodeURIComponent(body)}`,
'_blank' // Might not be supported in all platform.
);
Notice the _blank
as the second argument, this allows you to run your test without stubbing the window.open
function. This is because it tells the browser to open the mailto:
link in it’s own window and Cypress doesn’t happen to worry about any new windows opening.
Caution: _blank
isn’t in Safari in iOS and WebView on iOS. (UNTESTED: You might get away with using a on those platform.)
A sample Cypress test might look like following:
describe("Send Mail", () => {
beforeEach(() => {
cy.visit("/");
});
it("should generate the correct mailto link", () => {
// Stub the window.open to prevent navigation
cy.window().then((win) => {
cy.stub(win, "open").as("locationStub");
});
// Fill out the email input
cy.get('[data-testid="email-input"]').type("[email protected]");
// Fill out the subject input
cy.get('[data-testid="subject-input"]').type("Test Subject");
// Fill out the body textarea
cy.get('[data-testid="body-textarea"]').type("This is a test email body.");
// Click the send button
cy.get('[data-testid="send-button"]').click();
// Assert that the mailto link is generated correctly
cy.get("@locationStub").should(
"be.calledWithMatch",
"[email protected]",
);
cy.get("@locationStub").should(
"be.calledWithMatch",
/subject=Test%20Subject/,
);
cy.get("@locationStub").should(
"be.calledWithMatch",
/body=This%20is%20a%20test%20email%20body/,
);
});
});
Using a form
or an anchor a
tag
The major issue with using window.open
with a stub is, like window.location.href
, it might also be used by other parts of your application which might be affected by the stub.
Like window.open
, <form>
and <a>
tag both support a target
attribute which can tell the browser to open the link (mailto
URI in our case) to open in a separate window.
But unlike window.open
, they are well on all major browsers.
Here’s an implementation that uses a hidden <form>
tag to open the mailto
URI.
In the example, the reason we have a separate <form>
tag than the one shown to the user is to prevent the browser from appending more query string auto generated from the <input>
tags. This is the default behavior of the browser when submitting a <form>
with GET
method.
Similarly, you can create a <a>
tag and click it programatically. With this approach you can get both browser support and a way to validate the generated mailto:
URI.
A sample Cypress test might look like the following:
describe("Send Mail", () => {
beforeEach(() => {
cy.visit("/formAction");
});
it("should generate the correct mailto link", () => {
// Fill out the email input
cy.get('[data-testid="email-input"]').type("[email protected]");
// Fill out the subject input
cy.get('[data-testid="subject-input"]').type("Test Subject");
// Fill out the body textarea
cy.get('[data-testid="body-textarea"]').type("This is a test email body.");
// Click the send button
cy.get('[data-testid="send-button"]').click();
// Validate the action field of email form.
cy.get('[data-testid="email-form"]').should(
"have.attr",
"action",
"[email protected]?subject=Test%20Subject&body=This%20is%20a%20test%20email%20body.",
);
});
});
But what about the “LaunchProcess: failed to execvp:”?
In you CI that runs your Cypress tests, you might see an message similar to:
LaunchProcess: failed to execvp:
xdg-email
This means that the test tried to open the email client but no email client is registered/installed on the system. You can safely ignore this message if you application doesn’t interact with any email client directly.
This message will disappear if you run the tests for window.open
by stubbing the function but it does mean that it won’t open a email client anymore.
This message can’t be turned off for the <a>
and <form>
tag methods. At least I haven’t been able to. Leave a comment if you have found a way.
Key Takeaways
- When evaluating different approaches for implementing any feature in your application, always think about which approach is most easily testable.
- Missing feature in Cypress?? Why can’t I tell cypress to ignore any new window? Why can’t I intercept the
mailto:
links in cypress?