Integration testing Passwordless authentication with Playwright
Tags: webdev, javascript, playwright, programming
Published: 2023-01-04 15:39:59

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


- [Let’s start](#lets-start)

- [TL;DR;](#tldr)

- [Setting up an email provider](#setting-up-an-email-provider)

- [Testing login form](#testing-login-form)

- [Wrapping up](#wrapping-up)

---

![[Feedback widget for your docs](https://happyreact.com/)](https://cdn-images-1.medium.com/max/5344/1*wou8Cy22lSgdM5Iefco6ng.png)

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

---

<a name="lets-start"></a>
## Let’s start

We will use NextAuth.js bootstrapped from [this example](https://github.com/nextauthjs/next-auth/tree/main/apps/example-nextjs) 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](https://playwright.dev/). I’m using it in my projects and it has excellent developer experience. 

Complementary packages:

* [smtp-tester](https://github.com/deitch/smtp-tester) for starting the SMTP local server

* [cheerio](https://cheerio.js.org/) for finding the link in the email’s HTML

* [nodemailer](https://github.com/nodemailer/nodemailer) for sending emails 

I configured the email provider and added [Playwright](https://playwright.dev/). Nothing more than following standard installation from Playwright and NextAuth email adapter setup. **Follow [the next auth email provider guide](https://next-auth.js.org/providers/email) on how to set it up.**

<a name="tldr"></a>
## TL;DR;

Install these packages:

```bash
yarn add smtp-tester cheerio --dev
```

Import smtp-tester and cheerio:

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

Your test should look like this:

```ts
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:

```js
// 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!**

<a name="setting-up-an-email-provider"></a>
## 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](https://marcin.page/snippets/setting-smtp-locally-for-testing-in-playwright-or-cypress/)” 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.

```ts
// 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:

```bash
yarn add smtp-tester cheerio --dev 
```

and add a start/stop SMTP server 

```ts
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.

<a name="testing-login-form"></a>
## 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](https://happyreact.com/) texts are as follows. 

```ts
// 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.

```ts
// 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!**

<a name="wrapping-up"></a>
## 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.