Visual Regression Testing in Selenium

A years-old Selenium suite stays green while the layout falls apart, because assertions read text and DOM structure, not pixels. This guide adds Visual Tests to exactly such a suite (Mocha, selenium-webdriver, CommonJS) in four steps, without a rewrite:

  1. Install @buddy-works/visual-tests-selenium and hook the plugin into the existing driver.
  2. Add two takeSnap() lines where the tests already stand.
  3. Establish the baseline with a single run on main.
  4. Add a VT session action and an APPROVE_VT gate to the pipeline.

The result: a broken layout stops at the gate before it ships, while the functional tests pass on the same npm test.

The test suite nobody wants to rewrite

For this guide I built Refundry, a simple app for handling returns and refunds. It displays a queue of cases with amounts, statuses, and an Approve button.

Image loading...Refundry refund queue: a table of twelve cases with amounts, statuses, and Approve buttons

The page is server-rendered with Express and EJS, no bundler. Let's assume the Mocha and selenium-webdriver tests have been maintained for years.

The full suite covers four scenarios: the row count, the status breakdown, the amounts, and the Approve flow, which changes application state. Below are the two scenarios used later in this guide:

js
// test/refund-queue.test.js: the functional suite that has run for years. 'use strict'; const assert = require('node:assert/strict'); const { By, until } = require('selenium-webdriver'); const BASE_URL = process.env.BASE_URL || 'http://localhost:4480'; describe('Refund queue', function () { // before/after: the usual headless Chrome setup and driver.quit(), with // timeouts tuned for CI, plus a findRowByCaseId() XPath helper. it('renders the queue with all 12 cases', async function () { await driver.get(BASE_URL + '/'); await driver.wait(until.elementLocated(By.id('refunds-table')), 10000); const rows = await driver.findElements(By.css('#refunds-table tbody tr')); assert.equal(rows.length, 12); }); // The two remaining tests check the status breakdown and the amounts. it('approves a requested refund from the queue', async function () { await driver.get(BASE_URL + '/'); const table = await driver.findElement(By.id('refunds-table')); const row = await findRowByCaseId('RF-1042'); await row.findElement(By.css('.approve-btn')).click(); // The POST redirects back to the queue. Wait for the navigation to // finish so we do not read the previous version of the table. await driver.wait(until.stalenessOf(table), 10000); await driver.wait(until.elementLocated(By.id('refunds-table')), 10000); const updatedRow = await findRowByCaseId('RF-1042'); const status = await updatedRow .findElement(By.css('.status-pill')) .getText(); assert.equal(status, 'Approved'); }); });

The tests use driver.wait(until...), stalenessOf after the redirect, and timeouts tuned for CI. But they only check data and DOM structure: the row count, the "Approved" status, the "$184.00" amount. They will not catch misaligned amounts, wrapping statuses, or a broken table layout.

Two checkpoints, one page

Visual Tests takes a snapshot of the page and compares it to an approved reference image, the baseline. When the difference exceeds the tolerance threshold, Buddy sends the snapshot for review and can hold the pipeline until a decision is made.

Checkpoints are chosen along application states, not the test count. Refundry has one page, so we protect two:

  • the queue in its initial state, before anyone touches it,
  • the queue after Approve, past the only mutation the suite performs.

The Approve test already goes through the click and the redirect, so the second checkpoint can be added without extra navigation.

Info
A snapshot in each of the four tests would produce three copies of the initial queue: repeated snapshots add no new information, but they still require storage and review. The same rule scales to larger suites, and dynamic data such as dates, counters, or avatars can be masked with cssIgnores or xpathIgnores.

One package and one line per checkpoint

The Visual Tests suite has a CLI tab with instructions for Storybook, Selenium, Cypress, Puppeteer, and Playwright. Separate guides show the same integration with Playwright, Cypress, and Puppeteer.

Image loading...Suite CLI tab with runner tabs, Selenium selected: package install, plugin setup, takeSnap, and the session command

Selenium also gets a LANGUAGE switch: JavaScript, Java, C#. For Java, Buddy shows the Maven plugin works.buddy.visual-tests:plugin with a takeSnapshot() method, so Visual Tests for Selenium is not reserved for Node suites. Ours is JavaScript, so we install the official npm package:

bash
npm install --save-dev @buddy-works/visual-tests-selenium $

The plugin receives the WebDriver instance the tests already create in before:

