Zum Inhalt springen

Zum Testen und Weiterentwickeln von Extensions, aber auch zum Ausprobieren von Core-Funktionen, habe ich mir spezielle TYPO3-Installationen erstellt. Sie enthalten beispielsweise Seitenbäume zum Form Framework, in denen ich Beispiel-Formulare einrichten und absenden kann.

Das Frontend-Testing-Framework Cypress kreuzt aktuell ständig meinen Weg, zuletzt als Vortrag auf dem Web Camp Venlo. Daher habe ich jetzt die Chance ergriffen, das Tool in meinen Testumgebungen erstmalig einzurichten.

Dieser Artikel beschreibt meine Testumgebungen, die erforderlichen Tests für meine Extension "content_slug" sowie meine Konfiguration von Cypress.

Disclaimer zu Beginn: Cypress nutze ich hier für End-to-End-Tests (E2E), die eine vollständige Testumgebung zwingend voraussetzen. TYPO3-Extensions werden üblicherweise auf anderem Wege automatisiert getestet.

TYPO3-Testinstanzen

  • Lokale, Composer-basierte Installationen
  • Zu prüfende Extensions werden nicht im Git-Repository der TYPO3-Installation versioniert, sondern (meist per Symlink) aus einem lokalen Projekt­verzeichnis geladen
    • Vorteil: Änderungen an einer Extension sind sofort in mehreren TYPO3-Installationen verfügbar
  • Für jeden Einsatzzweck (Formular-Extensions, Routing-Features, …) gibt es einen eigenen Seitenbaum
  • Beim Release einer neuen TYPO3-Major-Version wird die letzte Instanz dupliziert und aktualisiert
  • So sind (weitgehend) identische Installationen von TYPO3 v7 bis v12 entstanden

Erste Cypress-Aufgabe: Automatisierte Prüfung generierter Sprungmarken

Meine TYPO3-Extension "content_slug" ermöglicht die Generierung menschen­lesbarer Sprungmarken (URL-Fragmente) für Inhaltselemente.

Ob und wie die Fragmente angewendet werden, ist aber von einigen Faktoren abhängig. Daher habe ich einen Testseitenbaum mit verschiedenen Konstellationen erstellt.

Bislang habe ich hier eine manuelle Funktions- und Sichtprüfung vorgenommen. Mit etwas CSS habe ich den Inhaltsbereich begrenzt (zur besseren Prüfung der Sprungmarken-Funktion) und lasse mir die href-Attribute von Links anzeigen.

Was muss getestet werden?

  • Fragmente in Menü-Inhaltselementen (DataProcessor)
  • Anpassen von Verlinkungen im RTE und inputLink-Feldern (Hook bzw. PSR-14-Event)
  • Ersetzen einiger Sonderzeichen im Fragment (Sanitizer)
  • Verhalten bei mehrsprachigen Websites
  • Verhalten bei verschiedenen Konstellationen, z.B. wenn das Fragment fehlt oder der Header des Inhaltselements nicht gerendert wird
  • TypoScript zur individuellen Konfiguration des Fragment-Identifiers

In allen Fällen kann ich im Frontend prüfen, ob das gerenderte Ergebnis in Links und id-Attributen mit dem von mir erwarteten Ergebnis übereinstimmt. Das macht das Testing in diesem Fall sehr einfach.

Der Testseitenbaum ist zweisprachig angelegt. Über ein zweites PAGE-Objekt überschreibe ich zudem die Konfiguration der Fragmente. Dadurch kann ich gleichzeitig den Default der Extension und die Möglichkeit der Individualisierung prüfen.

Ingesamt gibt es daher vier Tests per Unterseite:

  1. Englische Originalsprache mit Standard-Konfiguration
  2. Deutsche Übersetzung mit Standard-Konfiguration
  3. Englische Originalsprache mit individualisierter Konfiguration
  4. Deutsche Übersetzung mit individualisierter Konfiguration

TypoScript-Setup

