Testing signup flow with activation by email

dsheiko

Dmitry Sheiko

Posted on July 9, 2019

Testing signup flow with activation by email

Functional testing isn’t something new. We all do it, less or more, with different tools and approaches. However when it comes to flows, where transactional emails (signup confirmations, password resets, purchase notifications and others) involved that may still bring questions. For example, we instruct the testing tool to navigate to the registration page, fill out the form and press the submit button. The web-application sends email with activation link. So we need the testing tool to read the email message, parse it and navigate the link. The first challenge is to connect the testing tool with the mail server. It’s not a big deal if your mail server exposes a REST API. Otherwise you need to consider a specialized service such as Sendgrid, Mailgun, Email Yak, Postmark.

Mail Server API

To say truth, it can be also achieved with Restmail.net. It's free, it's requires no registration, it allows to create dynamically inboxes, it exposes a REST API to read received emails. However all the sent messages are public. The REST API is dead simple:

    GET /mail/<user>
    DELETE /mail/<user>

So you can send an email to, let’s say, joe1@restmail.net and receive its contents with GET /mail/joe1. Naturally you can delete it afterwards with DELETE /mail/joe1

Polling Inbox

Well, but how we can use it in test cases? We need a function, which polls mail server API for inbox updates. The function shall find the email messages sent during the testing session, parse the action link and return it for testing methods. I suggest the following implementation:

     function pollForValue({ url, interval, timeout, parserFn, parserPayload = {}, requestFn = null }) {
        const request = requestFn ? requestFn : async ( url ) => {
          const rsp = await fetch( url );
          if ( rsp.status < 200 || rsp.status >= 300  ) {
            return {};
          }
          return await rsp.json();
        };

        return new Promise(( resolve, reject ) => {
          const startTime = Date.now();
          pollForValue.attempts = 0;

          async function attempt() {
            if ( Date.now() - startTime > timeout ) {
              return reject( new Error( `Polling: Exceeded timeout of ${ timeout }ms` ) );
            }
            const value = parserFn( await request( url ), parserPayload );
            pollForValue.attempts ++;
            if ( !value ) {
              return setTimeout( attempt, interval );
            }
            resolve( value );
          }
          attempt();

        });
     }

As you call the function it polls a given URL until message(s) received or timeout. It returns the parsed value (e.g. activation link) and accepts an options object with the following properties:

  • url – REST API resource. Here http://restmail.net/mail/
  • interval – interval between polling requests in ms
  • timeout – maximal allowed time span for the function to loop in ms
  • parserFn – callback that receives the REST API response and parses it for the desired value. The pollForValue function will poll the provided URL until parserFn returns a truthy value (or timeout)
  • requestFn – (OPTIONAL) a callback to replace default window.fetch
  • parserPayload - (OPTIONAL) extra payload for parserFn callback

Test Application

