Running Selenium Tests in GitHub actions email test cover image

Run Selenium Tests in GitHub Actions – Email Testing

Welcome to the second part of the two-part series on running selenium tests with GitHub Actions. In the first article, we outlined how to get started and how to set up your repo for Actions. In this guide, we’ll outline how to run email integration tests and pass secrets to GitHub Actions.

Looking for up-to-date test code? Our Selenium email testing guide has a signup verification test for Node.js and headless Chrome. It uses a unique address per run, waits only for new mail, and comes with a ready-to-use GitHub Actions workflow. This post walks through the GitHub Actions setup step by step.

To start… Why would you want to test emails with live email services in the first place? Can’t you simply write to a log file or standard out? Yes… and no. Writing to log files or standard out is ok at the beginning of the application lifecycle. Building out the email send feature takes a back seat to ensure the application actually works.

But let’s say your application is almost ready for its initial release. You want to test that you can even connect to an SMTP server. Or test to ensure the right message can go to the right inbox. Testing via log files or standard out starts to get a bit limiting in that regard. Additionally, say you want to be really sure that the contents of an email you send are what you expect them to be. At this point, you’d like to simulate as much of the email delivery as possible.

You can accomplish this with a disposable email service like Mailsac.

Allow us to plug our mail service

At Mailsac we focus on the developer experience around email automation and testing. That’s why we’ve made it so you can test out the API mentioned in this guide with a free account. You can sign up here.

So let’s lay out our testing goal:

  1. Simulate an email send with our sample application.
    1. Refer to our Guide To Stress Free Email Testing with Next.js for more information on how we created this sample application
    2. Use Selenium to drive the form and hit send
  2. Use our capture service API to send the email
  3. Use Mailsac’s API to read the email from the destination inbox
  4. Verify the contents of said email
  5. Do all this on GitHub Actions

Let’s get started.

API Credentials

If you don’t already have one, go ahead and create a mailsac account and generate an API key.

Plug those API keys in a file called .env at the root of the project:

MAILSAC_USERNAME=$MAILSAC_USERNAME
MAILSAC_API_KEY=$MAILSAC_GENERATED_KEY
MAILSAC_HOST=capture.mailsac.com
MAILSAC_PORT=5587

.env

Craft the Email

Let’s craft the email by driving Selenium through the form. Start by crafting a selenium test:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By } = require('selenium-webdriver');

const screen = {
  width: 1920,
  height: 1080
};

(async function emailSendTest() {
    
  let driver = await new Builder()
    .forBrowser(Browser.CHROME)
    // .setChromeOptions(new chrome.Options().headless().windowSize(screen))
    .build();

  try {
    await driver.get('<http://localhost:3000>');
    let didSendButtonRender = await driver.findElement(By.id('sendbutton')).isDisplayed()
    
    if (!didSendButtonRender){
      throw new Error(`Send button was not rendered properly.`);
    }
 
    await driver.findElement(By.id('email')).sendKeys("[email protected]");
    await driver.findElement(By.id('comment')).sendKeys("This is some text from our Selenium test.");
    await driver.findElement(By.id('sendbutton')).click();

  } finally {
    await driver.quit();
  }
})();

tests/email-send.js

Note: I left the headless option off for this first test. You’ll want to turn the headless option back on for the test run via our continuous integration environment.

You can do a local test by running

node tests/email-send.js

And checking the inbox we tested ([email protected]) manually:

Success! Though that’s part of the way there. Let’s assert that the email contents match via the API.

Read the Email via Mailsac’s API

Thankfully, Mailsac has not only a robust API but also lots of sample code inside the docs. We’ll lift the sample email read from here:

https://docs.mailsac.com/en/latest/services/reading_mail/reading_mail.html

Note that you’ll need to install a couple of dev packages to get this to work: dotenv and superagent. dotenv is needed in this instance since our tests don’t load the entire next framework and as such, we need a method to read your .env file. superagent is a small client-side HTTP request library for doing quick HTTP calls like the one we’re about to do.

So go ahead and add them to your developer dependencies:

npm install --save-dev dotenv superagent

And add our own text comparison to Mailsac’s sample code:

require('dotenv').config()

const superagent = require('superagent')
const mailsac_api_key = process.env.MAILSAC_API_KEY
const expected_message = 'This is some text from our Selenium test.'

superagent
  .get('<https://mailsac.com/api/addresses/[email protected]/messages>')
  .set('Mailsac-Key', mailsac_api_key)
  .then((messages) => {
      const messageId = messages.body[0]._id
      superagent
          .get('<https://mailsac.com/api/text/[email protected]/>' + messageId)
          .set('Mailsac-Key', mailsac_api_key)
              .then((messageText) => {
                  if (messageText.text !== expected_message)  {
                    throw new Error(`Message '${messageText.text}' does not match expected text '${expected_message}'`)
                  }
                  else{
                    console.log("Message comparison passed");
                  }
              })
  })
  .catch(err => {
      console.log(err.message)
			process.exit(-1)
  })

tests/email-read.js

Running the test locally should result in a passing test:

$ node tests/email-read.js
Message comparison passed

Of course, if we’ll run these tests many times we’ll also want to ensure that we delete the email contents after our successful read. Let’s add a cleanup step to our read test:

require('dotenv').config()

const superagent = require('superagent')
const mailsac_api_key = process.env.MAILSAC_API_KEY;
const expected_message = 'This is some text from our Selenium test.';
const testInbox = '[email protected]';

superagent
.get(`https://mailsac.com/api/addresses/${testInbox}/messages`)
  .set('Mailsac-Key', mailsac_api_key)
  .then((messages) => {
      const messageId = messages.body[0]._id
      superagent
          .get(`https://mailsac.com/api/text/${testInbox}/` + messageId)
          .set('Mailsac-Key', mailsac_api_key)
              .then((messageText) => {
                  if (messageText.text !== expected_message)  {
                    throw new Error(`Message to delete '${messageText.text}' does not match expected text '${expected_message}'`)
                  }
                  else{
                    console.log("API Read Op: Message comparison passed");
                    superagent
                    .delete(`https://mailsac.com/api/addresses/${testInbox}/messages/${messageId}`)
                    .set('Mailsac-Key', mailsac_api_key)
                    .then((messageResponse) => {
                        console.log(`API Deletion Op: ${messageResponse.body.message}`)
                    })
                  }
              })
  })
  .catch(err => {
      console.log(err.message)
      process.exit(-1)
  })