Als statische TypoScript-Dateien werden EXT:fluid_styled_content sowie EXT:content_slug eingebunden.

Im Setup-Feld ist auf einfachem Wege etwas CSS ergänzt. Darunter folgt das zweite PAGE-Objekt, für das ich dann die Plugin-Konfiguration etwas anpasse. Der Präfix nutzt weiterhin die UID des Inhaltselements, beginnt nun aber mit dem String "custom". Falls ein Wert im Subheader-Feld gepflegt ist, wird dieser als Suffix angehängt. Der so zusammengefügte Wert wird am Ende noch einmal durch den Sanitizer geschickt. Dabei werden z.B. nicht unterstützte Leer- und Sonderzeichen im Subheader ersetzt.

page = PAGE
page.10 < styles.content.get
page.cssInline {
    10 = TEXT
    10.value = body { max-width: 400px; padding-bottom: 80rem; font-family: 'Source Sans Pro'; background: ghostwhite; margin: 3rem; }
  	20 = TEXT
    20.value = .frame { margin-bottom: 6rem; background: white; padding: 1rem; }
    30 = TEXT
    30.value = a[href]::after { content: " (" attr(href) ") "; color: grey; display: flex; font-size: .8em; }
}


// Page object with custom fragments
pageWithCustomFragment < page
pageWithCustomFragment {
  typeNum = 1
}

[getTSFE().type == 1]
    // Test custom fragment prefix:
    plugin.tx_contentslug.urlFragmentPrefix = TEXT
    plugin.tx_contentslug.urlFragmentPrefix {
        field = uid
        stdWrap.noTrimWrap = |custom|-|
        if.isTrue = {$plugin.tx_contentslug.settings.renderPrefix}
    }
    
    // Test optional suffix with value from second field:
    plugin.tx_contentslug.urlFragmentSuffix = TEXT
    plugin.tx_contentslug.urlFragmentSuffix {
        field = subheader
        if.isTrue.field = subheader
        stdWrap.noTrimWrap = |-||
    }
    
    // Sanitize fragment again because of subheader value:
    lib.contentElement.variables.fragmentIdentifier {
        stdWrap.postUserFunc = Sebkln\ContentSlug\Evaluation\FragmentEvaluation->sanitizeFragment
    }
[end]

Da ich bereits einen Route Enhancer für Seiten konfiguriert hatte, musste der typeNum "1" dort ergänzt werden:

routeEnhancers:
  PageTypeSuffix:
    type: PageType
    default: /
    index: ''
    map:
      /: 0
      withCustomFragment.html: 1

Aufgerufen wird das zweite PAGE-Objekt also nicht mit dem GET-Parameter type=1, sondern mit dem gewählten Suffix withCustomFragment.html

Cypress-Konfiguration

Da dieser Beitrag kein Cypress-Tutorial sein soll, verzichte ich auf die generelle Vorstellung des Tools. Die offizielle Dokumentation mit "Getting started"-Guides und Referenz ist da sehr hilfreich.

Aber: Ich war überrascht, wie schnell Cypress einsatzbereit war! Installiert habe ich das Tool mit Yarn, danach konnte ich Cypress starten und in der GUI "E2E Testing" auswählen. Dadurch wurden mir alle wichtigen Konfigurationsdateien automatisch im Projektverzeichnis angelegt. Beim ersten Start von Google Chrome (per Cypress) habe mir auch die Beispiel-Tests ("Specs") generieren lassen. So einfach kann's gehen!

Verzeichnis-Struktur von Cypress

Im Verzeichnis "e2e" werden die Spec-Dateien mit den eigentlichen Tests abgelegt.

Die generierten Beispiel-Specs habe ich in ein neues Unterverzeichnis verschoben, um sie von den produktiv genutzten Tests zu trennen.

Weitere Unterverzeichnisse trennen die Spec-Dateien und Fixtures (Erläuterung unten) der zu prüfenden Projekte.

cypress.config.js

