QA Interview Hub · Knowledge base · Quizzes
Playwright
Emulation and environment
Devices, viewport and media
devices is a set of ready profiles: screen size, userAgent, deviceScaleFactor, isMobile, hasTouch and the default browser.
projects: [
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'tablet', use: { ...devices['iPad Pro 11'], colorScheme: 'dark' } },
],
It is important to understand the limit: this is emulation in a desktop engine, not a real device. It checks responsive layout, the mobile menu and touch events through tap() well, but it doesn't catch bugs specific to mobile Safari or performance on a weak phone.
tap() needs hasTouch: true, otherwise there will be an error.
Other useful emulation options:
colorScheme: 'dark' or page.emulateMedia({ colorScheme: 'dark' }) for testing the dark theme.reducedMotion: 'reduce' turns off animations if the application respects this setting; it also stabilises tests.page.emulateMedia({ media: 'print' }) checks the printed version of a statement or a receipt.offline: true or context.setOffline(true) in the middle of a test checks behaviour without a network.javaScriptEnabled: false checks SSR pages.viewport: { width: 1280, height: 720 } and page.setViewportSize() for checking breakpoints.
In an interview: I cover mobile scenarios with projects using devices, understanding that it is emulation, not a real device. Separately I check the dark theme, offline through context.setOffline, and I turn off animations through reducedMotion.
Geolocation, permissions, locale and time zone
For applications with maps, multilingual support or time zones, these settings matter:
test.use({
locale: 'uk-UA',
timezoneId: 'Europe/Kyiv',
geolocation: { latitude: 49.8397, longitude: 24.0297 },
permissions: ['geolocation'],
});
test('nearest branch in Lviv', async ({ page, context }) => {
await page.goto('/branches');
await expect(page.getByText('Lviv, Rynok Sq.')).toBeVisible();
await context.setGeolocation({ latitude: 50.4501, longitude: 30.5234 });
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByText('Kyiv')).toBeVisible();
});
Geolocation won't work without a permission: the site will get a refusal. Permissions are granted through permissions in the config or context.grantPermissions(['clipboard-read'], { origin }), and reset with context.clearPermissions(). The set of supported permissions differs slightly between browsers.
locale affects navigator.language, the Accept-Language header and the formatting of numbers and dates through Intl. This lets you check that an amount is displayed as "1 000,50 ₴" and not as "1,000.50".
timezoneId changes the browser's time zone. Classic bugs caught this way: a transaction at 23:30 Kyiv time ends up in "tomorrow's" statement because the front end converts to UTC; or a date of birth shifts by a day. I run such tests with several zones, for example Kyiv and America/New_York, through projects or parameterisation.
In an interview: For localisation I set locale and timezoneId and check the formats of amounts and dates. For geolocation both geolocation and a permission in permissions are needed. Tests with time zones help catch date shifts at the day boundary.
Clock API: controlling time
Since version 1.45 there is page.clock, which replaces Date, timers and requestAnimationFrame in the browser. This solves tasks that used to be painful: a session timer, an "offer valid for 10 minutes" banner, a status that changes at midnight.
The main methods:
clock.install({ time }) installs a fake clock with an initial time. Call it before goto, so the page loads with it already.clock.fastForward('05:00') fast-forwards, and the timers that should have fired do fire; the string is in mm:ss or hh:mm:ss format, or a number of milliseconds.clock.runFor(ms) runs time sequentially, executing each timer in turn.clock.pauseAt(time) and clock.resume() stop time at a specific moment.clock.setFixedTime(time): Date.now() always returns one value, while timers work as usual.clock.setSystemTime(time) changes the current time without firing timers.
test('session expiry warning', async ({ page }) => {
await page.clock.install({ time: new Date('2026-10-08T10:00:00') });
await page.goto('/dashboard');
await page.clock.fastForward('14:00');
await expect(page.getByRole('dialog', { name: 'Session is ending' })).toBeVisible();
await page.clock.fastForward('01:00');
await expect(page).toHaveURL(/login/);
});
Important: Clock controls time only in the browser. A session whose expiry is checked by the server won't end because of it: for the backend you need test endpoints or a short TTL on the test environment.
In an interview: For time-dependent logic I use page.clock: I set the time with install before navigation and fast-forward with fastForward. I understand it affects only the browser, so I check the server TTL separately.
Visual tests: toHaveScreenshot
await expect(page).toHaveScreenshot('dashboard.png') compares a screenshot with a baseline. The first run creates the baseline and fails, and subsequent ones compare. Baselines are updated with npx playwright test --update-snapshots.
The assertion retries: it takes screenshots until two in a row match, and only then compares with the baseline. Animations are disabled by default, and the caret in fields is hidden.
Baselines are stored with a project and platform suffix, for example dashboard-chromium-linux.png. Fonts and rendering differ between macOS and Linux, so baselines are generated and checked in one environment, usually in the official Docker image, so that local and CI runs match.
Dynamic data is the main source of flakiness in visual tests. Tools against it:
await expect(page).toHaveScreenshot('statement.png', {
mask: [page.getByTestId('balance'), page.getByTestId('timestamp')],
maxDiffPixelRatio: 0.01,
fullPage: true,
});
mask paints over the given elements.maxDiffPixels and maxDiffPixelRatio allow a small difference; threshold (0.2 by default) is the sensitivity to the colour difference of a single pixel.stylePath attaches CSS only for the duration of the screenshot, for example to hide a chat widget.- Fixed data through mocks and
page.clock gives stable content.
A screenshot of a single component is expect(locator).toHaveScreenshot(), which is more stable than a whole page. For non-graphical data there is toMatchSnapshot.
In an interview: I write visual tests with toHaveScreenshot, generate baselines in Docker so there is no difference between macOS and Linux, and mask dynamic data with mask or stabilise it with mocks and page.clock. It is better to screenshot individual components than the whole page.