Skip to content

To test and develop extensions, but also to evaluate core functionality, I have created dedicated TYPO3 installations. They contain, for example, page trees for the Form Framework, in which I can set up and submit demo forms.

The frontend testing framework Cypress is currently crossing my path quite frequently, most recently as a session at Web Camp Venlo. As a result, I've now taken the chance to set up this tool in my test environments for the first time.

This article outlines my testing environments, the required tests for my extension "content_slug" as well as my initial configuration of Cypress.

Disclaimer at the beginning: I am using Cypress here for end-to-end (E2E) testing, which requires a fully functional test environment. Typically, TYPO3 extensions are tested in a different automated way.

TYPO3 testing instances

  • Locally hosted, Composer-based installations
  • Tested extensions are not versioned in the Git repository of the TYPO3 installation, but loaded (usually via symlink) from a local project directory
    • Advantage: Changes to an extension are immediately available in multiple TYPO3 installations
  • For each purpose (form extensions, routing features, …), a separate page tree exists
  • When a new TYPO3 major version is released, the latest instance is duplicated and upgraded
  • As a result, (largely) identical installations from TYPO3 v7 to v12 have been created

First job for Cypress: automated testing of generated URL fragments

My TYPO3 extension "content_slug" allows the creation of human-readable URL fragments for content elements.

However, if and how the fragments are applied depends on several factors. Therefore, I created a test page tree with different constellations.

So far I have done manual function and visual checks. With some CSS I limited the content area (to better check the jump link functionality) and and display the href attributes of links.

What needs to be tested?

  • URL fragments in menu content elements (DataProcessor)
  • Modifying links in RTE and inputLink fields (Hook or PSR-14 event)
  • Replacing some special characters in fragments (Sanitizer)
  • Behavior with multilingual websites
  • Behavior in different constellations, e.g. if the fragment is missing or the header of the content element is hidden
  • TypoScript for individual configuration of the fragment identifier

In all cases, I can check in the frontend if the rendered results in links and id attributes match the expected results. This makes testing very easy in this particular case.

The test page tree is created in two languages. Using a second PAGE object, I also override the configuration of the fragments. This allows me to test the extension defaults and the possibility of customization at the same time.

Altogether there are therefore four tests per subpage:

  1. English default language with the default configuration
  2. German translation with the default configuration
  3. English default language with individualized configuration
  4. German translation with individualized configuration

TypoScript setup

EXT:fluid_styled_content and EXT:content_slug are included as static TypoScript files.

In the setup field some CSS is added in a simple way. Below that follows the second PAGE object, for which I then modify the plugin configuration. The prefix still contains the UID of the content element, but now starts with the string "custom". Also, if a value is maintained in the subheader field, it will be appended as a suffix. The resulting value is then passed through the sanitizer again. In the process, e.g. unsupported spaces and special characters in the subheader are replaced.

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]

Since I had already configured a route enhancer for pages, the typeNum "1" had to be added there:

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

Consequently, the second PAGE object is not called with the GET parameter type=1, but with the chosen suffix withCustomFragment.html

Cypress configuration

Since this article is not intended to be a Cypress tutorial, it will not cover a general introduction to the tool. The official documentation with "Getting started" guides and reference is very helpful there.

But I was surprised how quickly Cypress was ready for use! I installed the tool with Yarn, after that I could start Cypress and select "E2E Testing" in the GUI. This automatically created all the important configuration files for me in the project directory. At the first start of Google Chrome (via Cypress) I also had the sample tests ("Specs") generated. It can be that easy!

Directory structure of cypress

The spec files with the actual tests are stored in the "e2e" directory.

I moved the generated sample specs to a new subdirectory to separate them from the real tests.

Further subdirectories separate the spec files and fixtures (explanation below) of the tested projects.

cypress.config.js

As all page trees of the installation are available under the same domain (+ individual url path), I set the baseURL in the Cypress configuration.

In the specPattern, I added the path production/ to exclude the example specs.

Also, I don't currently need videos for occurred errors, so this feature is disabled for now.

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 scripts

I added two scripts in the package.json to run Cypress tests:

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

The first command yarn cy:run:content-slug starts all tests related to the extension in the headless browser and prints the test results on the command line.
The second command opens Google Chrome directly, without the detour via the Cypress launchpad. From here I can start and debug individual tests – also via the browser console, which is supplied with information by Cypress.

Creating the required tests

The contents in the test page tree are permanently defined. Therefore, a content element selected by UID must always have exactly the same links (or an id).

We need to check two things:

  • Correctly generated id attributes at the content element headers.
  • Correctly generated href attributes at links of any type.

In both cases, a simple comparison of the frontend with a string defined in the test is sufficient.

Remember: each test is run four times (two languages + two TypoScript configurations).

1. id attribute on headers

I created a separate spec file for each subpage.

On each subpage, the IDs of the given content elements and their heading (#c48 h2) are used as selectors.

/// <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 attributes of all links

To test the links, I used fixtures. These are files in JSON format that can be used – roughly speaking – to separate the test data from the test scripts. The test data (e.g. login data of a test user) could then be created manually or generated dynamically.

The advantage in my case was that I only needed to define the reference links once to use them in multiple test scripts.

Example: Content of the fixture file with reference links for the header types in TYPO3. Included are four arrays for the afore-mentioned testing scenarios.

{
	"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:

These four arrays are then loaded to match the four page's variants:

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

Testing the menu content element "Section index of subpages from selected pages" (menu_section_pages) requires the most complex test script in my project: The menu contains all content elements from five subpages, each listed in unsorted lists (<ul>).

For each subpage, there is a fixture file with the correct reference links. Therefore, we have to additionally iterate over the five fixture files and then use the selector of the corresponding unsorted list.

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

When creating the test script, I referred to ChatGPT for the first time. Its result was indeed very helpful, even if the generated code snippet still had to be adjusted. Perhaps a different prompt would have returned a completely functional code snippet. In any case, ChatGPT is an exciting tool that will make work easier in the future, not only for us developers.

Conclusion

With the automated Cypress tests I can be certain not to miss any frontend rendering issues during the further development of the extension. Of course, the behavior in the TYPO3 backend is not covered by this. Cypress does not replace unit tests.
The created tests were quickly transferred to the TYPO3 v11 instance – UIDs of pages and the content are identical.

For this first purpose I only had to compare strings. But Cypress can do so much more than that. In my job, the tool is also becoming a topical subject right now. So in the near future I will be able to learn a lot from and with colleagues. And with a little experience, the current Cypress setup will certainly look different in a few months.

Back to news list