js
let visualTests; before(async function () { // ...the existing driver setup stays untouched... // The package is ESM-only, so a CommonJS suite loads it with a // dynamic import. const { VisualTestsPlugin } = await import( '@buddy-works/visual-tests-selenium' ); visualTests = new VisualTestsPlugin(driver, !process.env.BUDDY_VT_TOKEN); });

With the plugin initialized, we add two takeSnap() calls in places the tests already navigate to:

js
// Test 1, the queue in its initial state: CHECKPOINT 1. await visualTests.takeSnap('refund-queue', { fullPage: true }); // The Approve test, after the mutation and the assertions: CHECKPOINT 2. await visualTests.takeSnap('refund-queue-after-approve', { fullPage: true });

The suite, the matrix, and the baseline

Snapshots need somewhere to land, so you create a suite and configure it in Settings: Full page mode, Chrome, Firefox and Safari browsers, the OS, Full HD and Laptops devices, the diff threshold, the baseline branch:

Image loading...Refundry suite Settings: Full page mode, Chrome/Firefox/Safari on Ubuntu, Full HD and Laptops devices, suite token

Info
The matrix lives in the suite, not in the test code. Three browsers times two resolutions is six renders per takeSnap, so the two Refundry checkpoints produce twelve compared images per session. If the suite snapshotted all four tests, there would be twenty-four, mostly duplicates of the same queue.

You establish the baseline with a single run on main, using the same command the suite has run for years, just wrapped in a Visual Tests session:

bash
# One-time: prepare the browser for resource discovery. bdy tests visual setup # Keep the suite token in an environment variable, never in the repo. export BUDDY_VT_TOKEN="<your-suite-token>" # The server must be running, then: the existing suite inside a session. The # Approve test mutates in-memory state, so start a fresh server before each run. node server.js & bdy tests visual session create "npm test" $$$$$$$$$$

Four tests pass, the session uploads two snapshots, refund-queue and refund-queue-after-approve. Approve both, and from now on main is the reference, and unchanged snapshots from future runs are approved automatically.

The refactor that looks like a cleanup

Time to introduce a regression. The table's styles.css has carried a block for years that looks overengineered: table-layout: fixed, explicit widths for eight columns, nowrap with ellipsis on the cells, a separate rule for amount alignment. Someone tidies up and cuts the whole thing:

diff
- #refunds-table { - table-layout: fixed; - width: 100%; - } - - #refunds-table col.col-case { width: 8%; } - #refunds-table col.col-customer { width: 19%; } - /* ...six more column widths... */ - - #refunds-table th, - #refunds-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - #refunds-table th.amount, - #refunds-table td.amount { - text-align: right; - }

Commit refactor: simplify the refund table styles, minus 31 lines, no changes to markup or logic. The table falls back to auto layout and breaks immediately on real-world data: case ids wrap onto two lines, dates and order numbers fold, amounts lose their right alignment and pile up against the left edge, rows swell to double height:

Image loading...The refund queue after the refactor: ids and dates broken onto two lines, unaligned amounts, swollen rows

Run the suite against this. All four tests pass. There are still twelve rows, the status after Approve still reads "Approved," the amount still reads "$184.00." Every assertion checks text, and the text didn't change. A functional suite has no way to catch this.

A pipeline with two signals and a gate at the end

In CI both signals come from a single run of the suite. The Visual Regression Gate (Selenium) pipeline triggers on a push to any branch except main and consists of two actions: one build that runs npm test inside a Visual Tests session, and a gate at the end:

Image loading...Pipeline Workflow tab: push trigger, the Functional and visual tests action, and the Approve visual changes gate

yaml
- pipeline: Visual Regression Gate (Selenium) events: - type: PUSH refs: ["refs/heads/*"] trigger_conditions: - trigger_condition: VAR_IS_NOT trigger_variable_key: BUDDY_EXECUTION_BRANCH trigger_variable_value: main actions: - action: Functional and visual tests type: BUILD docker_image_name: cypress/browsers docker_image_tag: node-24.18.0-chrome-150.0.7871.128-1-ff-153.0-edge-150.0.4078.83-1 reset_entrypoint: true unit_tests_suite: refundry # native test result tracking unit_tests_path: reports/junit.xml visual_tests_suite: refundry # binds the session to the VT suite commands: | npm ci node server.js & # ...wait until the app responds... # The suite exactly as the team has run it for years, wrapped in a # Visual Tests session: the assertions decide the exit code, the two # takeSnap checkpoints land in the Refundry suite. bdy tests visual session create "npm test -- --reporter xunit --reporter-option output=reports/junit.xml" - action: Approve visual changes type: APPROVE_VT from_action: Functional and visual tests suite: refundry ref: $BUDDY_EXECUTION_REVISION
Info
The same setup can be clicked together in the action editor. The Configure visual tests button sets visual_tests_suite, takes care of the bdy CLI and a browser in the action, and wraps the test command in a Visual Tests session, while Collect test results sets unit_tests_suite and unit_tests_path. The YAML above does exactly the same, just explicitly.

One npm test, one action, two independent signals. The assertions still decide whether the action passes, exactly as they have for years: a failed test stops the pipeline before the gate. The visual_tests_suite field binds the action to the Refundry suite and injects the token automatically, so both takeSnap calls feed the suite without any variable to manage. There is no reason to run the suite a second time without the token: the checkpoints do not assert anything, so a separate "functional lane" would double the pipeline (a second npm ci, a second server boot, a second full run) and add no information. And the pipeline does not end with a deploy, it ends with the APPROVE_VT gate: in this pipeline the finale is a human decision about appearance.

The functional results get tracked natively too. Mocha has a built-in xunit reporter, so without touching the repo the suite writes a JUnit XML report, and the unit_tests_suite and unit_tests_path fields on the action (the "Collect test results" chip in the UI) send it to the project's Unit Tests tab:

Image loading...The Collect tests results modal on the tests action: Refundry suite, JUnit XML format, reports/junit.xml path

The Unit Tests tab also offers ready-made reporters from @buddy-works/unit-tests for Jest, Jasmine, Mocha, Cypress, Playwright, and Vitest. We deliberately skip them here: they would mean changing the suite's configuration, and the whole point of this guide is "do not touch the legacy suite". The built-in reporter plus two fields on the action deliver the same result without any new dependencies.

The run: green on function, stopped on looks

Pushing the refactor commit triggers the pipeline. Everything is visible on one screen: the tests action passes on the broken table, every assertion holds, while its Visual Tests session uploads the snapshots, and the Approve visual changes gate holds the run in Awaiting with live Proceed and Stop buttons:

Image loading...The run held at the gate: the Functional and visual tests action green with its session chips, Approve visual changes waiting with Proceed and Stop

The Unit Tests tab confirms this is no accident and no skipped test: four tests, one hundred percent, every assertion passed on the broken layout:

Image loading...A Unit Tests session: four green tests of the refund-queue suite, 100% passed

Meanwhile the Visual Tests session says the opposite. Both checkpoints came back as Changed:

Image loading...The Visual Tests session from the run: refund-queue and refund-queue-after-approve flagged as Changed, pending review

In the Side by side view, the baseline from main is on the left and the current branch on the right. The data is the same, but the ids wrap and the amounts are no longer aligned:

Image loading...The refund-queue side-by-side diff: the compact baseline table on the left, the broken auto layout from the feature branch on the right

The checkpoint after Approve shows the same regression:

Image loading...The refund-queue-after-approve side-by-side diff: baseline versus the broken table after the Approve mutation

The Selenium tests confirmed the data and the behavior were correct. Only the comparison against the baseline caught the change in appearance.

Reject, revert, green

Since the change is unwanted, reject both snapshots and choose Stop on the gate:

Image loading...The Visual Tests session after review: both snapshots rejected, counter shows NO 2

Image loading...The run stopped at the gate: Approve visual changes interrupted, the run marked as Canceled

Since a single commit introduced the problem, it can be reverted:

bash
git revert 388f6c9 # Revert "refactor: simplify the refund table styles" git push $$

The push triggers another run, and this time everything passes with no manual touch. The functional tests report the same four greens, the snapshots come back as Unchanged, Buddy auto-approves them against the baseline, and the gate has nothing to hold:

Image loading...The run fully green: the tests action with both session chips, the gate passed automatically

Image loading...The Visual Tests session after the revert: both snapshots Unchanged, approved automatically

Note the two chips on the tests action in the run view: the Visual Tests session chip and the Refundry unit-tests suite chip, side by side on the same action. Both link straight to their sessions, so from a single run screen you can click through to the diffs and to the test results with full commit context.

Summary

  • No rewrite. The @buddy-works/visual-tests-selenium package wraps the driver the suite already uses; a checkpoint is one takeSnap() line where the test already stands.
  • Count states, not tests. One page, one mutation: two checkpoints, with dynamic data masked by cssIgnores and xpathIgnores.
  • One run, two signals. A single npm test inside a Visual Tests session feeds both the Unit Tests tab and the visual suite, with no second lane rerunning the suite.
  • The gate decides. A broken render waits at APPROVE_VT for a human; unchanged snapshots pass automatically.
Jarek Dylewski

Jarek Dylewski

Customer Support

A journalist and an SEO specialist trying to find himself in the unforgiving world of coders. Gamer, a non-fiction literature fan and obsessive carnivore. Jarek uses his talents to convert the programming lingo into a cohesive and approachable narration.

Jul 22, 2026
Share