tests/email-read.js

Let’s add it to a test script and our workflow YAML file:

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "external-tests": "node tests/external-login.js",
    "test": "node tests/button-render.js && npm run mail-tests",
    "mail-tests": "node tests/email-send.js && node tests/email-read.js",
    "e2e-test": "start-server-and-test dev <http://localhost:3000> test"
  },

package.json

Note that we added a run mail-tests script to our end-to-end testing.

Try your end-to-end script locally to ensure it works:

End to end sample test

GitHub Action Test

Now that you have a working test on your local workstation, it’s time to push it up so GitHub Actions can start running your tests. If you haven’t already, read through the first article to catch up on GitHub Actions configuration and initialization.

As a reminder, this is our main.yml workflow file:

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest
    name: Run Selenium Tests
    steps:
    - name: Start selenoid
      uses: Xotabu4/selenoid-github-action@v2

    - uses: actions/checkout@v1
    - run: npm ci  

    - name: Run end to end tests
      run: npm run e2e-test

    - name: Run external login test
      run: npm run external-tests

.github/workflows/main.yml

With all that said, let’s try and see if this email test sends an email on GitHub.

Do a git push and check your results on GitHub:

A gitHub test failure

Looks like a failure. On closer inspection:

GitHub specific failure with details

Ah, that’s right! We forgot to set our API keys at the GitHub level. Let’s go ahead and do that.

GitHub Actions Secrets

You can find it under Settings in your repo:

Github walkthrough image : Click settings

Then under Secrets → Actions click New repository secret

Add each secret that will be needed:

GitHub Secrets listing for your Actions.

Finally, ensure you add it to the workflow file:

- name: Run end to end tests
      run: npm run e2e-test
      env:
        MAILSAC_API_KEY:  ${{secrets.MAILSAC_API_KEY}}
        MAILSAC_USERNAME: ${{ secrets.MAILSAC_USERNAME }}
        MAILSAC_HOST:     ${{ secrets.MAILSAC_HOST }}
        MAILSAC_PORT:     ${{ secrets.MAILSAC_PORT }}

.github/workflows/main.yml

Do a git commit and git push and see the results on GitHub:

Successful email test with GitHub actions.

Success! You can check the details to ensure the API read and write options fire fired off:

Github actions success detailed view.

Conclusion

GitHub Actions is a powerful CI/CD tool and we have only scratched the surface of its capabilities with Selenium. We’ve also shown the power of email testing and how you can ensure the contents of every email are intentional.

We hope you enjoyed the guide, and hope to hear from you on our forums or follow our other content on LinkedIn.

Running Selenium Tests in GitHub actions Cover image

Run Selenium Tests in GitHub Actions

Browser automation is an invaluable tool. At a personal level, you can use it to automate repetitive tasks online. But browser automation can deliver so much more. At its best, it can run tasks with consistent results. Tasks that need lots of manual execution and complexity. Tasks like checking button placement, evaluating user login, and registration workflows. And in our modern era, we have our pick of browser automation frameworks.

We’ve covered browser automation before, but in this guide, we’ll do a deep dive into one of those frameworks. Additionally, we’ll cover using those testing frameworks alongside a specific continuous integration application.

In this guide we’ll walk through three testing scenarios using Selenium and GitHub Actions:

  • Check if a button rendered
  • Test a login workflow
  • Ensure the contents of a sent email

We’ll use our sample application to make our examples concrete, informative, and useful.

Repo URL: https://github.com/mailsac/mailsac-capture-service-example-nextjs

With all that said, let’s get started!

First, let’s talk about what you need to enable GitHub Actions.

Getting started with GitHub Actions

First, if you’d like a primer on GitHub Actions their page is a great resource. To summarize, it’s an easy way to run tasks when someone takes an “action” against your repo.

Actions can be:

  • Pull request merges
  • Commits
  • Repo pushes

GitHub takes your action and runs a task like integration tests, unit tests, etc. All you need to do is create a .github/workflows/main.yml file. Note: Running tasks is just one of the many possibilities for GitHub Actions. You can encompass entire workflows and even produce packages.

We’ll introduce more details further along the guide. For now, you can start by adding an empty file at:

.github/workflows/main.yml

The Sample Application We’ll Use

We’ll fork our sample application at the URL:

https://github.com/mailsac/mailsac-capture-service-example-nextjs

We’ll use this repo to walk through the examples mentioned in the intro. These sample scenarios are not meant to be comprehensive, of course. They’re meant to kick-start your journey with GitHub Actions.

To start, you’ll need to run the standard node installation and run the command:

npm install
npm run dev

Navigate to http://localhost:3000 and see our application in action:

Our initial application

With the preliminary steps done, on to the tests.

First Selenium Test: Check if the button rendered

Let’s start with the simplest test, a button render assertion test.

For this first section, we will:

  1. Add the node packages required to test
  2. Add our selenium test
  3. Add testing scripts to our package.json so that GitHub Actions can call our tests
  4. Add a simple Github Actions workflow file

Let’s start by adding a new branch (or you can fork the repo if you’d like):

git checkout -B cicd-selenium-app-test

Now let’s move on to our sample packages.

Packages: Selenium WebDriver & Start-Server-And-Test

We’ll need to add selenium and a start-server-and-test package. start-server-and-test starts our application. Then it calls our selenium tests so they can run against our running application.

npm install --save-dev start-server-and-test selenium-webdriver

start-server-and-test is one of our most straightforward ways to add live server testing capabilities. If you need more flexible frameworks, you may want to investigate mocha or cypress.

We’ll just focus on the core GitHub actions and selenium testing in this guide.

Selenium Test: Button Render

On to the test itself.

Let’s create a test under tests/button-render.js using selenium and headless chrome:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By } = require('selenium-webdriver');

const screen = {
  width: 1920,
  height: 1080
};

(async function buttonRender() {
  let driver = await new Builder()
    .forBrowser(Browser.CHROME)
    .setChromeOptions(new chrome.Options().headless().windowSize(screen))
    .build();

  try {
    await driver.get('<http://localhost:3000>');
    let didSendButtonRender = await driver.findElement(By.id('sendbutton')).isDisplayed()
    if (!didSendButtonRender){
      throw new Error(`Send button was not rendered properly.`);
    }
 
  } finally {
    await driver.quit();
  }
})();

tests/button-render.js

You can find the full usage guide on selenium’s documentation page.

