QA Interview Hub · Knowledge base · Quizzes
Playwright
Browser, Context, Page
Built-in fixtures and manual creation
The runner provides the fixtures browser, context and page. browser has worker scope: one launched browser per worker. context and page have test scope: a new context and page for every test, which the runner itself closes after the test.
When you need more control, you create a context manually from the browser fixture:
test('guest sees the cookies banner', async ({ browser }) => {
const context = await browser.newContext({ locale: 'uk-UA' });
const page = await context.newPage();
await page.goto('/');
await expect(page.getByRole('dialog', { name: 'Cookies' })).toBeVisible();
await context.close();
});
The runner doesn't close a manual context: that is my responsibility. If you forget context.close(), open pages will remain when a test fails, and video and HAR may not be written, because they are finalised exactly when the context is closed.
Context options are the same as the use section in the config: viewport, locale, timezoneId, storageState, permissions, extraHTTPHeaders, baseURL. The only difference is that through use they are applied to the built-in context fixture, and through newContext to my own context.
In an interview: browser lives at the worker level, while context and page are created for each test and closed automatically. If I need a separate context with different settings, I create it through browser.newContext() and close it myself, best of all in a fixture, so that the teardown runs even on a failure.
Two users in one test
A classic task: check a chat or a money transfer between two people. Here you need two independent contexts, because tabs of one context share cookies, and the second user would simply "overwrite" the first one's session.
test('message reaches the recipient', async ({ browser }) => {
const alice = await browser.newContext({ storageState: '.auth/alice.json' });
const bob = await browser.newContext({ storageState: '.auth/bob.json' });
const alicePage = await alice.newPage();
const bobPage = await bob.newPage();
await alicePage.goto('/chat/bob');
await alicePage.getByRole('textbox').fill('Hello');
await alicePage.getByRole('button', { name: 'Send' }).click();
await bobPage.goto('/chat/alice');
await expect(bobPage.getByText('Hello')).toBeVisible();
await alice.close();
await bob.close();
});
Both contexts work in one browser, so this is cheap. A separate browser for the second user isn't needed.
The opposite situation is several pages in one context through context.newPage(). They share the session, and this suits checking synchronisation between tabs of one user, for example a logout in one tab must log out the other.
In an interview: For two users I create two contexts with different storageState, because a context is a separate session. Several pages of one context share cookies, so I use them only for single-user scenarios across several tabs.
Page events as an extra check
A Page emits events you can subscribe to: console, pageerror, request, response, requestfailed, dialog, popup, download. This is useful not only for debugging, but also as an automatic quality check.
For example, unhandled JavaScript errors on a page often break functionality unnoticed by UI assertions. They can be collected and used to fail the test:
test('payment page has no JS errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay' }).click();
expect(errors).toEqual([]);
});
The difference: pageerror is an exception that nobody caught, while console with the type error is what the code itself printed through console.error. The latter is often noisy because of third-party scripts, so the list is usually filtered.
The requestfailed event helps catch requests that failed at the network level, for example blocked by CORS or a dropped connection. To avoid duplicating such code in every test, it is moved into an auto fixture.
In an interview: I subscribe to pageerror and console to catch JS errors that UI assertions don't see. To avoid copying this into every test, I make an auto fixture that checks after the test that there were no errors.