Skip to main content

Testing Responsive Components

Overview

Abyss components using the MediaQuery component render all breakpoint variants (mobile, tablet, desktop) in the DOM and use CSS to control visibility. This approach improves performance and SSR compatibility but requires specific testing strategies.

The change

Before

Components conditionally rendered elements based on viewport size:

{
isMobile ? <MobileNav /> : <DesktopNav />;
}

This meant that only one element existed in the DOM at any given time.

After

Components render all variants and use CSS to control display:

<MediaQuery smallerThan="md"><MobileNav /></MediaQuery>
<MediaQuery largerThan="md"><DesktopNav /></MediaQuery>

Now, both elements always exist in the DOM and CSS determines which is visible.

Impact on tests

This change means that test locators may match multiple elements (mobile and desktop variants) instead of just one. Tests must be updated to filter by visibility to ensure they interact with the correct variant.

Note: The below sandbox examples in this section are for Playwright. Other framework examples can be seen further down on the page.

1. Multiple elements matched

Symptom:

Error: Multiple elements found for selector .breadcrumb-link
Expected 3, found 6

Cause: Test locator matches both mobile and desktop elements.

Fix: Filter by visibility

// ❌ Matches both mobile and desktop
const links = await page.locator('.breadcrumb-link').all();
// ✅ Only matches visible elements
const links = await page
.locator('.breadcrumb-link')
.filter({ visible: true })
.all();

2. Interacting with hidden elements

Symptom:

Error: Element is not visible

Cause: Test is targeting the hidden variant instead of the visible one.

Fix: Always filter by visibility

// ❌ Might target hidden element
await page.locator('[data-testid="nav-menu"]').first().click();
// ✅ Targets visible element
await page
.locator('[data-testid="nav-menu"]')
.filter({ visible: true })
.click();

Testing patterns

Playwright

// Single element
await page
.getByRole('link', { name: 'Home' })
.filter({ visible: true })
.click();
// Multiple elements
const visibleLinks = await page
.getByRole('link')
.filter({ visible: true })
.all();
// Count visible elements
const count = await page
.locator('.breadcrumb')
.filter({ visible: true })
.count();
expect(count).toBe(3);

React Testing Library

// Filter by visibility helper
function isVisible(element: HTMLElement): boolean {
return (
element.offsetParent !== null &&
window.getComputedStyle(element).display !== 'none' &&
window.getComputedStyle(element).visibility !== 'hidden'
);
}
// Use the helper
const visibleLinks = screen.getAllByRole('link').filter(isVisible);

Cypress

// Filter visible elements
cy.get('.breadcrumb-link').filter(':visible').should('have.length', 3);
// Ensure element is visible before interaction
cy.get('[data-testid="mobile-nav"]').should('be.visible').click();

Page object model (POM) pattern

Update page object models to include visibility filters by default:

export class BreadcrumbsPage {
constructor(private page: Page) {}
// ✅ Visibility filter built into getter
get breadcrumbLinks() {
return this.page.locator('.breadcrumb-link').filter({ visible: true });
}
async clickBreadcrumb(text: string) {
await this.breadcrumbLinks.filter({ hasText: text }).click();
}
async getBreadcrumbCount() {
return await this.breadcrumbLinks.count();
}
}

Viewport testing

When testing responsive behavior, set explicit viewports:

test.describe('Mobile view', () => {
test.use({ viewport: { width: 375, height: 667 } });
test('shows correct navigation', async ({ page }) => {
// Mobile nav should be visible
await expect(
page.locator('[data-testid="mobile-nav"]').filter({ visible: true })
).toBeVisible();
// Desktop nav should not be visible
await expect(
page.locator('[data-testid="desktop-nav"]').filter({ visible: true })
).toHaveCount(0);
});
});
test.describe('Desktop view', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('shows correct navigation', async ({ page }) => {
// Desktop nav should be visible
await expect(
page.locator('[data-testid="desktop-nav"]').filter({ visible: true })
).toBeVisible();
// Mobile nav should not be visible
await expect(
page.locator('[data-testid="mobile-nav"]').filter({ visible: true })
).toHaveCount(0);
});
});

Affected components

These components render multiple responsive variants:

ComponentWhat's DuplicatedFilter Required
AlertLayout and actionsYes
BreadcrumbsMobile shows subsetYes
EmphasisBannerLayoutYes
FooterLink columnsYes
HeaderNavigation menusYes
PageBodyIntroContent layoutYes
StepTrackerDisplay formatYes
CarouselNavigation buttonsYes

Best practices

  • Always filter by visibility when targeting responsive elements
  • Set explicit viewports in tests for predictable behavior
  • Test both variants in separate test cases (mobile and desktop)
  • Use semantic selectors (roles, labels) over class names
  • Validate only one variant is visible at any viewport
  • Build visibility filters into page object models for reusability

Why this approach?

The CSS-based approach provides several benefits:

  • Better SSR/hydration: No mismatches between server and client
  • Improved performance: CSS-based visibility is faster than JS re-renders
  • Consistency: Follows modern React patterns (CSS over JS for styling)
  • Accessibility: Screen readers handle visibility correctly
Table of Contents