To help the test out, we’ll add an ID attribute to the Send button:

...
<button
    onClick={sendEmail}
    id="sendbutton"
    className="mt-3 w-full inline-flex items-center justify-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
  >
    Send
</button>
...

pages/index.js

Telling GitHub Actions How to Run Our Tests

We’ll need to add a couple of testing scripts to package.json:

...
"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "node tests/button-render.js",
    "e2e-test": "start-server-and-test dev <http://localhost:3000> test"
  },
...

package.json

Now let’s add the workflow main.yml file to the repo. This is the main file that kicks off a GitHub Action workflow. To keep things simple, we’ll execute our actions based on when a comitter pushes code. You can find the full list over at GitHub’s documentation pages.

on: [push]

jobs:
  tests:
    runs-on: ubuntu-latest
    name: Run Selenium Tests
    steps:
    - name: Start selenoid
      uses: Xotabu4/selenoid-github-action@v2

    - uses: actions/checkout@v1
    - run: npm ci  

    - name: Run end to end tests
      run: npm run e2e-test

.github/workflows/main.yml

For our project, we’ll use solenoid-github-action, a GitHub Action that starts a selenium grid instance in a docker container. Solenoid is a golang reimplementation of Selenium. It makes it very easy to integrate with any continuous integration/deployment environment.

In the last portion of the file, the npm run e2e-test section kicks off the end-to-end test that starts our server and runs the selenium tests.

That’s it! Before you commit and push your code, try running it locally:

npm run e2e-test

The test should pass in your local environment. If it fails due to chrome driver issues you can find a full guide on browser driver installations here.

Send it over to GitHub via git push.

git push --set-upstream origin cicd-selenium-app-test

Head over to your repo on GitHub and under the Actions tab you should now see a selenium test run

A GitHub Actions workflow results page

You can drill down and see where our specific tests ran

Congrats! That completes our first successful test using selenium and GitHub actions. Let’s move on to something a bit more useful.

Second Selenium Test: Open and login into a web app

Let’s expand on our tests a bit. Since we’re aiming for simplicity in this guide, I won’t add a whole authentication workflow to our application. Instead, let’s focus on an existing website and attempt (and fail) to log in.

We’ll start by adding a new test to our repo.

Selenium Test: Login to Dev.to

Let’s bring in our external login Selenium test, but slightly modified:

const chrome = require('selenium-webdriver/chrome');
const {Builder, Browser, By, until } = require('selenium-webdriver');

const screen = {
    width: 1920,
    height: 1080
  };

(async function externalLogin() {
  let driver = await new Builder()
  .forBrowser(Browser.CHROME)
  .setChromeOptions(new chrome.Options().headless().windowSize(screen))
  .build();

  try {
    await driver.get('<https://dev.to>');
    await driver.findElement(By.linkText('Log in')).click();
    await driver.wait(until.titleContains('Welcome - DEV Community'), 3000);
    await driver.findElement(By.name('commit')).click();
    await driver.wait(until.titleIs(''), 3000);
    let errorBox = await driver.findElement(By.className('registration__error-notice'));
    await driver.wait(until.elementIsVisible(errorBox));
    let errorText = await errorBox.getText();

    if (!errorText.includes('Unable to login')){
      throw new Error(`Error text does not contain expected value "${errorText}"`);
    }

  }
  finally {
    await driver.quit();
  }
})();

tests/external-login.js

Only real changes are that we added a headless option for chrome.

Modifying Our Tests

Keeping things simple, let’s just add a new test script:

....
"test": "node tests/button-render.js",
"external-tests": "node tests/external-login.js",
...

package.json

And add a separate testing task to our workflow:

...
- name: Run external login test
      run: npm run external-tests
...

.github/workflows/main.yml

Let’s check our result on GitHub:

GitHub Action Failure example

Looks like a failure due to an incorrect title.

Let’s make the match less precise and rerun the test:

...
await driver.findElement(By.linkText('Log in')).click();
await driver.wait(until.titleContains('Welcome! - DEV Community'), 3000);
await driver.findElement(By.name('commit')).click();
...

tests/external-login.js

Let’s try another git push and..

A full successful external login test screen/

Success!

Conclusion

This was the first part of a two-part series about diving into GitHub Actions and Selenium tests. In the second part, we’ll run through:

  1. running our application in GitHub actions
  2. sending emails via our web form
  3. reading and comparing the email contents
  4. deleting the email contents afterward

All are driven by our selenium tests.

We hope you enjoyed the guide, and hope to hear from you on our forums or follow our other content on LinkedIn.

10 Best Testing Tools for Software Developers in 2022 Logo

10 Best Testing Tools For Software Developers in 2022

As a software developer, it’s crucial to have effective testing tools. They run the gamut from quick libraries to full-fledged analytic frameworks. They also range from free to paid. But which ones are the best in the testing space? In this article, we will list the top 10 best testing tools for software development teams.

But why test?

We can hear the groaning now. Testing is like exercise. We know we’re supposed to, but only so many of us do. Even fewer of us genuinely enjoy it. But testing doesn’t have to be a grind. In fact, we’re willing to bet a portion of you will enjoy it. Why bother with tests?

  • Testing can be fun – A lot of these tools are automation based. You can focus on crafting a comprehensive test as part of your feature building.
  • Testing can be done by other groups – It can create a bridge between you and, for example, the QA team. Commiserating around shared testing struggles can go a long way in building trust between teams.
  • An investment in yourself – Much like the exercise analogy, an investment in testing is an investment in yourself. Refactoring code, deployment flight checks, and all-around confidence about your changes can only be achieved by proper testing.

Now that you know some of the whys, let’s walk through the 10 best testing tools for software developers.

The 10 Best Testing Tools For Software Developers in 2022

1. Puppeteer 

Puppeteer is a favorite of the NodeJS community due to its easy integration into your existing build system. It automates form submission, UI testing, keyboard inputs, and more. The main limitation it does have surrounds browser support. As of this writing Puppeteer only supports Chrome. Firefox support is still in the experimental phase.

Puppeteer’s killer feature is that it installs the browser binary for you, making integrating it into your build system easy.

We’ve written an article on using Puppeteer to walk through a common testing scenario. It guides through testing a login screen to dev.to and ensuring entering a bad password does not allow you to log in. You can find it under Automate the Testing Pain Away with Browser Automation.

2. JMeter 

