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:
- Install
@buddy-works/visual-tests-seleniumand hook the plugin into the existing driver. - Add two
takeSnap()lines where the tests already stand. - Establish the baseline with a single run on
main. - Add a VT session action and an
APPROVE_VTgate 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...![]()
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.
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...![]()
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:
bashnpm install --save-dev @buddy-works/visual-tests-selenium$
The plugin receives the WebDriver instance the tests already create in before:
jslet 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...![]()
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...![]()
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...![]()
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
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 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 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...![]()
Meanwhile the Visual Tests session says the opposite. Both checkpoints came back as Changed:
Image loading...![]()
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 checkpoint after Approve shows the same regression:
Image loading...![]()
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...![]()
Image loading...![]()
Since a single commit introduced the problem, it can be reverted:
bashgit 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...![]()
Image loading...![]()
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-seleniumpackage wraps the driver the suite already uses; a checkpoint is onetakeSnap()line where the test already stands. - Count states, not tests. One page, one mutation: two checkpoints, with dynamic data masked by
cssIgnoresandxpathIgnores. - One run, two signals. A single
npm testinside 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_VTfor a human; unchanged snapshots pass automatically.
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.