Da alle Seitenbäume der Installation unter derselben Domain (+ individuellen URL-Pfad) erreichbar sind, setze ich die baseURL in der Cypress-Konfiguration.

Im specPattern ergänze ich den Pfad production/, um die Beispiel-Specs auszuschließen.

Zudem benötige ich aktuell keine Videos für aufgetretene Fehler, daher ist das Feature vorerst deaktiviert.

const { defineConfig } = require("cypress");

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://t3zwoelf.test',
    specPattern: "cypress/e2e/production/**/*.cy.{js,jsx,ts,tsx}",
    video: false,
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
});

Yarn-Skripte

In der package.json habe ich zwei Skripte ergänzt, um Cypress-Tests auszuführen:

"scripts": {
    "cy:run:content-slug": "cypress run --spec 'cypress/e2e/production/content-slug/**/*'",
    "cy:open:content-slug": "cypress open --e2e --browser chrome"
}

Der erste Befehl yarn cy:run:content-slug startet alle Tests zur Extension im Headless Browser und liefert die Testergebnisse in der Kommandozeile zurück.
Der zweite Befehl öffnet direkt Google Chrome, ohne den Umweg über das Cypress-Launchpad. Hier kann ich einzelne Tests starten und debuggen – auch über die Browserkonsole, die von Cypress mit Informationen gefüttert wird.

Erstellen der benötigten Tests

Die Inhalte im Test-Seitenbaum sind von mir fest definiert. Ein per UID auswählbares Inhaltselement muss daher immer exakt dieselben Links (oder eine id) besitzen.

Zwei Dinge müssen also geprüft werden:

  1. Korrekt generierte id-Attribute an den Überschriften der Inhaltselemente
  2. Korrekt generierte href-Attribute bei Verlinkungen jeder Art

In beiden Fällen genügt ein simpler Vergleich des Frontends mit einem im Test definierten String.

Zur Erinnerung: jeder Test wird jeweils viermal ausgeführt (zwei Sprachen + zwei TypoScript-Konfigurationen).

1. id-Attribute an Überschriften

Für jede Unterseite habe ich ein separates Spec-File erstellt.