JMeter is a popular open-source tool that can test web applications, network performance, and more. It has a versatile GUI for manual testing and a CLI for automated testing. It also offers recording capabilities much like some other tools on this list. It’s a powerful tool with one enormous drawback: it can only integrate with Java applications.

3. Selenium WebDriver 

Selenium WebDriver is an open-source automation tool that can test web browsers across different platforms. It’s by far one of the more popular testing tools available around browser automation testing. It can incorporate into a variety of different continuous integration / continuous deployment tools.

Additionally, WebDriver is one of the few tools to be W3C recommended! In their words:

Major browser vendors (Mozilla, Google, Apple, Microsoft) support WebDriver and work constantly to improve the browsers and browser controlling code, which leads to a more uniform behavior across the different browsers, making your automation scripts more stable.

From Selenium’s Project Page
A successful Selenium WebDriver Test

To help you get started with WebDriver we’ve written a guide on creating your automated headless browser testing.

4. Mailsac 

Allow us to shamelessly plug ourselves for a moment, but for your email testing and capture needs, Mailsac is top-notch. We provide just-in-time subdomains and a robust API for automation-driven email testing.

Here’s a guide to using our on-demand subdomain feature to create Company-Wide Email Inboxes. 

A video walkthrough of using ad-hoc Developer Environments in your testing

5. Postman 

Postman is a flexible tool for managing and automating testing requests. It has an intuitive GUI and can generate scripts in various programming languages. You can also store playbooks in Postman to call later using their collection runner.

postman  test runner
Job Runner CLI Status From Postman’s Writing Tests Blog Post

Postman’s killer feature is the ability to integrate API calls onto your build system: 

postman   travis success
Job Runner CLI Status From Postman’s Writing Tests Blog Post

Give Postman a shot, it’s one of the most popular web API testing tools for a reason.

6. Selenium IDE

Selenium IDE is WebDriver’s GUI-driven sibling. It does a similar job of orchestrating browser functions but with the twist that it can “record” actions as you perform them in your browser.

Successful test suite
Successful test suite

Selenium IDE can test web applications, API endpoints, and everything else that WebDriver can do. It’s available as Firefox, Chrome, and Edge extensions.

Much like WebDriver we’ve written a quick start guide on automating the browser.

7. Chrome DevTools

The built-in Chrome DevTools offer a powerful extension that allows you to inspect and test web pages in real-time. Most other browsers offer this capability but Chrome is hard to beat. It offers:

  • Built-in Lighthouse report capable of grading the accessibility of your page
  • Artificial network throttling to simulate slow connection speeds
  • Performance measurement across pages
  • Hundreds of extra plugins

8. JUnit5 

JUnit5 is a popular open-source unit testing framework that can test Java applications. It offers the ability to have test runners for your test cases and enables you to focus on Test Driven Development.

It even has its own tag on Stack Overflow.

9. Cucumber Open

Cucumber is an open-source testing tool that can be used to test web applications and APIs. Cucumber is a rather unique one on this list in that it focuses on getting the specifications right the first time. It’s called Behavior Driven Development and it allows project managers and technical contributors to collaborate on concrete aspects of the application.

10. Firefox Developer Tools

Firefox does a great job innovating on the developer toolset. (3D View anyone?) Since it can do anything the Chrome DevTool set can, we’ll use this section to focus on specific plugin shoutouts:

User-Agent Switcher

Test your browser detection logic with this extension.

Ghostery

Ghostery is great for a variety of reasons, not least of which is to prevent distractions. It blocks a variety of trackers, ads, and generally improves page performance.

Cookie Manager

A versatile extension that can help you test all sorts of functionality in your application. Cookie Manager can help you with everyday tasks from authentication testing to session switching and inspection. You can additionally export and import cookie sets.

Conclusion

These are some of the best software testing tools available for developers. They all have their strengths and weaknesses, but they are all powerful tools that can help you improve your code quality.

If you’d like to discuss some of the tools you use for your software testing we’ll love to hear about it! Head on over to our community at forum.mailsac.com and discuss your must-have or time-saving tools.

Blog Cover

Automate the Testing Pain Away with Browser Automation

When I say “You need to test your code”, do you wince? Is it a feeling of guilt, one of “I know I should, but…”. Testing may not conjure up the sexiest of images. We as developers frequently put tests off until the end of our feature cycle. Or respond to a production bug by issuing a quick patch. Or worse, just bury our heads in the sand and pretend that we don’t have any bugs in our code at all. (Note: All code has bugs).

The reality is that testing is an incredible investment in your code’s future. Investing in tests is like an insurance policy: hedging your bets against an unknown future. An unknown future consisting of bitrot, dependency deprecations, or service API changes. Testing provides the ability to patch those unknowns through refactoring or flat-out removing stale dependencies. Testing can also buffer against those risks, providing peace of mind.

In this post, I’ll outline 3 different types of testing tools:

  • Selenium WebDriver
  • Selenium IDE
  • Puppeteer

To do an apples-to-apples comparison the testing scenario will be the same for all three tools. I’ll also model my testing after a user’s typical behavior. Behaviors like login attempts, searching, and form submissions. They also try to hit every layer of the application, from the user interface to the database.

Benefits of Testing a User Interface

Testing isn’t just limited to the backend. Testing your interface can provide complete end-to-end testing scenarios such as:

  1. Repeated calls to your modal. Does the modal come back after the first call?
  2. Does your submit button produce an error if the form has an incorrect value?
  3. Does the UI load after a successful login to an empty state in your application?
  4. Does a specific result come back after a form search?

I’m going to walk through a straightforward testing scenario with three tools. Not to rank them, but to touch on the nuances of each. Some of these tools allow you to create tests through simple browsing. Others are headless, allowing you to drive through programming languages.

What’s a headless browser?

Conventional browsing involves rendering forms, buttons, and images to the user. A headless browser interacts with websites through code without displaying any controls. Headless browsing opens up possibilities that are tough to achieve with conventional browsers like:

  • Integration with your build systems
  • Consistency in testing
  • Decreasing the duration of your tests
  • Layout screen captures and comparisons

Tools of the Automated Browser Trade

Onto the good stuff: The tools and testing scenarios.

The Most Popular – Selenium WebDriver

Selenium is by far the most popular testing tool out there. It covers headless testing and both local and remote tests.

WebDriver targets as its core base Developers and QA Team members who can write code.

The Easiest To Get Started with – Selenium IDE

