Visual Regression for Adaptive Interfaces: Testing That Crisis Mode Actually Looks Different
Source: Dev.to
Introduction
State management is easy to test, but visual transformation isn’t. In a crisis, what the user actually sees is the only thing that matters. This post explains how to verify that emergency (or “crisis”) mode isn’t just flipping booleans—it truly changes the experience.
What unit tests can verify
- ✅ State updated correctly
- ✅ CSS classes applied
- ✅ Component rendered
What unit tests cannot verify
- ❌ Button actually looks bigger
- ❌ Contrast ratio actually increased
- ❌ Layout actually simplified
- ❌ Text actually readable
The gap between “test passed” and “user helped” is visual. Screenshot testing bridges that gap.
Visual Regression Technique
Capture the same component in normal mode and crisis mode, then verify they’re meaningfully different.
import { test, expect } from '@playwright/test';
test.describe('Crisis Mode Visual Transformations', () => {
test('emergency mode visually differs from normal mode', async ({ page }) => {
// Capture normal state
await page.goto('/pain-entry');
const normalScreenshot = await page.screenshot();
// Activate crisis mode
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('activate-crisis-mode', {
detail: { severity: 'emergency' }
}));
});
// Wait for visual transition
await page.waitForTimeout(500); // Allow CSS transitions
// Capture crisis state
const crisisScreenshot = await page.screenshot();
// Verify they're different (this is the key assertion)
expect(Buffer.compare(normalScreenshot, crisisScreenshot)).not.toBe(0);
});
test('emergency mode matches approved baseline', async ({ page }) => {
await page.goto('/pain-entry?crisis=emergency');
await expect(page).toHaveScreenshot('emergency-mode-pain-entry.png', {
maxDiffPixels: 100, // Allow minor anti-aliasing differences
});
});
});
Component‑level feedback
test.describe('Touch Target Scaling', () => {
test('buttons scale up in emergency mode', async ({ page }) => {
await page.goto('/component-preview/button');
// Normal size
await expect(page.locator('[data-testid="primary-button"]'))
.toHaveScreenshot('button-normal.png');
// Emergency size
await page.evaluate(() => {
document.documentElement.setAttribute('data-crisis-mode', 'emergency');
});
await expect(page.locator('[data-testid="primary-button"]'))
.toHaveScreenshot('button-emergency.png');
});
});
Handling Multiple Adaptive States
Standard visual regression uses a single baseline per test, but adaptive interfaces have many valid states. For a pain‑tracker button, valid combinations include:
| Mode | Contrast | Font Size | Touch Targets | Baseline |
|---|---|---|---|---|
| Normal | Normal | Medium | Standard | A |
| Normal | High | Medium | Standard | B |
| Crisis | Normal | Large | Extra‑Large | C |
| Crisis | High | Large | Extra‑Large | D |
| … | … | … | … | … |
Matrix testing example
const PREFERENCE_MATRIX = [
{ mode: 'normal', contrast: 'normal', fontSize: 'medium' },
{ mode: 'normal', contrast: 'high', fontSize: 'medium' },
{ mode: 'normal', contrast: 'normal', fontSize: 'large' },
{ mode: 'crisis', contrast: 'normal', fontSize: 'large' },
{ mode: 'crisis', contrast: 'high', fontSize: 'large' },
];
for (const prefs of PREFERENCE_MATRIX) {
test(`pain entry form - ${prefs.mode}/${prefs.contrast}/${prefs.fontSize}`, async ({ page }) => {
// Set preferences via URL params or localStorage
await page.goto(
`/pain-entry?mode=${prefs.mode}&contrast=${prefs.contrast}&fontSize=${prefs.fontSize}`
);
// Each combination has its own baseline
const baselineName = `pain-entry-${prefs.mode}-${prefs.contrast}-${prefs.fontSize}.png`;
await expect(page).toHaveScreenshot(baselineName);
});
}
Storybook & Chromatic
If you use Storybook, Chromatic can handle matrix testing elegantly.
// Button.stories.tsx
export default {
title: 'Components/Button',
component: Button,
};
export const Normal = () => Save Entry;
export const Emergency = () => (
Save Entry
);
export const HighContrast = () => (
Save Entry
);
export const EmergencyHighContrast = () => (
Save Entry
);
Chromatic captures each story as a separate baseline, ensuring visual changes are intentional.
Accessibility Structure Checks
Visual changes should preserve semantic meaning. The following tests verify that the DOM structure adapts correctly.
test.describe('Crisis Mode Accessibility Structure', () => {
test('normal mode has full navigation structure', async ({ page }) => {
await page.goto('/pain-entry');
await expect(page.locator('main')).toMatchAriaSnapshot(`
- main:
- heading "Log Pain Entry" [level=1]
- navigation "Entry sections":
- link "Basic Info"
- link "Location"
- link "Symptoms"
- link "Triggers"
- link "Medications"
- form "Pain entry form":
- group "Pain Level":
- slider "Pain intensity"
- group "Location":
- button "Select body areas"
- group "Notes":
- textbox "Additional notes"
- button "Save Entry"
- button "Cancel"
`);
});
test('emergency mode simplifies to essentials', async ({ page }) => {
await page.goto('/pain-entry?crisis=emergency');
// Emergency mode should have FEWER elements, not more
await expect(page.locator('main')).toMatchAriaSnapshot(`
- main:
- heading "Quick Pain Log" [level=1]
- form "Simplified pain entry":
- group "How bad?":
- slider "Pain level"
- group "Where?":
- button "Tap body location"
- button "Save Now"
- button "Need Help?"
`);
});
});
test('emergency mode reduces cognitive load', async ({ page }) => {
// Count interactive elements in normal mode
await page.goto('/pain-entry');
const normalInteractive = await page
.locator('[role="button"], [role="link"], input, select, textarea')
.count();
// Count in emergency mode
await page.goto('/pain-entry?crisis=emergency');
const emergencyInteractive = await page
.locator('[role="button"], [role="link"], input, select, textarea')
.count();
// Emergency mode should have fewer interactive elements
expect(emergencyInteractive).toBeLessThan(normalInteractive);
// But not zero – core functionality must remain
expect(emergencyInteractive).toBeGreaterThan(2);
});
Contrast Verification
High‑contrast mode is meaningless if the contrast ratios don’t meet WCAG thresholds. Automated tests can verify this.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Contrast Verification', () => {
test('normal mode meets WCAG AA (4.5:1)', async ({ page }) => {
await page.goto('/pain-entry');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toHaveLength(0);
});
test('high contrast mode exceeds WCAG AAA (7:1)', async ({ page }) => {
await page.goto('/pain-entry?contrast=high');
const results = await new AxeBuilder({ page })
.withRules(['color-contrast-enhanced']) // AAA level
.analyze();
expect(results.violations).toHaveLength(0);
});
});
Conclusion
Visual regression testing is essential for adaptive interfaces, especially when “crisis mode” must reliably convey larger touch targets, higher contrast, and simplified layouts. By combining screenshot comparisons, matrix testing, Storybook/Chromatic baselines, ARIA structure snapshots, and contrast analysis, you can ensure that the UI not only flips state flags but also delivers a genuinely safer experience for users in distress.