# no-viewport-dependent
Prevent tests that depend on specific viewport sizes or screen dimensions.
## Rule Details
Viewport-dependent tests are brittle and can fail across different environments:
- Different screen resolutions and display densities
- Varying browser window sizes in CI/CD environments
- Mobile vs desktop viewport differences
- Browser zoom levels affecting element positioning
- CSS media queries changing layout based on viewport size
- Element visibility depending on scroll position
This rule helps prevent test flakiness by detecting tests that rely on specific viewport dimensions or screen properties.
## Options
This rule accepts an options object with the following properties:
```json
{
"test-flakiness/no-viewport-dependent": [
"error",
{
"allowViewportSetup": true,
"allowResponsiveTests": false,
"ignoreMediaQueries": false
}
]
}
```
### `allowViewportSetup` (default: `true`)
When set to `true`, allows viewport configuration in test setup hooks.
```javascript
// With allowViewportSetup: true (default)
beforeEach(() => {
cy.viewport(1920, 1080); // Allowed in setup
});
// With allowViewportSetup: false
beforeEach(() => {
cy.viewport(1920, 1080); // Not allowed
});
```
### `allowResponsiveTests` (default: `false`)
When set to `true`, allows tests specifically designed for responsive design testing.
```javascript
// With allowResponsiveTests: true
describe("Responsive design", () => {
cy.viewport("macbook-15");
cy.get('[data-cy="mobile-menu"]').should("not.be.visible"); // Allowed
});
// With allowResponsiveTests: false (default)
cy.get('[data-cy="mobile-menu"]').should("not.be.visible"); // Not allowed
```
### `ignoreMediaQueries` (default: `false`)
When set to `true`, ignores tests that specifically test CSS media query behavior.
```javascript
// With ignoreMediaQueries: true
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); // Ignored
// With ignoreMediaQueries: false (default)
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); // Not allowed
```
## Examples
### Incorrect
```javascript
// Viewport size checks
expect(window.innerWidth).toBe(1920);
expect(window.innerHeight).toBeGreaterThan(1000);
// Screen dimension checks
expect(screen.width).toBe(1920);
expect(screen.height).toBe(1080);
// Media query matching
expect(window.matchMedia("(max-width: 768px)").matches).toBe(false);
expect(window.matchMedia("(min-width: 1200px)").matches).toBe(true);
// Element position checks that depend on viewport
expect(element.getBoundingClientRect().top).toBe(100);
expect(element.offsetLeft).toBeLessThan(500);
// Scroll position checks
expect(window.scrollY).toBe(0);
expect(document.documentElement.scrollTop).toBeGreaterThan(200);
// CSS computed style checks that vary by viewport
expect(getComputedStyle(element).display).toBe("none"); // Hidden on mobile
expect(getComputedStyle(element).width).toBe("300px");
// Viewport-dependent visibility checks
expect(element).toBeVisible(); // May fail on different screen sizes
cy.get(".mobile-menu").should("be.visible"); // Depends on viewport
// Playwright viewport-dependent tests
await expect(page.locator(".desktop-only")).toBeVisible();
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator(".mobile-only")).toBeVisible();
// Element positioning assertions
const rect = await element.boundingBox();
expect(rect.x).toBe(100); // Position depends on viewport
expect(rect.y).toBeLessThan(300);
// Responsive grid/layout checks
const columns = await page.locator(".grid-column").count();
expect(columns).toBe(3); // May be different on smaller screens
// Touch/hover behavior based on device type
if ("ontouchstart" in window) {
expect(touchEnabled).toBe(true);
}
// Orientation-dependent tests
expect(window.orientation).toBe(0); // Portrait
expect(screen.orientation.angle).toBe(90); // Landscape
// CSS breakpoint testing
cy.viewport("iphone-6");
cy.get(".hamburger-menu").should("be.visible");
```
### Correct
```javascript
// Test functionality regardless of viewport
expect(getUserData()).toEqual(expectedData);
expect(calculateTotal(items)).toBe(150);
// Use semantic queries instead of position-based
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
expect(screen.getByText("Welcome")).toBeVisible();
// Test responsive behavior with explicit viewport setup
describe("Navigation", () => {
beforeEach(() => {
// Set consistent viewport for all tests in this suite
cy.viewport(1920, 1080);
});
it("shows desktop navigation", () => {
cy.get('[data-cy="desktop-nav"]').should("be.visible");
cy.get('[data-cy="mobile-nav"]').should("not.exist");
});
});
// Test both responsive states explicitly
describe("Responsive menu", () => {
it("shows desktop menu on large screens", () => {
cy.viewport(1200, 800);
cy.get('[data-cy="desktop-menu"]').should("be.visible");
});
it("shows mobile menu on small screens", () => {
cy.viewport(375, 667);
cy.get('[data-cy="mobile-menu"]').should("be.visible");
});
});
// Use data attributes for responsive elements
;
// Test with CSS classes instead of viewport dimensions
expect(element).toHaveClass("mobile-layout");
expect(element).not.toHaveClass("desktop-layout");
// Mock viewport-related APIs in tests
beforeEach(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1024,
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: query === "(min-width: 768px)",
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
})),
});
});
// Test behavior instead of layout
test("toggles menu visibility", async () => {
const user = userEvent.setup();
render();
const menuToggle = screen.getByRole("button", { name: /menu/i });
await user.click(menuToggle);
expect(screen.getByRole("navigation")).toBeInTheDocument();
});
// Use Playwright with consistent viewport setup
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
});
// Test content accessibility regardless of layout
test("all content is accessible", async ({ page }) => {
await page.goto("/");
// Test that all interactive elements are reachable
const buttons = await page.locator("button").all();
for (const button of buttons) {
await expect(button).toBeEnabled();
}
});
// Mock CSS media queries for consistent testing
const createMatchMedia = (width) => {
return (query) => ({
matches: evaluateMediaQuery(query, width),
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
};
window.matchMedia = createMatchMedia(1024);
```
## Best Practices
### 1. Set Consistent Viewport in Test Setup
Establish a standard viewport for all tests:
```javascript
// Cypress
beforeEach(() => {
cy.viewport(1280, 720); // Standard viewport for all tests
});
// Playwright
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
});
// Puppeteer
beforeEach(async () => {
await page.setViewport({ width: 1280, height: 720 });
});
```
### 2. Test Responsive Behavior Explicitly
When testing responsive design, be explicit about viewport changes:
```javascript
describe("Responsive design", () => {
const testViewports = [
{ name: "mobile", width: 375, height: 667 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1920, height: 1080 },
];
testViewports.forEach(({ name, width, height }) => {
describe(`${name} viewport`, () => {
beforeEach(() => {
cy.viewport(width, height);
});
it("displays appropriate navigation", () => {
if (name === "mobile") {
cy.get('[data-cy="mobile-nav"]').should("be.visible");
} else {
cy.get('[data-cy="desktop-nav"]').should("be.visible");
}
});
});
});
});
```
### 3. Use Semantic Queries Instead of Position-Based
Focus on what users see and do rather than layout details:
```javascript
// Instead of position-based checks
expect(element.getBoundingClientRect().top).toBe(100);
// Use semantic meaning
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("navigation")).toBeVisible();
```
### 4. Mock Viewport-Related APIs
Mock browser APIs that depend on viewport:
```javascript
const mockMatchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
beforeEach(() => {
window.matchMedia = jest.fn().mockImplementation(mockMatchMedia);
// Mock specific breakpoints
window.matchMedia.mockImplementation((query) => {
if (query === "(max-width: 768px)") {
return { ...mockMatchMedia(query), matches: false };
}
return mockMatchMedia(query);
});
});
```
### 5. Test Behavior, Not Layout
Focus on functional testing rather than visual layout:
```javascript
// Instead of testing specific layout
expect(getComputedStyle(element).display).toBe("grid");
// Test the behavior
test("displays all navigation items", () => {
const navItems = ["Home", "About", "Contact"];
navItems.forEach((item) => {
expect(screen.getByRole("link", { name: item })).toBeInTheDocument();
});
});
```
### 6. Use CSS Classes for State Detection
Use CSS classes to indicate responsive states:
```javascript
// Component with responsive logic
function Navigation() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 768px)");
setIsMobile(mediaQuery.matches);
}, []);
return (
);
}
// Test with class-based assertions
expect(screen.getByRole("navigation")).toHaveClass("desktop-nav");
```
## Framework-Specific Examples
### React Testing Library
```javascript
// Mock window properties
beforeEach(() => {
// Mock window.innerWidth
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1024,
});
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: query.includes("1024"),
media: query,
})),
});
});
```
### Cypress
```javascript
// Viewport testing with explicit setup
describe("Responsive Components", () => {
const viewports = [
{ device: "mobile", width: 375, height: 667 },
{ device: "desktop", width: 1920, height: 1080 },
];
viewports.forEach(({ device, width, height }) => {
context(`${device} view`, () => {
beforeEach(() => {
cy.viewport(width, height);
});
it("renders correctly", () => {
cy.visit("/");
cy.get('[data-cy="content"]').should("be.visible");
});
});
});
});
```
### Playwright
```javascript
// Test multiple viewports
const devices = ["Desktop Chrome", "iPhone 12", "iPad Pro"];
devices.forEach((deviceName) => {
test.describe(`${deviceName}`, () => {
test.use({ ...devices[deviceName] });
test("renders navigation", async ({ page }) => {
await page.goto("/");
await expect(page.locator("nav")).toBeVisible();
});
});
});
```
## When Not To Use It
This rule may not be suitable if:
- You're specifically testing responsive design behavior
- You're building viewport-aware components or libraries
- You're testing CSS media query functionality
- You're working with maps or visualization components that depend on dimensions
In these cases:
```javascript
// Disable for responsive design tests
// eslint-disable-next-line test-flakiness/no-viewport-dependent
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true);
// Or configure for responsive testing
{
"test-flakiness/no-viewport-dependent": ["error", {
"allowResponsiveTests": true,
"allowViewportSetup": true
}]
}
```
## Related Rules
- [no-animation-wait](./no-animation-wait.md) - Prevents animation timing dependencies
- [no-hard-coded-timeout](./no-hard-coded-timeout.md) - Prevents hard-coded timeout values
## Further Reading
- [Responsive Design Testing Strategies](https://web.dev/responsive-web-design-basics/)
- [Cypress - Viewport Testing](https://docs.cypress.io/api/commands/viewport)
- [Playwright - Emulation](https://playwright.dev/docs/emulation)
- [Testing Library - Responsive Tests](https://kentcdodds.com/blog/testing-implementation-details)
- [CSS Media Queries in Tests](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Testing_media_queries)