31. Dezember 2021

E2E-Testing von Netlify Forms mit Cypress und Github Actions

Statische Webseiten bieten oftmals wenig Interaktion mit dem Nutzer. Auch bei unseren Kunden dient der Internetauftritt hauptsächlich zur Vorstellung des eigenen Unternehmens, der Personen oder Dienste.
Dennoch gibt es ein Element, das es auf jeder (gewerblichen) Webseite geben muss: das Kontaktformular

In den meisten Fällen setzt man dabei auf einen (externen) Service, der die Kundenanfrage verarbeitet und das Formular per Mail an den Webseitenbetreiber weiterleitet. Wir bei Simpel Web nutzen hierfür Netlify Forms, da es einfach zu integrieren ist, zuverlässig funktioniert und einige Sicherheitsfeatures automatisch mitbringt.

Da das Kontaktformular oftmals die einzige Möglichkeit für eine Kontaktaufnahme ist oder in vielen Fällen die präferierte Methode vieler Nutzer ist, muss man gewährleisten, dass alles reibungslos funktioniert. Doch wie kann man das am Besten sicherstellen? Es gibt viele Gründe für ein nicht mehr funktionierendes Kontaktformular: eine sich geänderte API des Serviecanbieters, Dependency Updates genutzter Frameworks, usw.
Im schlimmsten Fall erhält der Nutzer sogar eine “Nachricht wurde erfolgreich verschickt”-Meldung, obwohl niemals eine E-Mail ankam.

In diesem Blog Post zeigen wir, wie man automatisiert ein (Netlify) Formular testen kann. Hierfür nutzen wir Cypress, Github Actions auf einer Gatsby basierten Webseite. Wer keine Lust hat sich alle Schritte durchzulesen, kann direkt hier auf das dazugehörige Repository gehen.
Eine Live Version findet man hier: Live Demo

Setup eines Netlify Formulars mit react-hook-form

Für das Styling des Formulars verwenden wir Tailwind, auf das jetzt nicht näher eingegangen wird.
Erstellen wir zunächst eine Komponente, die ein Netlify Formular erhält:

// 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;

data-netlify sorgt dafür, dass Netlify beim Prozessieren des HTMLs das Formular als Netlify Formular erkennt und entsprechend registriert. netlify-honepot ist ein Sicherheitsfeature von Netlify um Spam zu verhindern. Dabei referenziert man ein verstecktes Input-Field mit entsprechendem Namen. Formularanfragen, die dieses Feld beinhalten, werden dann automatisch von Netlify als Spam erkannt und ignoriert.

Für das Formularhandling verwenden wir in unserem Beispiel react-hook-form.

npm install react-hook-form

Zunächst erzeugen wir ein neues Formular in der Komponente:

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


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

Ergänzen wir nun unser Formular mit ein paar Feldern unter Verwendung von react-hook-form:

// 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>

Wenn wir die erstellte Formular-Komponente nun auf unserer Startseite integrieren, erhalten wir folgendes Ergebnis:

Gatsby mit react-hook-form & Tailwind

Im nächsten Schritt bestimmen wir das Verhalten beim Klicken auf “Senden”. Dazu ergänzen wir das <form> Tag um folgende Zeile

onSubmit={handleSubmit(sendForm)}

und die Komponente selber um folgende Funktionen:

// 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));
  };

Somit haben wir ein einfaches Formular geschrieben, das bereits mit Netlify Form funktioniert.

Verwendung von Cypress für E2E Tests

Um in unserer CI Pipeline unsere Live-Seite testen zu können, verwenden wir Cypress:

npm install cypress --save-dev

Mit dem Befehl npx cypress open initialisiert Cypress automatisch das Projekt mit der benötigten Konfiguration und Ordnerstruktur. Um keine CORS-Probleme zu bekommen und verifizieren können, dass ein Netzwerk-Request abgesetzt wurde beim Verschicken des Formulars, nutzen wir folgende Cypress Config:

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

Schreiben wir nun unseren Test, der bei einem CI Durchlauf automatisch die Funktionalität des Formulars testen soll.

// 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."
        );
      });
  });
});

Im Erfolgsfall loggen wir eine Nachricht in der Entwicklerkonsole des Browsers (siehe Form-Komponente). Außerdem wollen den ausgehenden Netzwerk-Request überprüfen. Für beides erstellen wir Spys:

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


Danach wird das Formular ausgefüllt und ein “Senden” simuliert:

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();

Im letzten Schritt wird überprüft, ob der richtige Request rausgegangen ist und dieser erfolgreich war:

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."
        );
      });

Insgesamt ergibt sich mit ein wenig Refactoring folgender Test:

/// <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."
        );
      });
  });
});

Verwenden von Github Actions für das automatisierte Testen des Formulars

Mit dem im vorherigen Abschnitt beschriebenen Test, haben wir die Möglichkeit zu überprüfen, ob zum Einen das Formular abgeschickt wird und es zum Anderen auch erfolgreich bei Netlify ankommt.
Um das automatisiert prüfen zu können, nutzen wir Github Actions und definieren uns einen Workflow.

Dazu legen wir im Root unseres Repositories einen .github Ordner an, in dem wir wiederum einen workflows Ordner anlegen. In diesem erstellen wir unseren Workflow namens form-test.yaml und erhalten folgende Struktur

Ordnerstruktur für Github Workflows

mit folgendem 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/

Dieser Workflow läuft immer automatisch am ersten Tag eines Monats. Schauen wir uns den Schritt “Cypress run” genauer an:

  • working-directory: Ordner, in dem sich cypress.json und der Cypress Ordner selbst befinden
  • spec: Pfad zur Testdatei, die ausgeführt werden soll, ausgehend vom zuvor festgelegten Working-Directory
  • CYPRESS_BASE_URL: Umgebungsvariable, die von Cypress automatisch genutzt wird, um den basePath zu setzen.

Ergebnis

Wartet man nun den ersten Tag eines Monats ab, ergibt sich auf Github folgendes Bild:

Github Workflow

Außerdem sollte man diesen Testlauf auch im Admin-Panel von Netlify wiederfinden:

Netlify Form Übersicht

Aus Demonstrationsgründen ist der im Repository hinterlegte Workflow nicht scheduled, sondern kann manuell getriggert werden, damit nicht unnötigerweise jeden Monat Formularrequests an Netlify geschickt werden.
Außerdem ist bei der deployten Seite das tatsächliche Absenden des Requests an Netlify abgestellt, um einen Missbrauch unserer Demo zu verhindern.

Wir hoffen euch hat dieser Blog Beitrag gefallen und freuen uns auch gerne über Anregungen oder Verbesserungsvorschläge

Zur Startseite