Proposal: Visual Regression Testing in xwiki-platform

Hello! This is my proposal that we should implement a way to do visual regression testing in xwiki-platform, and how to go about it.

Context

We’re integrating BlockNote as a WYSIWYG editor option in XWiki. We discovered that, at least currently, the editor is tightly coupled with the default styling that comes out of the box. An example is this issue (XWiki-24239) in which we discovered that BlockNote hardcodes, as a pixel offset, where the block handle should be put, per block type, particular to their theming.

XWiki has a lot of theming support, so we expect there to be many quirks we’ll have to work around, to adjust for this.

I argue that we should introduce an approach to do visual regression testing, to be able to assert that the editor looks the way we want it to, and also to make it easier to validate, when we do upgrades, that our styling remains the same.

This would be useful for other areas of the product also, AFAIU it is not something we really check for currently, and it can help with subtle UI changes.

Approach

My suggestion is to use Selenium’s WebElement API to take screenshots, and to use the image-comparison library for the comparison step. The screenshots are scoped at the element level (not the whole page) to reduce as much as possible the surface area.

I wrote a generic ScreenshotComparator class which uses the above to achieve this. I propose to move this into xwiki-platform-test-docker to be able to use it with all newly written Selenium tests.

The method i suggest to expose is assertScreenshotMatches(String name, WebElement element), (renamed to assertMatches).

As a developer working on a new test, you’d write the test, run it, it will fail (as there is nothing to compare against) and generate a screenshot. Then you’d take a look, and if it looks correct, you’d have to promote it as the ‘canonical’ screenshot to compare against on future runs. You’d do this by moving the generated screenshots in the folder path corresponding to the test you wrote.

The path for the screenshots is: src/test/resources/screenshots/<Class>/<method>/<browser>/<name>.png

Limitations

  • We need separate screenshot sets for both Chrome and Firefox, as rendering differs enough for them to not match.
  • I haven’t tested this on an ARM machine but there is a good chance that the Chrome screenshots we have, will not work, since Selenium uses a Chromium driver on arm. And it’s really tricky to regenerate screenshots when you need to update a test, for two different architectures.
  • Right now we’re avoiding having to scroll the page, to ensure screenshot stability. We haven’t really experimented much with scrolling but i expect it’ll cause issues. We could also look into increasing the viewport size for the drivers, it is currently quite tight (about 800px tall, and it is different between chrome and firefox.)

Example

Here is an example of how you could use this in a test, from the experiment we did:


@Test
void sideMenuIsAlignedOnLargeHeadings(TestUtils setup, TestReference testReference,
    ScreenshotComparator screenshots) throws Exception
{
    setup.deletePage(testReference);
    setup.createPage(testReference, LARGE_HEADINGS_CONTENT);

    assertSideMenuIsAligned(editInplace(), screenshots, LARGE_HEADINGS);
}

private void assertSideMenuIsAligned(BlockNoteRichTextArea textArea, ScreenshotComparator screenshots,
    String[] blocks) throws IOException
{
    WebElement content = new InplaceEditablePage().getContentContainer();
    for (int i = 0; i < blocks.length; i++) {
        textArea.hoverBlock(i);
        screenshots.assertMatches(blocks[i], content);
    }
}

And in the comparator class, the most important parts are:

public void assertMatches(String name, WebElement element) throws IOException
{
    // The test name and the browser are part of the file names because the screenshots folder is shared by all
    // the tests.
    String prefix = "%s-%s-%s-%s".formatted(this.testClassName, this.testMethodName, this.browser, name);
    File actualFile = new File(this.outputFolder, prefix + ".png");
    BufferedImage actual = takeScreenshot(element);
    ImageComparisonUtil.saveImage(actualFile, actual);

    String referencePath = "src/test/resources/" + getReferencePath(name);
    BufferedImage reference = readReference(name);
    assertNotNull(reference, () -> ("There is no reference screenshot for [%s]. Check the screenshot taken by the "
        + "test, at [%s], and copy it to [%s] if it is correct.").formatted(name, actualFile, referencePath));

    File differenceFile = new File(this.outputFolder, prefix + "-diff.png");
    ImageComparisonResult result = new ImageComparison(reference, actual, differenceFile)
        .setPixelToleranceLevel(PIXEL_TOLERANCE_LEVEL).compareImages();
    assertEquals(ImageComparisonState.MATCH, result.getImageComparisonState(),
        () -> ("The screenshot [%s] doesn't match its reference (%s%% of the pixels are different). Compare the "
            + "screenshot taken by the test, at [%s], with the reference screenshot, at [%s]. The differences are "
            + "highlighted at [%s].").formatted(name, result.getDifferencePercent(), actualFile, referencePath,
                differenceFile));
}

private BufferedImage takeScreenshot(WebElement element) throws IOException
{
    return ImageIO.read(new ByteArrayInputStream(element.getScreenshotAs(OutputType.BYTES)));
}

Conclusions

Despite some of the limitations, I personally think that this is the best way to go about achieving these kinds of automated UI tests that are aware of styling/positioning. The alternative, which I also tried, to do element-positioning math to check for alignment, is not maintainable IMO. But you can see the attempt here for comparison.

Let me know what you think.

Thank you,
Nicoleta C.

Hi Nicoleta,

Thanks for working on this!

I confirm style regressions are currently easy to miss. We have plenty of automated functional tests but almost no automated tests to verify the styles (skin, color theme).

We can disable these tests for ARM architecture if we have proof they fail. They will continue to run on CI.