Selenium designed the IDE version for exploratory testing and bug replication. It’s perfect for walking through a bug with someone else or creating a recording of a bug for your ticketing system.

The NodeJS Fan Favorite – Puppeteer

Puppeteer is a favorite of the NodeJS community due to its easy integration into your existing build system. It automates form submission, UI testing, keyboard inputs, and more. It’s main limitation however is the browsers it supports. As of this writing Puppeteer only supports Chrome. Firefox support is, as of this writing, experimental.

Puppeteer’s killer feature is that it installs the browser binary for you, making the integrating into your build system easy.

Testing Scenario: A Failed Login to dev.to

Here’s our testing scenario:

  1. Load https://dev.to
  2. Click the “Log in” button
  3. Load a page with “Welcome! – DEV Community” in its title.
  4. Click on the “Continue” button
  5. Ensure an “Unable to login” banner appears on the page.

For consistency throughout the walkthrough, I’ll use:

  1. Chrome as my browser
  2. Javascript as the programming language of choice

Test Case 1 – Selenium WebDriver

Let’s begin with an empty directory and selenium package installation:

npm init tests
cd tests
npm install selenium-webdriver

Next, download a browser driver. You can find the full supported list in selenium’s code repository. You can place the binary anywhere. For this walkthrough, I’ll place it in the current project directory under the bin/ path.

Set your specific browser driver path:

export PATH=$PATH:$PWD/bin

I’ll be using this quick test setup (selenium.js):

const {Builder, Browser, By, Key, until} = require('selenium-webdriver');

(async function example() {
  let driver = await new Builder().forBrowser(Browser.CHROME).build();
  try {
    await driver.get('http://dev.to');
    await driver.findElement(By.linkText('Log in')).click();
    await driver.wait(until.titleIs('Welcome! - DEV Community'), 3000);
    await driver.findElement(By.name('commit')).click();
    await driver.wait(until.titleIs(''), 3000);
    let errorBox = await driver.findElement(By.className('registration__error-notice'));
    await driver.wait(until.elementIsVisible(errorBox));
    let errorText = await errorBox.getText();

    if (!errorText.includes('Error')){
      throw new Error(`Error text does not contain expected value: ${errorText}`);
    }

  } finally {
    await driver.quit();
  }
})();

Set your driver and run the file

SELENIUM_BROWSER=chrome node selenium.js
A failed Selenium Test

In general I like to ensure my tests fail from the start, followed by working towards passing the tests:

const {Builder, Browser, By, Key, until} = require('selenium-webdriver');

(async function example() {
  let driver = await new Builder().forBrowser(Browser.CHROME).build();
  try {
    await driver.get('http://dev.to');
    await driver.findElement(By.linkText('Log in')).click();
    await driver.wait(until.titleIs('Welcome! - DEV Community'), 3000);
    await driver.findElement(By.name('commit')).click();
    await driver.wait(until.titleIs(''), 3000);
    let errorBox = await driver.findElement(By.className('registration__error-notice'));
    await driver.wait(until.elementIsVisible(errorBox));
    let errorText = await errorBox.getText();

    if (!errorText.includes('Unable to login')){
      throw new Error(`Error text does not contain expected value "${errorText}"`);
    }

  } catch(e) {
    console.error(`Error running test suite: ${e.message}`)
  }
  finally {
    await driver.quit();
  }
})();

With line 15 fixed, rerun the script:

A successful Selenium Test

Success!

The above was a taste of what you can do with Selenium. You can even break out of the testing mindset and use Selenium for scraping and populating activity trackers.

On to the next tool.

Test Case 2 – Selenium IDE

While the previous test requires some programming ability, Selenium IDE is friendly to anyone who can drive a browser. The IDE version’s main use case is bug discovery, recording and profiling.

First, download the package from the Selenium page.

After installing the plugin, start a new project:

Selenium IDE introduction screen.
New Project Page
Selenium IDE's project base URL, where all of your tests will start.
Set our base url to dev.to

After you hit “Start Recording”, Selenium will launch a new Chrome window and redirect you to dev.to

Initial Dev.To Walkthrough with Selenium IDE

From the video, we:

  1. Let the initial dev.to page load
  2. Clicked on the “Log in” button
  3. Clicked on the Selenium IDE extension
  4. Stopped the Extension recording
  5. Arrived at the Commands window below
Selenium IDE's properties loaded automatically
Selenium testing properties loaded automatically

To continue our test scenario, let’s ensure that the page title is Welcome! - DEV Community and that our login attempt fails with an empty submission.

Again, I always like to have my tests fail first, so let’s start with that case. Use Selenium’s assert title command to ensure the title is what we expect. Add it to the command list:

Selenium IDE's asserting that the title fails.
Asserting the page title to fail

If you run the test, it should fail:

Failed automation testing in Selenium IDE.
Example of a failed test

Let’s go ahead and fix it with the correct title and rerun the test:

Successful Title Check

And success! Now let’s add the login check:

Walking through a complete test to success.

To summarize the video, we:

  1. Started a new recording
  2. Hit the Log in button
  3. Clicked Continue without supplying credentials
  4. Used the Selenium element picker to pick out the element we were interested in asserting.

The Commands window should now look like this:

Successful automation testing in Selenium IDE.
Added Command Check

Success!

The IDE version is the simplest to get started with and I recommend it for initial test write-ups. It can help you identify which elements you need to test against, think about app flow and what counts as a failure.

One question remains: Rendering the browser is nice, but I want to hook this into my continuous integration system. How can I do that when every test wants to load an application that requires a windowing system?

The answer is to go headless.

Test Case 3 – Puppeteer

Puppeteer is the perfect match to test web UI components inside a continuous integration system. It’s fast, headless, brings its own dependencies and runs the latest versions of Chrome and Firefox.

Let’s start by installing puppeteer on a new project:

mkdir tests
npm i puppeteer

Keep in mind that this automatically installs the chrome driver we had to manually download in the Selenium example. From Puppeteer’s documents:

When you install Puppeteer, it downloads a recent version of Chromium (~170MB Mac, ~282MB Linux, ~280MB Win) that is guaranteed to work with the API (customizable through Environment Variables).

https://pptr.dev/#installation

