31. Dezember 2021

E2E-Testing of Netlify Forms with Cypress and Github Actions

Static web pages are delivered to the user’s web browser exactly as stored – meaning they show the same information for all users. Consequently, static web pages are suitable for content that never or rarely needs to be updated and where individual user interaction does not play a big role. This also counts for most of our customers as their main purpose for a website is a self-presentation of their business & services in the world wide web. Nonetheless, there is one element that needs to be part of every (business) website: a contact form!

Usually, you choose an external service that is able to handle incoming customer form requests and forwards them to the website owner. We at Simpel Web are using Netlify Forms for this since it is easy to integrate, it is reliable and it comes with security features for spam protection.

The contact form is often the only channel for contact on a website or it is at least the preferred method. Thus, it is really important that it is working 24 hours a day 7 days a week. But how can we guarantee this? There are many reasons why a contact form is malfunctioning: a changed API or a bug on the service provider’s side, an untested dependency update withing your repository that affects the submission of the form, etc.

In the worst case the user even receives a “thank for your message” notification even though the message has been never sent.

In this blog post we want to demonstrate how to automize an end-to-end testing of a Netlify form. The tools we use for this are Cypress and Github Actions and the frontend is based on Gatsby. If you do not want to go through the individual steps, you can skip the rest of this post have look at the Github repository. You can even check our live demo

Setup a Netlify Form with react-hook-form

For styling we use Tailwind which we won’t explain further in this post. Let’s create a component that contains a Netlify Form:

// form.js
const FormComponent = () => {

  return (
    <form
      name="contact"
      method="POST"
      data-netlify="true"
      netlify-honeypot="bot-field"
      className="w-[600px]"
    > 
      <p class="hidden">
        <label>
          Don’t fill this out if you’re human: <input name="bot-field" />
        </label>
      </p>
    </form>
  )
}

export default FormComponent;

Make sure to include data-netlify in your template to ensure that Netlify registers this form during the processing of the HTML. With netlify-honeypot you can define a a hidden input field that is used for spam protection and recognized by Netlify. All submissions that contain this field are automatically marked as spam and ignored.

To handle the form we can install react-hook-form:

npm install react-hook-form

In the next step we create a new form within the component:

// form.js
import { useForm } from "react-hook-form";


const FormComponent = () => {
  const { register, handleSubmit } = useForm();
  ...
}

Now, we can extend our existing form with a few other fields:

// new content of the <form> tag
<div className="mb-6">
    <label
        for="firstName"
        className="block mb-2 text-sm font-medium text-white"
    >
        First name
    </label>
    <input
        {...register("firstName")}
        placeholder="First name"
        className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"

    />
</div>

<div className="mb-6">
    <label
        for="lastName"
        className="block mb-2 text-sm font-medium text-white"
    >
        Last name
    </label>
    <input
        {...register("lastName")}
        placeholder="Last name"
        className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"

    />
</div>

<div className="mb-6">
    <label
        for="category"
        className="block mb-2 text-sm font-medium text-white"
    >
        Category
    </label>
    <select
        {...register("category")}
        className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
    >
        <option value="">Select...</option>
        <option value="A">Category A</option>
        <option value="B">Category B</option>
    </select>
</div>

This gives us the following result when running the web page locally:

Gatsby with react-hook-form and Tailwind

In the next step, we define the behavior when the user clicks on “submit” by extending the <form> tag with the following line

onSubmit={handleSubmit(sendForm)}

and the the component itself with the following methods

// map json to form url encoded version
const encode = (data) => {
  return Object.keys(data)
    .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
    .join("&");
}

// send form request to Netlify
const sendForm = (formData, event) => {

    event.preventDefault();
    fetch("/", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: encode({
        "form-name": "contact",
        ...formData,
      }),
    })
      .then((response) => {
        if (response.ok) {
          console.log("Message successfully sent.");
        } else {
          console.error("Oops. Something went wrong.");
        }
      })
      .catch((e) => console.error(e));
  };

Hence, we implemented a simple form that already works with Netlify.

Cypress for E2E-tests

Let’s install Cypress to make it possible to test our website within our CI pipeline:

npm install cypress --save-dev

The command npx cypress open automatically initializes the project with the necessary Cypress configuration and folder structure. Next, we need to modify the Cypress configuration in order to allow CORS and to check if a network request has been sent successfully:

// cypress.json
{
  "baseUrl": "http://localhost:8000",
  "chromeWebSecurity": false,
  "experimentalFetchPolyfill": true
}

Finally, we can write our first end-to-end test which ensures the functionality of our contact form:

// form.spec.js

/// <reference types="cypress" />

function fillWithValidData() {
  cy.get("[data-cy=firstName]").type("Simpel");
  cy.get("[data-cy=lastName]").type("Web");
  cy.get("[data-cy=category]").select("A");
}

function assertCorrectRequest(intercept) {
  expect(intercept.request.method).to.equal("POST");
  expect(intercept.request.headers).to.include({
    "content-type": "application/x-www-form-urlencoded",
  });
  expect(intercept.request.body).to.contain("form-name=contact");
}

