Integration testing Passwordless authentication with Playwright

| 4 min read

Passwordless authentication is becoming more and more popular. It’s an easy way to have authentication functionality. The main advantage of having magic links is better security. One password to remember less — avoid reuse passwords by users. Additionally, you make sure the user confirms their account with email.

The pain point of using email authentication is testing. Not anymore!

Table of contest


Feedback widget for your docs

This post is inspired by work on my project https://happyreact.com/. Add a feedback widget to your product documentation. Built a better product, avoid user churn and drive more sales!


Let’s start

We will use NextAuth.js bootstrapped from this example with an email provider turned on. It will be a great and easy-to-use playground for our testing.

As stated in the title, our testing framework will be Playwright. I’m using it in my projects and it has excellent developer experience.

Complementary packages:

I configured the email provider and added Playwright. Nothing more than following standard installation from Playwright and NextAuth email adapter setup. Follow the next auth email provider guide on how to set it up.

TL;DR;

Install these packages:

yarn add smtp-tester cheerio --dev

Import smtp-tester and cheerio:

import { test } from '@playwright/test';
import smtpTester from 'smtp-tester';
import { load as cheerioLoad } from 'cheerio';

Your test should look like this:

test.describe('authenticated', () => {
let mailServer;

test.beforeAll(() => {
mailServer = smtpTester.init(4025);
});

test.afterAll(() => {
mailServer.stop();
});

test('login to app', async ({ page, context }) => {
await page.goto('/login');

// your login page test logic
await page
.locator('input[name="email"]')
.type('test@example.com', { delay: 100 });
await page.locator('text=Request magic link').click({ delay: 100 });

await page.waitForSelector(
`text=Check your e-mail inbox for further instructions`
);

let emailLink = null;

try {
const { email } = await mailServer.captureOne('test@example.com', {
wait: 1000
});

const $ = cheerioLoad(email.html);

emailLink = $('#magic-link').attr('href');

} catch (cause) {
console.error(
'No message delivered to test@example.com in 1 second.',
cause
);
}

expect(emailLink).not.toBeFalsy();

const res = await page.goto(emailLink);
expect(res?.url()?.endsWith('/dashboard')).toBeTruthy();

await context.storageState({
path: path.resolve('tests', 'test-results', 'state.json')
});
}
});

Sent emails through our created server:

// This check will work when you pass NODE_ENV with 'test' value
// when running your e2e tests
const transport = process.env.NODE_ENV === 'test'
? nodemailer.createTransport({ port: 4025 })
: nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD
}
});

⚠️ Make sure to send emails through this SMTP server ONLY in testing!

Setting up an email provider

At this point, I assume you have set up an SMTP provider and you are familiar with that matter. When you need a guide, please see “How to set up an SMTP provider for testing” on my blog.

Let’s make our authenticated test. Create authenticated.spec.ts a file in our tests directory with an empty describe block. We want to use beforeAll and afterAll hooks.

// authenticated.spec.ts

import { test } from '@playwright/test';

test.describe('authenticated', () => {
test.beforeAll(() => {});

test.afterAll(() => {});
})

Next, install smtp-tester package and import it into our created test. Run the following command:

yarn add smtp-tester cheerio --dev 

and add a start/stop SMTP server

import { test } from '@playwright/test';
import smtpTester from 'smtp-tester';
import { load as cheerioLoad } from 'cheerio';

test.describe('authenticated', () => {
let mailServer: any;

test.beforeAll(() => {
mailServer = smtpTester.init(4025);
});

test.afterAll(() => {
mailServer.stop();
});
});

What is happening here?

  • Before all tests, we are starting the SMTP server

  • After all tests, we are stopping this service

The important part is that we are creating our SMTP server on port 4025. We will use this port later to send emails.

Testing login form

Now let’s write some test logic. You should adjust texts to what you show in your application after certain actions. HappyReact texts are as follows.

// authenticated.spec.ts

test('login to app', async ({ page, context }) => {
await page.goto('/login');

// your login page test logic
await page
.locator('input[name="email"]')
.type('test@example.com', { delay: 100 });
await page.locator('text=Request magic link').click({ delay: 100 });

await page.waitForSelector(
`text=Check your e-mail inbox for further instructions`
);

let emailLink = null as any;

try {
const { email } = await mailServer.captureOne('test@example.com', {
wait: 1000
});

const $ = cheerioLoad(email.html);

emailLink = $('#magic-link').attr('href');

} catch (cause) {
console.error(
'No message delivered to test@example.com in 1 second.',
cause
);
}

expect(emailLink).not.toBeFalsy();

const res = await page.goto(emailLink);
expect(res?.url()?.endsWith('/dashboard')).toBeTruthy();

await context.storageState({
path: path.resolve('tests', 'test-results', 'state.json')
});
}

What is happening here?

  • Going to the login page and filling out our email address

  • Capturing email sent to our email address

  • Loading HTML from the email into cheerio and finding a login link

  • Checking if the link is present and visiting it

  • Saving auth cookies to use them later in tests (optional)

💡 It’s a good idea to add id to link with a URL in the email template. This will let you find it much easier.

Remember about our SMTP server is running on port 4025? We need to instruct nodemailer to send emails using this server instead standard one. It’s the only way to capture email in tests and prevent it from being sent to a real e-mail address. Be sure that you are using it only in testing.

// This check will work when you pass NODE_ENV with 'test' value
// when running your e2e tests
const transport = process.env.NODE_ENV === 'test'
? nodemailer.createTransport({ port: 4025 })
: nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD
}
});

⚠️ Make sure to send emails through this SMTP server ONLY in testing!

Wrapping up

Testing sending emails was always a pain. This technique can be extended. Alongside authentication, you can test other functionalities like invoices or team invites. Results with the better application.