Auf jeder Unterseite werden die IDs der dort vorhandenen Inhaltselemente zzgl. der Überschrift (#c48 h2) als Selektor verwendet.

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

describe('Generated fragment identifiers for header types', () => {
  it('should exactly match in English default language, using default TypoScript configuration', () => {
    cy.visit('/content_slug/all-header-types/')
    cy.get('#c48 h2').should('have.attr', 'id', 'c48-heading-with-default-layout')
    cy.get('#c52 h1').should('have.attr', 'id', 'c52-heading-with-h1-layout')
    cy.get('#c54 h2').should('have.attr', 'id', 'c54-heading-with-h2-layout')
    cy.get('#c56 h3').should('have.attr', 'id', 'c56-heading-with-h3-layout')
    cy.get('#c58 h4').should('have.attr', 'id', 'c58-heading-with-h4-layout')
    cy.get('#c60 h5').should('have.attr', 'id', 'c60-heading-with-h5-layout')
    cy.get('#c62').should('not.contain.html', '<header>')
  })

  it('should exactly match in German translation, using default TypoScript configuration', () => {
    cy.visit('/content_slug/de/alle-ueberschrift-typen/')
    cy.get('#c48 h2').should('have.attr', 'id', 'c48-ueberschrift-mit-standard-layout')
    cy.get('#c52 h1').should('have.attr', 'id', 'c52-ueberschrift-mit-h1-layout')
    cy.get('#c54 h2').should('have.attr', 'id', 'c54-ueberschrift-mit-h2-layout')
    cy.get('#c56 h3').should('have.attr', 'id', 'c56-ueberschrift-mit-h3-layout')
    cy.get('#c58 h4').should('have.attr', 'id', 'c58-ueberschrift-mit-h4-layout')
    cy.get('#c60 h5').should('have.attr', 'id', 'c60-ueberschrift-mit-h5-layout')
    cy.get('#c62').should('not.contain.html', '<header>')
  })

  it('should exactly match in English default language, using custom TypoScript configuration', () => {
    cy.visit('/content_slug/all-header-types/withCustomFragment.html')
    
    // etc.
  })

  it('should exactly match in German translation, using custom TypoScript configuration', () => {
    cy.visit('/content_slug/de/alle-ueberschrift-typen/withCustomFragment.html')
    
    // etc.
  })
})

2. href-Attribute aller Links

Für die Prüfung der Links habe ich Fixtures verwendet. Das sind Dateien im JSON-Format, mit denen sich – grob gesagt – die Testdaten von den Testskripten trennen lassen. Die Testdaten (z.B. Logindaten eines Testnutzers) könnten dann statisch angelegt oder auch dynamisch erzeugt werden.

In meinem Fall brauchte ich die als Referenz genutzten Links so nur einmal definieren, um sie dann in mehreren Testskripten verwenden zu können.

Beispiel: Inhalt der Fixture-Datei mit Referenz-Links für die Header-Typen in TYPO3. Enthalten sind vier Arrays für die vorgenannten Testszenarien.

{
	"defaultEN": [
		"/content_slug/all-header-types/#c48-heading-with-default-layout",
		"/content_slug/all-header-types/#c52-heading-with-h1-layout",
		"/content_slug/all-header-types/#c54-heading-with-h2-layout",
		"/content_slug/all-header-types/#c56-heading-with-h3-layout",
		"/content_slug/all-header-types/#c58-heading-with-h4-layout",
		"/content_slug/all-header-types/#c60-heading-with-h5-layout",
		"/content_slug/all-header-types/#c62"
	],
	"defaultDE": [
		"/content_slug/de/alle-ueberschrift-typen/#c48-ueberschrift-mit-standard-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c52-ueberschrift-mit-h1-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c54-ueberschrift-mit-h2-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c56-ueberschrift-mit-h3-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c58-ueberschrift-mit-h4-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c60-ueberschrift-mit-h5-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c62"
	],
	"customEN": [
		"/content_slug/all-header-types/#custom48-heading-with-default-layout",
		"/content_slug/all-header-types/#custom52-heading-with-h1-layout",
		"/content_slug/all-header-types/#custom54-heading-with-h2-layout",
		"/content_slug/all-header-types/#custom56-heading-with-h3-layout",
		"/content_slug/all-header-types/#custom58-heading-with-h4-layout",
		"/content_slug/all-header-types/#custom60-heading-with-h5-layout",
		"/content_slug/all-header-types/#c62"
	],
	"customDE": [
		"/content_slug/de/alle-ueberschrift-typen/#custom48-ueberschrift-mit-standard-layout",
		"/content_slug/de/alle-ueberschrift-typen/#custom52-ueberschrift-mit-h1-layout",
		"/content_slug/de/alle-ueberschrift-typen/#custom54-ueberschrift-mit-h2-layout",
		"/content_slug/de/alle-ueberschrift-typen/#custom56-ueberschrift-mit-h3-layout",
		"/content_slug/de/alle-ueberschrift-typen/#custom58-ueberschrift-mit-h4-layout",
		"/content_slug/de/alle-ueberschrift-typen/#custom60-ueberschrift-mit-h5-layout",
		"/content_slug/de/alle-ueberschrift-typen/#c62"
	]
}

Diese vier Arrays werden dann jeweils passend zur Seiten-Variante geladen:

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

describe('Links in content element of type Section Index', () => {
  const pages = [
    '/content_slug/',
    '/content_slug/de/',
    '/content_slug/withCustomFragment.html',
    '/content_slug/de/withCustomFragment.html'
  ];
  const jsonDataKeys = ['defaultEN', 'defaultDE', 'customEN', 'customDE'];

  jsonDataKeys.forEach((key, index) => {
    it(`should match on page ${pages[index]} using ${key} data`, () => {
      cy.fixture('content-slug/href-header-types.json').then((jsonData) => {
        const hrefValues = jsonData[key];
        cy.visit(pages[index]);
        cy.get('#c44 ul ul a').each(($element, index) => {
          cy.wrap($element).should('have.attr', 'href', hrefValues[index])
        })
      });
    });
  });
});

Die Prüfung des Menü-Inhaltselements "Section index of subpages from selected pages" (menu_section_pages) benötigt in meinem Projekt das komplexeste Testskript: Das Menü enthält alle Inhaltselemente von fünf Unterseiten, die jeweils in unsortierten Listen (<ul>) aufgeführt sind.

Zu jeder Unterseite gibt es eine Fixture-Datei mit den korrekten Referenzlinks. Wir müssen also zusätzlich über die fünf Fixture-Dateien iterieren und den Selektor der dazu passenden unsortierten Liste verwenden.

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

describe('Links in content element of type Section Index of subpages', () => {
  const pages = [
    '/content_slug/',
    '/content_slug/de/',
    '/content_slug/withCustomFragment.html',
    '/content_slug/de/withCustomFragment.html'
  ], jsonDataKeys = [
    'defaultEN',
    'defaultDE',
    'customEN',
    'customDE'
  ];

  const fixtures = [
    'content-slug/href-header-types.json',
    'content-slug/href-subpage.json',
    'content-slug/href-l10n-connected.json',
    'content-slug/href-l10n-free.json',
    'content-slug/href-dataprocessing.json'
  ], selectors = [
    '#c46 li:nth-child(1) ul a',
    '#c46 li:nth-child(2) ul a',
    '#c46 li:nth-child(3) ul a',
    '#c46 li:nth-child(4) ul a',
    '#c46 li:nth-child(5) ul a'
  ];

  // Iterate over each fixture (5, matching number of selectors):
  fixtures.forEach((fixture, fIndex) => {
    // Iterate over JSON data keys (4, matching the number of tested pages):
    jsonDataKeys.forEach((key, dataIndex) => {
      it(`should match on page ${pages[dataIndex]}, using ${key} data from fixture ${fixture}`, () => {
        cy.fixture(fixture).then((jsonData) => {
          const hrefValues = jsonData[key];
          const selector = selectors[fIndex];
          cy.visit(pages[dataIndex]);
          // Iterate over each link found in the unordered list,
          // compare it with the string stored in the JSON array:
          cy.get(selector).each(($linkElement, linkIndex) => {
            cy.wrap($linkElement).should('have.attr', 'href', hrefValues[linkIndex])
          })
        });
      });
    });
  });
});

Bei der Erstellung des Testskripts habe ich erstmalig auf ChatGPT zurückgegriffen. Dessen Ergebnis war tatsächlich sehr hilfreich, auch wenn das erzeugte Code-Beispiel noch angepasst werden musste. Vielleicht hätte ein anderer Eingabe-Prompt ein komplett funktionstüchtiges Code-Snippet zurückgeliefert. Auf alle Fälle ist ChatGPT ein spannendes Werkzeug, das zukünftig nicht nur uns Entwicklern einiges an Arbeit erleichtern wird.

Fazit

Mit den automatisierten Cypress-Tests kann ich zukünftig sicher sein, dass mir bei der Weiter­entwicklung der Extension keine Fehler im Frontend-Rendering durchgehen. Natürlich ist das Verhalten im TYPO3-Backend davon ausgeklammert. Cypress ersetzt keine Unit-Tests.
Die einmal erstellten Tests ließen sich schnell in die TYPO3 v11-Instanz überführen – die UIDs der Seiten und Inhalte sind dort ja identisch.

Für diesen ersten Einsatzzweck musste ich nur Strings vergleichen. Cypress kann aber wesentlich mehr als das. Im Job wird das Tool auch gerade zum Thema. In der nächsten Zeit werde ich daher viel von und mit Kollegen lernen können. Mit etwas Erfahrung sieht das Cypress-Setup in ein paar Monaten sicher anders aus.

Zur News-Übersicht