With that said, let’s create a test file that will run (and fail) our test scenario (puppeteer.js):

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({headless: false});
  const page = await browser.newPage();
  const loginSelector = 'a[href="/enter"]';
  const submitLoginSelector = '[name="commit"]';
  const errorBoxSelector = '.bad notice';
  try {
    await page.goto('https://dev.to');
    await page.waitForSelector(loginSelector,{ timeout: 3000 });
    await page.click(loginSelector);
    const pageTitle = await page.title();

    if (pageTitle !== 'Welcome! - DEV Community'){
      throw new Error(`Page title ${pageTitle} does not match expected value`);
    }
    await page.waitForSelector(submitLoginSelector,{ timeout: 3000 });
    await page.click(submitLoginSelector);
    await page.waitForSelector(errorBoxSelector,{ timeout: 3000 });
    
  }catch(e){
    console.error(`Error in test suite: ${e.message}`)
  }finally {
    await browser.close();
  }
})();
Failed test due to incorrect error box selection

Some notes on the above code:

  • Lines 6-8 are Puppeteer’s method of selecting elements on the page.
  • Like Selenium WebDriver, you have to manually check a page’s attributes and decide on what to do should they fail
    • In the above code it’s line 15, asserting the title matches the expected value
    • It’ll also implicitly fail on line 20, due to the error div class not matching what dev.to sends to the browser.
  • I’ve disabled the headless feature to show that Puppeteer lets you do that!

Let’s fix the test. Change it to the correct value *and* turn on headless mode:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({headless: true});
  const page = await browser.newPage();
  const loginSelector = 'a[href="/enter"]';
  const submitLoginSelector = '[name="commit"]';
  const errorBoxSelector = '.registration__error-notice';
  try {
    await page.goto('https://dev.to');
    await page.waitForSelector(loginSelector,{ timeout: 3000 });
    await page.click(loginSelector);
    const pageTitle = await page.title();

    if (pageTitle !== 'Welcome! - DEV Community'){
      throw new Error(`Page title ${pageTitle} does not match expected value`);
    }

    await page.waitForSelector(submitLoginSelector,{ timeout: 3000 });
    await page.click(submitLoginSelector);
    await page.waitForSelector(errorBoxSelector,{ timeout: 3000 });
  }catch(e){
    console.error(`Error in test suite: ${e.message}`)
  }finally {
    await browser.close();
  }
})();

Now rerunning the test simply gets you the empty prompt:

tests:DreamMachine % node puppeteer.js
tests:DreamMachine % 

Nice, simple, and clean.

Conclusion

I’ve gone through three different sets of tools for different needs. The best part about these tools is that you can string them all together or pick and choose the ones that are right for you.

I hope the main takeaway is the same: Testing can be painless and even fun!

Selenium can also be used to test email signups with Mailsac.

Questions or comments? Stop by the Mailsac Forums, we’d love to hear from you!

Selenium Email Testing: Verify Signup Emails with Node.js

Updated September 2026. Tested with selenium-webdriver 4.49, headless Chrome 153 and Node.js 22, 24 and 26.

Selenium email testing covers the part of a user journey that leaves the browser. Your app sends an email, and the test has to read it before it can continue. Selenium WebDriver can fill in a signup form, but the email lands somewhere else. This guide adds that missing piece with the Mailsac email testing API, in JavaScript with selenium-webdriver and headless Chrome.

You’ll build an end-to-end signup test. It signs up with a new address, waits for the verification email, follows its link and checks that the account is verified. The same helper then reads a 6-digit sign-in code, and the whole suite runs in GitHub Actions.

How Selenium email testing works

  1. Trigger. Selenium signs up with a new, unique address at mailsac.com. Your app sends the verification email through its usual email provider; Mailsac only receives it. There’s no inbox to create first: any address at mailsac.com, or on your own custom domain, can receive mail.
  2. Wait. The test asks the Mailsac REST API for that address’s messages every 2 seconds. It gives up after 60 seconds, and it ignores anything that arrived before the signup.
  3. Check. It reads the links Mailsac found in the email, opens the verification link in the same browser, and checks the page your app shows.

What you need

  • Node.js 22 or later. selenium-webdriver 4.47 and later declare Node.js 22 as their minimum. The Mailsac helper on its own runs on Node.js 18 and later.
  • Google Chrome. Selenium Manager ships with selenium-webdriver and downloads a matching ChromeDriver when none is on your PATH, so there’s no driver to install.
  • A Mailsac API key. Create a free account; API access is included on every plan, including free. Generate a key under API Keys & Users in the dashboard. It’s shown only once, so treat it like a password.
  • Your app, running where Chrome can reach it and able to send real email. This guide assumes http://localhost:3000.

Set up Selenium 4 with headless Chrome

Add selenium-webdriver to your app’s repository, or to a new folder if your tests live on their own. 4.49.0 is the version tested here.

# In your app's repository, or in a new folder after running: npm init -y
npm install --save-dev selenium-webdriver@4.49.0
npm pkg set scripts.test:email="node --test e2e/*.test.mjs"
mkdir e2e

The test:email script uses Node’s built-in test runner, so there’s no test framework to install. If your suite uses Mocha or Jest, keep the Selenium and Mailsac code below and adapt the imports, the before/after hooks (Jest calls them beforeAll/afterAll) and the timeout setting.

Add a small Mailsac helper

Save this as e2e/mailsac.mjs. It uses Node’s built-in fetch and has no dependencies. It reads your API key from the MAILSAC_API_KEY environment variable and sends it only in the Mailsac-Key header.

// e2e/mailsac.mjs: wait for an email with the Mailsac REST API. Node 18+, no dependencies.
import { randomUUID } from 'node:crypto';

const API = process.env.MAILSAC_API_URL || 'https://mailsac.com/api'; // override only for a mock