Note that the test case Nicoleta had to cover involved hovering an element before taking a screenshot of one of its ancestors. When the screenshot is taken the target element is scrolled into view which can move the hovered element, thus losing the hover. It’s a particular case. Scrolling alone shouldn’t cause problems, I think. But if it does, we should be able to extend @UITest with support for specifying the view port size, so that we can avoid the scroll.

+1 on my side for the proposal.

Thanks,
Marius

Hi Nicoleta,

I’m very happy to see you working on this and making progress! Visual regression testing is something we identified as missing a long time ago but never got to actually work on. I’m very enthusiastic about the idea, so a big +1 from me.

Some history, for context:

Your proposal meets the first requirement (image-comparison runs in the build, nothing external) and comes with a real use case, which is what was always missing.

ARM test results

I built your branch (commit 3d9fa71c623) and ran SideMenuIT on my Mac (Apple Silicon, arm64):

  • Firefox: 7/7 pass. The selenium/standalone-firefox image is multi-arch, and the references you generated work as is on arm64.
  • Chrome: 6/7 fail (0.1% to 2.8% of the pixels differ, only the image-only test passes). As you suspected, on arm64 our test framework uses selenium/standalone-chromium instead of selenium/standalone-chrome. The real cause, though, is fonts, not the browser engine: the Chromium image doesn’t ship fonts-liberation, so sans-serif resolves to Noto Sans instead of Liberation Sans. The text is wider and the long paragraph wraps differently. The side menu is correctly aligned in both cases, so these are false failures.

Suggestions

  1. Browser version drift. By default @UITest uses the latest browser image tag, and CI pulls it daily. When a new Chrome/Firefox version slightly changes text rendering, all references will break at the same time, on all branches (master and LTS), without any code change. I think this is the main maintenance risk, and we need to decide how to handle it: pin browserTag for screenshot tests, allow a small percentage of different pixels (setAllowingPercentOfDifferentPixels(); right now it’s 0%), and/or document a procedure to regenerate the references.
  2. Only one reference set, for Firefox. Firefox is the default browser on CI, and Chrome only runs in some matrix jobs. If we skip the screenshot assertion (not the whole test) on other browsers, we halve the number of references to maintain, and the ARM problem goes away, since Firefox already works on both architectures.
  3. Make the tests independent of system fonts, if we want to keep Chrome: force a bundled web font in these tests, or make sure fonts-liberation is available in the image.
  4. Make it easy to update references. Copying files by hand from target/screenshots to src/test/resources/... will get painful as soon as several screenshots change. A system property such as -Dxwiki.test.screenshots.update=true that writes the references directly would help a lot.
  5. Keep the screenshots as small as possible. The current ones capture the full content area (1220px wide), so an unrelated change (skin, translation, font) anywhere in that area breaks a test that is only about the handle alignment. Cropping to the block + its handle would make them much more robust.
  6. Integration in xwiki-platform-test-docker: +1. It would be nice if @UITest registered the parameter resolver itself so that tests don’t need @ExtendWith. +1 also to Marius’s idea of being able to configure the viewport size in @UITest.

None of this blocks starting. I’d be happy to see this land with the BlockNote tests, then extend it to other areas (skin, color themes, macros).

Thanks again for working on this!
-Vincent

PS: This reply was co-written with Claude Code, which also did the ARM test run.

+1, thanks for working on this!

From my limited experimentations with this testing strategy, I confirm that fonts needs to be deterministic to have relevant results. But, I’m sure we can fix that case by case.

The issue in our specific BlockNote case is that the side menu icon shown on hover is displayed outside (on the left of) the hovered content block. If we were to take the screenshot only for the hovered block it would miss the menu icon. The simplest solution for this was to take the screenshot of the closest parent that includes the side menu. The downside is that the screenshot includes more than we need.

A better solution (validated by Claude) is indeed to take the screenshot of the entire page and crop it to the element’s bounding rect plus a specified inset (to include the floating side menu). This means adding a new:

ScreenshotComparator#assertMatches(String name, WebElement element, Insets margin)

Thanks,
Marius

Thanks everyone. Implemented in https://github.com/xwiki/xwiki-platform/pull/6702 :

  • moved to xwiki-platform-test-docker, @UITest registers the parameter resolver (6)
  • assertMatches(name, element, Insets margin), cropping a page screenshot (5, Marius’s suggestion).
    SideMenuIT’s paragraph reference goes from 1220x205 to 1236x34
  • Firefox-only references, comparison skipped on the other browsers (2)
  • -Dxwiki.test.ui.screenshots.update=true to regenerate (4)
  • screenWidth/screenHeight on @UITest (Marius’s viewport idea)

One argument for dropping the Chrome references that hasn’t come up: they don’t just fail on
arm64, they can’t be regenerated there at all, so a maintainer on Apple Silicon can’t update
them when a UI change legitimately moves them. This is why I’m not attempting suggestion 3, as making the tests font-independent can only be validated on an ARM machine and I don’t have one, so I’d rather keep it as the documented precondition for bringing Chrome back.

Nothing done on suggestion 1 (browser version drift). Regeneration is one command now, which means it shouldn’t be difficult to just reupdate the reference images when needed. Whether you are ok with that, or we prefer to pin browserTag still, I’d like your opinion before deciding.

I also updated the blocknote tests with smaller screenshots.

Thanks,
Nicoleta

Hi @NicoletaCiausu thanks for working on this. These tests can bring a lot of ease of mind for me when changing seemingly unrelated CSS classes only to discover later that something, somewhere else broke :slight_smile:

This is especially true when implementing UI proposals.