describe("Form Test", () => {
  beforeEach(() => {
    // visit uses baseUrl from cypress.json and can be overwritten e.g. by setting CYPRESS_BASE_URL env
    cy.visit("/", {
      onBeforeLoad(win) {
        cy.spy(win.console, "log").as("consoleLog");
      },
    });
  });

  // Does only work on remote URL with a registered netlify form
  it("should successfully submit the contact form to netlify", () => {
    cy.intercept("POST", "/").as("postForm");

    fillWithValidData();

    cy.get("[data-cy=submit]").click();

    cy.wait("@postForm")
      .then((intercept) => {
        assertCorrectRequest(intercept);
      })
      .then(() => {
        cy.get("@consoleLog").should(
          "be.calledWith",
          "Message successfully sent."
        );
      });
  });
});

In case of success we are logging the message in our developer console of the browser. Besides, we want to check the outgoing network request. For both, we create spies:

cy.spy(win.console, "log").as("consoleLog"); 
cy.intercept("POST", "/").as("postForm");

Next, we fill out the form and simulate the “submit”:

cy.get("[data-cy=firstName]").type("Simpel");
cy.get("[data-cy=lastName]").type("Web");
cy.get("[data-cy=category]").select("A");


cy.get("[data-cy=submit]").click();

In a last step we check if the request has been sent successfully and if it contained the correct parameters:

cy.wait("@postForm")
      .then((intercept) => {
        expect(intercept.request.method).to.equal("POST");
        expect(intercept.request.headers).to.include({
          "content-type": "application/x-www-form-urlencoded",
        });
        expect(intercept.request.body).to.contain("form-name=contact");
       })
      .then(() => {
        cy.get("@consoleLog").should(
          "be.calledWith",
          "Message successfully sent."
        );
      });

The whole test setup looks with a bit of refactoring like this:

/// <reference types="cypress" />

function fillWithValidData() {
  cy.get("[data-cy=firstName]").type("Simpel");
  cy.get("[data-cy=lastName]").type("Web");
  cy.get("[data-cy=category]").select("A");
}

function assertCorrectRequest(intercept) {
  expect(intercept.request.method).to.equal("POST");
  expect(intercept.request.headers).to.include({
    "content-type": "application/x-www-form-urlencoded",
  });
  expect(intercept.request.body).to.contain("form-name=contact");
}

describe("Form Test", () => {
  beforeEach(() => {
    // visit uses baseUrl from cypress.json and can be overwritten e.g. by setting CYPRESS_BASE_URL env
    cy.visit("/", {
      onBeforeLoad(win) {
        cy.spy(win.console, "log").as("consoleLog");
      },
    });
  });

  // Does only work on remote URL with a registered netlify form
  it("should successfully submit the contact form to netlify", () => {
    cy.intercept("POST", "/").as("postForm");

    fillWithValidData();

    cy.get("[data-cy=submit]").click();

    cy.wait("@postForm")
      .then((intercept) => {
        assertCorrectRequest(intercept);
      })
      .then(() => {
        cy.get("@consoleLog").should(
          "be.calledWith",
          "Message successfully sent."
        );
      });
  });
});

Github Actions for automated form testing

In the previous section we defined a complete end-to-end test of the form using Cypress. Next, we need to automize that by creating a new workflow with Github Actions.

First, we create a new folder in the root of our repository called .github with another folder inside called workflows. This folder contains our new workflow form-text.yaml leading to the following new folder structure

folder structure for Github workflows

with the following workflow:

name: Test Netlify Form to get notified when form is not working

on:
  schedule:
   - cron: "0 0 1 * *" # first of each month


jobs:
  cypress-run:
    runs-on: ubuntu-latest
    timeout-minutes: 25

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Use Node.js
        uses: actions/setup-node@v2
        with:
          node-version: "16.x"

      - name: Cypress run
        uses: cypress-io/github-action@v2
        with:
          browser: chrome
          working-directory: netlify-forms-e2e-testing
          spec: cypress/integration/form.spec.js
        env:
          CYPRESS_BASE_URL: https://gifted-lumiere-295ebc.netlify.app/

This workflow is executed on the first day of every month. Let’s take a closer look at the step “Cypress run”:

  • working-directory: folder where cypress.json and the Cypress folder itself are located
  • spec: path to the test – relatively to the defined working-directory
  • CYPRESS_BASE_URL: environment variable that is used by Cypress to define the basePath

Result

This was the result on the first day of the month after we created our new workflow with the related E2E-test:

Github workflow result

Additionally, you can verify a succesfully submitted form in your Netlify admin panel:

Netlify Forms overview

For demonstration purposes the created demo repository for this blog post does not contain a scheduled workflow but one that can be triggered manually to prevent unnecessary form submission every month. Further, the submission of the form in this live demo is turned off to prevent abuse.

We hope you liked our blog post and we appreciate any comments, enhancement requests, and improvement suggestions.

Back to Home