// A new address for every test, so reruns and parallel jobs never share an inbox.
// Public @mailsac.com by default; set MAILSAC_TEST_DOMAIN to use your private domain.
export function uniqueAddress(prefix) {
  const domain = process.env.MAILSAC_TEST_DOMAIN || 'mailsac.com';
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@${domain}`;
}

async function mailsac(path, as = 'json') {
  const key = process.env.MAILSAC_API_KEY; // a CI secret; never in browser code
  if (!key) throw new Error('Set MAILSAC_API_KEY');
  const headers = { 'Mailsac-Key': key };
  const res = await fetch(API + path, { headers, signal: AbortSignal.timeout(10_000) });
  // Fail fast on errors. A 429 means the account hit its monthly Ops limit; retrying won't help.
  if (!res.ok) throw new Error(`Mailsac API returned ${res.status} for ${path}`);
  return as === 'json' ? res.json() : res.text();
}

// Poll every 2 s until the deadline. The list is newest first; skip anything received
// before `receivedAfter`, a Date.now() taken just before your app sends the email.
export async function waitForEmail(email, { subject, receivedAfter, timeoutMs = 60_000 }) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    const list = await mailsac(`/addresses/${email}/messages?limit=10`);
    const match = list.find((m) =>
      Date.parse(m.received) >= receivedAfter && m.subject?.includes(subject));
    if (match) {
      // Metadata has links[], the URLs Mailsac found in the email. Plain-text links can keep
      // trailing punctuation, e.g. <url> or [url]; drop it, then de-duplicate.
      const message = await mailsac(`/addresses/${email}/messages/${match._id}`);
      const links = (message.links ?? []).map((url) => url.replace(/[>\]).,;:!?'"*]+$/, ''));
      return { ...message, links: [...new Set(links)] };
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  throw new Error(`No "${subject}" email for ${email} within ${timeoutMs / 1000} s`);
}

// The message as plain text. Mailsac generates it for HTML-only email.
export const readText = (email, messageId) => mailsac(`/text/${email}/${messageId}`, 'text');

What it does:

  • A unique address per test. uniqueAddress('signup') returns a new address each time, so reruns, retries and parallel CI jobs never read each other’s mail.
  • Bounded polling. waitForEmail() lists the inbox every 2 seconds, newest first, and throws when timeoutMs (60 seconds by default) runs out. Each request also has a 10-second timeout, so a stalled connection can’t hang your suite.
  • Only mail received after the trigger. The list endpoint has no “received after” filter, so the helper compares each message’s received time with receivedAfter and also matches the subject. An older verification email, or a welcome email that arrives first, is skipped. Tests that just take the newest message can end up clicking yesterday’s link.
  • Links parsed by Mailsac. The message metadata includes links, the HTTP(S) URLs Mailsac found in the email’s text and HTML. The helper trims punctuation that plain-text emails can leave on the end of a link, such as > or ].
  • Plain text for codes. readText() returns the message as plain text. Mailsac generates it for HTML-only email.
  • Fails fast on API errors. Any non-2xx response throws with the status and path, never the key. In CI, consider retrying 5xx responses within your deadline. MAILSAC_API_URL is only for pointing the helper at a mock server.

Each API call counts as one Mailsac Op. The signup test uses 2 Ops when the email is there on the first poll (list, then metadata), plus 1 for each extra poll. The 2FA test below also reads the text, so it starts at 3. Each message that arrives at a private address or custom domain adds 1 more. A test that times out after 60 seconds uses about 30. The free plan includes 1,500 Ops a month; here’s how Ops are counted.

Test the signup verification email

Save this as e2e/signup.test.mjs. Change the lines marked PLACEHOLDER to match your app’s signup page, field names, confirmation text and email subject.

// e2e/signup.test.mjs: sign up in headless Chrome, then verify the address from the email.
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { Browser, Builder, By, until } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';
import { readText, uniqueAddress, waitForEmail } from './mailsac.mjs';

const APP_URL = process.env.APP_URL || 'http://localhost:3000'; // PLACEHOLDER: your app

describe('signup email', () => {
  let driver;
  before(async () => {
    const options = new chrome.Options().addArguments('--headless=new', '--window-size=1280,1000');
    driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build();
  });
  after(() => driver?.quit());

  it('verifies a new account from the link in the email', { timeout: 90_000 }, async () => {
    const email = uniqueAddress('signup');

    // PLACEHOLDER: your signup page, field names, confirmation text and email subject.
    await driver.get(`${APP_URL}/signup`);
    await driver.findElement(By.name('email')).sendKeys(email);
    await driver.findElement(By.name('password')).sendKeys('a-test-password-123');
    const receivedAfter = Date.now(); // just before the app sends the email
    await driver.findElement(By.css('button[type=submit]')).click();
    const status = await driver.wait(until.elementLocated(By.css('[role=status]')), 10_000);
    await driver.wait(until.elementTextMatches(status, /check your email/i), 10_000,
      'Signup not confirmed');

    const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
    const links = message.links.filter((url) => url.startsWith(`${APP_URL}/verify`));
    assert.equal(links.length, 1, `Expected one verification link, found ${links.length}`);

    await driver.get(links[0]);
    const heading = await driver.wait(until.elementLocated(By.css('h1')), 10_000);
    await driver.wait(until.elementTextIs(heading, 'Email verified'), 10_000, 'Email not verified');
  });
});

Start your app, then run the test with your key in the environment. Keep the key out of committed files.

MAILSAC_API_KEY=your_api_key npm run test:email

The test fails with a clear message if the email doesn’t arrive within 60 seconds, or if it doesn’t contain exactly one distinct link to your app’s /verify page. Filtering on your app’s origin and path means the test can’t pass by following a help or unsubscribe link, or a link to another site.

Take receivedAfter just before the click that sends the email. Taken earlier, it can let an older email through. Taken after the click, it can skip an email that arrived quickly.

Read a 6-digit OTP or 2FA code

Codes work the same way, except the test reads the plain text and finds the code itself; Mailsac doesn’t extract codes for you. Two-factor tests usually sign in to a seeded account, so the address is fixed and older codes pile up in its inbox. Because the code signs in to an account, give that account a private address; the free plan includes one.

Add this test inside the describe block in e2e/signup.test.mjs (readText is already imported), and set TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD for the seeded account. It clears the browser’s cookies first, because many apps sign you in as soon as you verify an email.

  it('signs in with the 6-digit code from the email', { timeout: 90_000 }, async () => {
    // PLACEHOLDER: a seeded test account with email 2FA turned on. Its address is fixed, so
    // older codes stay in the inbox; receivedAfter makes the test read only the new one.
    const { TWO_FACTOR_EMAIL: email, TWO_FACTOR_PASSWORD: password } = process.env;
    assert.ok(email && password, 'Set TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD');

    await driver.manage().deleteAllCookies(); // the signup test may have left you signed in
    await driver.get(`${APP_URL}/login`);
    await driver.findElement(By.name('email')).sendKeys(email);
    await driver.findElement(By.name('password')).sendKeys(password);
    const receivedAfter = Date.now();
    await driver.findElement(By.css('button[type=submit]')).click();
    const codeInput = await driver.wait(until.elementLocated(By.name('code')), 10_000);

    const message = await waitForEmail(email, { subject: 'Your sign-in code', receivedAfter });
    const codes = [...new Set((await readText(email, message._id)).match(/\b\d{6}\b/g))];
    assert.equal(codes.length, 1, `Expected one 6-digit code, found ${codes.length}`);

    await codeInput.sendKeys(codes[0]);
    await driver.findElement(By.css('button[type=submit]')).click();
    await driver.wait(until.urlContains('/account'), 10_000); // PLACEHOLDER: your signed-in page
  });

Requiring exactly one distinct 6-digit number keeps the test honest. If the email also contains an order number or a postcode with six digits, the test fails instead of typing the wrong one. In that case, match the words around the code instead, such as /code is (\d{6})/, and use its first capture group. For a longer walkthrough of a login form with an emailed 2FA code, see Verifying 2FA code delivery via email with Selenium.

Run the email tests in CI

In CI, the app under test has to send real email to the test address, just as it does locally. This GitHub Actions workflow assumes the tests live in your app’s repository. It starts the app, waits until it answers, then runs the tests in headless Chrome. The ubuntu-latest runner image includes Google Chrome and a matching ChromeDriver, so there’s nothing to install.

# .github/workflows/email-tests.yml
name: Email tests
on: [push, pull_request]
permissions:
  contents: read
jobs:
  selenium:
    runs-on: ubuntu-latest # includes Chrome and a matching ChromeDriver
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Start the app, then run the Selenium email tests
        env:
          MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
          MAILSAC_TEST_DOMAIN: ${{ vars.MAILSAC_TEST_DOMAIN }} # e.g. yourteam.msdc.co
        run: |
          npm start & # PLACEHOLDER: start your app; it must really send its email
          timeout 60 bash -c 'until curl -s -o /dev/null http://localhost:3000; do sleep 1; done'
          npm run test:email

Add MAILSAC_API_KEY as a repository secret under Settings, then Secrets and variables, then Actions. Optionally add a MAILSAC_TEST_DOMAIN variable for a private domain (see below). If you added the 2FA test, store TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD as secrets and add them to the step’s env, like MAILSAC_API_KEY. Its seeded inbox is shared, so keep two runs from overlapping: add concurrency: email-tests at the top level of the workflow.

Pull requests from forks don’t receive repository secrets, so the helper fails there with Set MAILSAC_API_KEY. To skip the job for those pull requests, add if: ${{ !github.event.pull_request.head.repo.fork }} to it. For a walkthrough of the GitHub settings with screenshots, see Run Selenium tests in GitHub Actions: email testing.

Don’t want a staging app delivering mail at all? Point its SMTP settings at Email Capture (capture.mailsac.com). Mailsac keeps each message instead of delivering it, and the same helper reads it by recipient address. Each captured message is one Op, and captured mail is public unless you turn on private capture or use a private custom domain.

Public vs private test addresses

By default, mail sent to a mailsac.com address is public. Anyone can view a public inbox on the Mailsac website without an account, and any Mailsac API key can read it or delete individual messages from it through the API. Public inboxes are temporary: messages may be recycled quickly. Public addresses also have lower inbound throttling thresholds.

A random address makes it unlikely that anyone stumbles on your test mail, but it isn’t access control. Use public addresses for synthetic test data only. If a verification link or code would work on a real account, or the email contains real personal data, use a private address or a private custom domain:

  • Private address. Reserve an address in the dashboard or with POST /api/addresses/{email}, and only your account can read its mail. The free plan includes one; it suits a fixed test account like the 2FA example. Private address docs
  • Custom domain. Every address on the domain can receive mail without setup, and its mail is private once the domain is verified. A zero-setup subdomain of msdc.co, such as yourteam.msdc.co, works right away with no DNS changes. Or bring your own domain: verify it with a TXT record, then point its MX records to Mailsac. Paid plans include custom domains (Indie 1, Business 5, Enterprise 12+); the free plan doesn’t. Custom domain docs

With a custom domain, set MAILSAC_TEST_DOMAIN=yourteam.msdc.co and every uniqueAddress() is on your private domain, so each test still gets its own private inbox. It’s the setup we recommend for team CI suites. Compare plans on the pricing page.

Troubleshooting

  • No "Verify your email" email for … within 60 s. Check that your app really sends email in this environment (many dev and CI setups log mail or use a sandbox instead), that the test polls the exact address the app emailed, and that the subject in the test matches; the match is case-sensitive. Keep the test machine’s clock in sync, because the helper skips mail received before receivedAfter. Busy public addresses can be throttled. The missing-mail guide covers other causes.
  • Expected one verification link, found 0. No link in the email starts with your APP_URL and /verify. Check that APP_URL matches the host the email uses, for example localhost versus 127.0.0.1. If your email provider rewrites links for click tracking, turn tracking off for test mail, or match the tracking link and let Chrome follow the redirect.
  • Expected one verification link, found 2 means the email has two different matching links, such as a plain-text part that broke a long URL across lines. Fix the template, or make the filter stricter.
  • Mailsac API returned 401 means the key is wrong; Set MAILSAC_API_KEY means it’s empty. In CI, pass the secret in the step’s env. 403 means the address is another account’s private address: pick another name or use your own domain. 429 means your account went past its monthly Ops and API access is paused; Mailsac emails warnings before that happens. Retrying won’t help: add Ops or move to a larger plan.
  • SessionNotCreatedError: session not created: This version of ChromeDriver only supports Chrome version …. An old ChromeDriver on your PATH takes precedence over Selenium Manager. Remove it, and Selenium Manager downloads the driver that matches your Chrome.
  • ERR_MODULE_NOT_FOUND for selenium-webdriver/chrome. In an ES module, import selenium-webdriver/chrome.js, with the extension.
  • npm warn EBADENGINE during install. Your Node.js is older than 22, the minimum that current selenium-webdriver declares. Upgrade Node.js.

Next steps

  • See the same trigger, wait and check loop with curl and plain Node.js on the Email Testing API page.
  • Browse every endpoint, including attachments, headers and cleanup, in the API reference, or start with the Mailsac docs.
  • For long waits, turn on webhook or WebSocket forwarding for a private address, or for a catch-all on your custom domain, instead of polling. Domain-wide WebSockets need Business or Enterprise, and pushed messages use Ops.
  • Using Cypress too? Read the Cypress email testing guide.

Create a free Mailsac account to get an API key, then run the signup test against your app.