So we have mail server API and polling function. Next, we going to try it in diverse testing tools. For that we will need a real world example. Imagine, we are testing ACME forum application built with NodeBB. Our goal is to fill out the registration form (http://localhost:4567/register) and submit it:

ACME forum registration form

It brings us to the next page where we have tick on the GDPR checkboxes.

ACME forum GDPR form

As the form submitted the application sends confirmation email. Here we go with pollForValue function. We call it to poll the REST API until the email message arrived. The function will use the following parsing logic to get the activation link from NodeBB default email template:

    function parseActivationLink( text ) {
        const re = /(http\:[^\"]+4567\/con[^\"]+)/g,
              res = text.match( re );
        return res ? res[ 0 ].replace( "=\r\n", "" ) : null;
    }

Thus we obtain the activation URL, which we follow to complete the registration.

Testing with Selenium WebDriver

Selenium WebDriver is probably the most popular testing tool. Not the most effortless, I would say, but still, it’s definetelly one you’ve heard about. So we setup the dev environment for Node.js and write our test case. Untill the point where we make ACME forum to send activation email everything is certain:

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

    (async function main() {
      const driver = await new Builder().forBrowser("chrome").build(),
            USER = "ctest1";
      try {
        await driver.get( "http://localhost:4567/register" );

        await driver.findElement( By.id("email" ) )
          .sendKeys( `${ USER }@restmail.net`, Key.RETURN );
        await driver.findElement( By.id("username" ) )
          .sendKeys( USER , Key.RETURN );
        await driver.findElement( By.id("password" ) )
          .sendKeys( `Password1234`, Key.RETURN );
        await driver.findElement( By.id("password-confirm" ) )
          .sendKeys( `Password1234`, Key.RETURN );

        await driver.findElement( By.id("gdpr_agree_email" ) )
          .click();

        await driver.findElement( By.id("gdpr_agree_data" ) )
          .click();

        await driver.findElement( By.css("#content form button" ) )
          .click();

        //…  

      } catch ( e ) {
        console.log( e );
      } finally {
        await driver.quit();
      }
    })();

We populate the first form with test values, where email shall be in restmail.net domain. As we are done with the last field, the form gets automatically submitted. Then we tick on the checkboxes and click on submit button. Now let’s do the polling. So we put at the beginning of the script a module to simplify HTTP(S) requests:

    const fetch = require( "node-fetch" );

Next we place our pollForValue and parseActivationLink functions. Now we can extend test steps with:

       const activationLink = await pollForValue({ url: `http://restmail.net/mail/${ USER }`, 
          interval: 1000, 
          timeout: 600000,  
          parserFn: ( messages ) => {
              if ( !messages ) {
                return null;
              }
               const sentAt = new Date( Date.now() - 1000 ),
                     unseen = messages.find( msg => new Date( msg.receivedAt ) > new Date( sentAt ) );                
              return parseActivationLink( messages[0].html );
          }
        });

        console.log( "Activation link:", activationLink );

        await driver.get( activationLink );

Thus after submitting the second form we make the script polling for newly sent email message. When it received we parse the message body for the activation link. Bingo! We get the link and we make the driver navigating to it.

Selenium Webdriver results

Testing with Cypress

Recently is gaining momentum a tool called Cypress. I do like it personally for the test debugging. Without polling for mail messages the test script may look like that:

    const USER = "ctest1";
    describe("User registration flow", () => {
        it( "registers user", ( done ) => {
          cy.visit( "http://localhost:4567/register" );

          cy.get( "#email" ).type( `${ USER }@restmail.net` );
          cy.get( "#username" ).type( USER );
          cy.get( "#password" ).type( "Password1234" );
          cy.get( "#password-confirm" ).type( "Password1234" );
          cy.get( "#register" ).click();
          cy.wait( 1000 );
          cy.get("#gdpr_agree_email").click();
          cy.get("#gdpr_agree_data").click();
          cy.get("#content form button.btn-primary").click();

          //...
          done();

        })
      })

Similar to what we did with Selenium we extend the script with pollForValue and parseActivationLink functions. However this time instead of using node-fetch we rather go with built-in cy.request function. That’s where pollForValue’s requestFn option jumps in action:

          pollForValue({ url: `http://restmail.net/mail/${ USER }`, 
                interval: 1000, 
                timeout: 600000,  
                parserFn: ( messages ) => {
                    if ( !messages ) {
                        return null;
                      }
                       const sentAt = new Date( Date.now() - 1000 ),
                             unseen = messages.find( msg => new Date( msg.receivedAt ) > new Date( sentAt ) );                
                      return parseActivationLink( messages[0].html );
                },
                requestFn: ( url ) => {
                    return new Promise(( resolve ) => {
                        cy.request( url )
                            .then( (response) => {
                                resolve( response.body );
                            } );
                    });
                }
          }).then(( link ) => { 
            activationLink = link;
            console.log( "Activation link:", activationLink );
            done(); 
          });

So it’s just left to declare activationLink

    let activationLink;

and visit the activation link

    it( "follows the activation link", () => {
       cy.visit( activationLink );
    })

Cypress results

Testing with Puppetry

We’ve just examined how we can do the trick with script-based testing tools. Let’s take now a code-less one – Puppetry. With this tool we don’t script, but use GUI to fulfill our test specifications. Alternatively we record user behavior. Anyways we end up with a test suite, which contains a table of the target elements:

Test target elements

And the test case:

Test case

In this example I used template variables. First I defined a new variable TEST_USER_ALIAS that resolves with every test run in ctest1, ctest2 and so on. Then I referred to the variable when typing into email field. Besides I applied template expression {{ faker( "internet.userName", "en" ) }} to generate real-world-like user name. And I also addressed and few environment-dependent variables. Other then that I don’t think you may have difficulties to read the test steps.

Environment-dependent variables

Now we extend the test for mail server polling. So we add the command corresponding to the earlier described function pollForValue:

Polling for newly sent mail message

We give in options the retrieving and parsing function similar to one we used with Selenium and Cypress. That will resolve into new template variable ACTIVATION_LINK, which we use to visit the page:

Test case to visit activation page

There it is. We’ve got the results:

Test results in browser

Test report

Recap

Testing user flows that involve transactional emails in a nutshell is not that complex as it’s may be seen. You just need an API to access the mail server and polling method (for example the function from this article). You can achieve with different testing tools, likely with one you’re currently working with.

💖 💪 🙅 🚩
dsheiko
Dmitry Sheiko

Posted on July 9, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related