QA How-To
Playwright Indeterminate Checkbox Testing Step by Step
Learn Playwright indeterminate checkbox testing step by step with runnable TypeScript, accessible locators, state transitions, and reliable assertions.
19 min read | 2,546 words
TL;DR
Set or reach the mixed state through the UI, locate the checkbox by role and accessible name, then use await expect(locator).toBeChecked({ indeterminate: true }). Also verify the checked and unchecked transitions because indeterminate is a visual DOM property, not a third submitted form value.
Key Takeaways
- Assert the live indeterminate DOM property instead of looking for an HTML attribute.
- Use toBeChecked({ indeterminate: true }) for a retrying, user-facing state assertion.
- Verify checked, unchecked, and indeterminate as distinct application states.
- Test the parent checkbox through the child actions that actually change its state.
- Check accessible mixed-state semantics separately when the control is custom-built.
- Avoid force clicks unless your test explicitly targets event behavior below the UI layer.
Playwright indeterminate checkbox testing step by step means verifying more than a gray dash. You need to prove that the control enters the mixed state, exposes the correct state to users, and leaves that state correctly when related selections change.
This tutorial builds a small permissions form and tests it with current Playwright assertions. For the broader setup, locator, fixture, and debugging context, keep the Playwright 1.5x advanced automation complete guide open as the cluster reference.
You will drive every meaningful transition through the browser. You will also learn when a DOM property assertion, an accessibility assertion, or a direct property read is the right tool.
What You Will Build
You will build and test a parent checkbox named Select all permissions with three child permissions. The parent has three visible states:
- Unchecked when no permission is selected.
- Indeterminate when some, but not all, permissions are selected.
- Checked when every permission is selected.
- Unchecked again when the user clears the complete selection.
The final suite uses role-based locators and Playwright's retrying toBeChecked() assertion. It also checks keyboard behavior, form submission meaning, and the accessible state expected from a native checkbox.
Here is the distinction that prevents most false tests:
| State | checked property |
indeterminate property |
Visual meaning | Submitted value |
|---|---|---|---|---|
| Unchecked | false |
false |
Nothing selected | Not submitted |
| Mixed | Usually false |
true |
Some children selected | Determined by checked, not indeterminate |
| Checked | true |
false |
All children selected | Submitted if named |
Indeterminate is not a third HTML form value. It is a live JavaScript property used to represent a partial selection. That fact shapes both the application code and the test design.
Prerequisites
Use Node.js 20 or newer and @playwright/test 1.50 or newer. The indeterminate option on toBeChecked() is available in Playwright 1.50+, so an older runner cannot execute the primary assertion in this guide.
Create a clean project:
mkdir checkbox-lab
cd checkbox-lab
npm init playwright@latest
Choose TypeScript, keep the default tests directory, and install browsers when prompted. Confirm the installed version:
npx playwright --version
Expected output is Version 1.50.0 or a later version. If your project already uses Playwright, update the package and browsers together:
npm install -D @playwright/test@latest
npx playwright install
You need no application framework. The tutorial loads a complete HTML fixture with page.setContent(), which keeps the example pasteable and isolates checkbox behavior from routing or backend setup.
Step 1: Create the Three-State Checkbox Fixture
Create tests/indeterminate-checkbox.spec.ts. Start with a fixture that recalculates the parent whenever a child changes.
import { test, expect } from '@playwright/test';
const permissionsForm = `
<form id="permissions-form">
<fieldset>
<legend>Repository permissions</legend>
<label>
<input id="select-all" type="checkbox" name="selectAll">
Select all permissions
</label>
<label><input type="checkbox" name="permission" value="read"> Read</label>
<label><input type="checkbox" name="permission" value="write"> Write</label>
<label><input type="checkbox" name="permission" value="admin"> Admin</label>
</fieldset>
<button type="submit">Save permissions</button>
<output id="result"></output>
</form>
<script>
const parent = document.querySelector('#select-all');
const children = [...document.querySelectorAll('[name=permission]')];
function syncParent() {
const selected = children.filter(checkbox => checkbox.checked).length;
parent.checked = selected === children.length;
parent.indeterminate = selected > 0 && selected < children.length;
}
parent.addEventListener('change', () => {
children.forEach(checkbox => { checkbox.checked = parent.checked; });
parent.indeterminate = false;
});
children.forEach(checkbox => checkbox.addEventListener('change', syncParent));
document.querySelector('#permissions-form').addEventListener('submit', event => {
event.preventDefault();
const values = new FormData(event.currentTarget).getAll('permission');
document.querySelector('#result').textContent = values.join(',');
});
</script>
`;
test.beforeEach(async ({ page }) => {
await page.setContent(permissionsForm);
});
The fixture calculates state from selected children instead of keeping a separate counter. This reduces the chance that UI state and data state drift apart. The parent change handler selects or clears all children, then removes any mixed state.
Verify the step: run npx playwright test tests/indeterminate-checkbox.spec.ts --list. Playwright should list the file without a syntax error, even though it contains no tests yet. If TypeScript reports an implicit type issue inside the HTML string, check that the backticks and closing script tag are intact.
Step 2: Prove the Initial Unchecked State
Add a test that starts from the user's visible contract. Locate the checkbox by role and accessible name rather than by its implementation ID.
test('starts with no permissions selected', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
const permissions = page.getByRole('group', {
name: 'Repository permissions',
}).getByRole('checkbox').filter({ hasNot: selectAll });
await expect(selectAll).not.toBeChecked();
await expect(selectAll).toHaveJSProperty('indeterminate', false);
await expect(permissions).toHaveCount(3);
for (const permission of await permissions.all()) {
await expect(permission).not.toBeChecked();
}
});
For this compact fixture, filtering demonstrates intent, but a production page may give the children a nested group or stable accessible names and locate them individually. The essential assertion is that checked and indeterminate both begin as false.
not.toBeChecked() alone does not prove the checkbox is not mixed. A checkbox can be logically unchecked while its indeterminate property is true. That is why the second assertion reads the property explicitly.
Verify the step: run:
npx playwright test tests/indeterminate-checkbox.spec.ts --project=chromium
The test should pass. Temporarily set parent.indeterminate = true at the end of the fixture script to confirm that the property assertion fails with an expected value of false, then undo that diagnostic change.
Step 3: Test Playwright Indeterminate Checkbox Testing Step by Step
Now select one child and assert the mixed state with Playwright's purpose-built matcher.
test('becomes indeterminate when one permission is selected', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
const read = page.getByRole('checkbox', { name: 'Read' });
await read.check();
await expect(read).toBeChecked();
await expect(selectAll).toBeChecked({ indeterminate: true });
await expect(selectAll).toHaveJSProperty('indeterminate', true);
});
check() performs an actionability check, clicks only when needed, and confirms that the target becomes checked. It better expresses the requested end state than a generic click() for this child control.
toBeChecked({ indeterminate: true }) is the main Playwright indeterminate checkbox assertion. It retries until the expected state appears or the assertion timeout expires. That retry behavior matters when a framework updates the parent after an event, render, or request. The additional toHaveJSProperty() assertion is educational here. In most suites, the semantic matcher alone is enough unless you are diagnosing the actual DOM property.
Do not write toHaveAttribute('indeterminate', 'true'). HTML does not define an indeterminate content attribute that controls native checkbox state. Setting element.indeterminate = true changes a property on the DOM object.
Verify the step: run the spec again. The new test should pass. Use npx playwright test --ui if you want to watch the child gain a checkmark and the parent display its browser-native mixed indicator.
Step 4: Verify Every State Transition
A single mixed-state assertion is incomplete. Test the sequence from none, to some, to all, and back to some.
test('moves between unchecked, mixed, and checked states', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
const read = page.getByRole('checkbox', { name: 'Read' });
const write = page.getByRole('checkbox', { name: 'Write' });
const admin = page.getByRole('checkbox', { name: 'Admin' });
await expect(selectAll).not.toBeChecked();
await read.check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await write.check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await admin.check();
await expect(selectAll).toBeChecked();
await expect(selectAll).toHaveJSProperty('indeterminate', false);
await write.uncheck();
await expect(selectAll).toBeChecked({ indeterminate: true });
await read.uncheck();
await admin.uncheck();
await expect(selectAll).not.toBeChecked();
await expect(selectAll).toHaveJSProperty('indeterminate', false);
});
This test catches stale-state bugs. For example, an implementation might set indeterminate when the first child is selected but forget to clear it after the final child becomes checked. Another implementation might correctly enter mixed state but fail to return to unchecked after the last selection is cleared.
Use toBeChecked() without the indeterminate option for the fully selected state. Pair it with a false property assertion when clearing stale mixed state is part of the contract.
Verify the step: all assertions should pass in Chromium, Firefox, and WebKit. Run npx playwright test. A cross-browser pass confirms the application logic uses the standard checkbox property instead of browser-specific styling or events.
Step 5: Test Parent Click and Keyboard Behavior
Users interact with the parent too. Native browser behavior clears indeterminate during activation and toggles checked; your change handler then applies that choice to every child. Test both pointer and keyboard paths.
test('selects all children when the mixed parent is clicked', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
const read = page.getByRole('checkbox', { name: 'Read' });
const write = page.getByRole('checkbox', { name: 'Write' });
const admin = page.getByRole('checkbox', { name: 'Admin' });
await read.check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await selectAll.click();
await expect(selectAll).toBeChecked();
await expect(selectAll).toHaveJSProperty('indeterminate', false);
await expect(read).toBeChecked();
await expect(write).toBeChecked();
await expect(admin).toBeChecked();
});
test('supports Space on the focused mixed parent', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
await page.getByRole('checkbox', { name: 'Admin' }).check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await selectAll.focus();
await expect(selectAll).toBeFocused();
await selectAll.press('Space');
await expect(selectAll).toBeChecked();
await expect(selectAll).toHaveJSProperty('indeterminate', false);
});
Use click() for the parent because the behavior under test is activation from a mixed state. Calling check() would express only the desired checked result and could skip an action if application state were already checked. The keyboard test proves that the native control remains operable without a pointer.
Verify the step: run the tests headed with npx playwright test --headed. The parent should move from a dash to a checkmark after either activation. The assertions, not your visual observation, remain the pass criteria.
Step 6: Check Accessible Mixed-State Semantics
A native <input type="checkbox"> exposes mixed state to the accessibility tree when its indeterminate property is true. You can inspect the accessible representation with an ARIA snapshot.
test('exposes mixed state to assistive technology', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
await page.getByRole('checkbox', { name: 'Write' }).check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await expect(selectAll).toMatchAriaSnapshot(`
- checkbox "Select all permissions" [checked=mixed]
`);
});
The state syntax belongs to Playwright's ARIA snapshot format. It verifies what the browser exposes, not the CSS appearance of the dash. That makes the test especially valuable for custom checkbox components, where a designer can create a convincing mixed icon while omitting aria-checked="mixed".
For a custom element, use an element with role="checkbox", manage focus and keyboard activation, and set aria-checked to false, mixed, or true. Do not add redundant ARIA to a native checkbox just to make a test pass. Prefer native semantics when the platform control meets the design.
ARIA snapshots can cover a larger stable widget structure too. Learn selective matching and dynamic-region handling in Playwright ARIA snapshot matching for dynamic pages.
Verify the step: run only this test with npx playwright test -g "assistive technology". If the snapshot differs, use await expect(selectAll).toMatchAriaSnapshot() without a template once to inspect Playwright's generated expected structure, then restore the explicit assertion.
Step 7: Verify Submitted Data, Not Just Appearance
Because indeterminate affects presentation and semantics rather than form serialization, test the selected child values at submission.
test('submits only the selected permissions in mixed state', async ({ page }) => {
const selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
await page.getByRole('checkbox', { name: 'Read' }).check();
await page.getByRole('checkbox', { name: 'Admin' }).check();
await expect(selectAll).toBeChecked({ indeterminate: true });
await page.getByRole('button', { name: 'Save permissions' }).click();
await expect(page.getByRole('status')).toHaveText('read,admin');
});
The <output> element has an implicit status role, so the last locator can follow the rendered result without relying on #result. This assertion connects the visible mixed state to the business result. A UI that displays a dash correctly but submits all permissions would still fail.
Notice that the parent has a name, but an indeterminate parent is normally unchecked and therefore is not a successful form control. Never infer selected child data from the parent alone. Read the actual child inputs or the application model.
In a real application, replace the output assertion with a network or UI confirmation that reflects the saved permissions. Keep the state assertion before submission so a failure tells you whether the problem started in selection logic or persistence.
Verify the step: run npx playwright test -g "submits only". Expected output is one passing test, and the page output becomes read,admin. Reverse the selection order only if your product does not guarantee submission ordering.
Step 8: Make the Test Reusable Without Hiding Intent
Extract a small component helper when several tests need the same controls. Keep assertions in tests so each scenario still reads as a business rule.
import type { Locator, Page } from '@playwright/test';
class PermissionsPanel {
readonly selectAll: Locator;
readonly read: Locator;
readonly write: Locator;
readonly admin: Locator;
constructor(page: Page) {
this.selectAll = page.getByRole('checkbox', {
name: 'Select all permissions',
});
this.read = page.getByRole('checkbox', { name: 'Read' });
this.write = page.getByRole('checkbox', { name: 'Write' });
this.admin = page.getByRole('checkbox', { name: 'Admin' });
}
async selectSome(...permissions: Locator[]) {
for (const permission of permissions) {
await permission.check();
}
}
}
test('keeps a reusable permissions panel readable', async ({ page }) => {
const panel = new PermissionsPanel(page);
await panel.selectSome(panel.read, panel.write);
await expect(panel.selectAll).toBeChecked({ indeterminate: true });
await expect(panel.admin).not.toBeChecked();
});
This helper centralizes locators and a common action but does not create a vague method such as verifyCorrectState(). The test still says exactly which state matters. Keep page objects small, and expose meaningful locators when direct assertions improve readability.
If setup becomes expensive or role-specific, move the panel into a lazy fixture. The Playwright fixture box and lazy initialization tutorial shows how fixture activation works. For permission matrices involving several signed-in accounts, continue with Playwright worker fixtures for multi-user testing.
Verify the step: place the imports at the top of the file, below the existing Playwright import if you split the helper into another module. Run npx playwright test. The reusable scenario and all previous scenarios should pass without test-order dependence.
Troubleshooting
Problem: toBeChecked({ indeterminate: true }) is rejected by TypeScript -> Check npx playwright --version. Upgrade @playwright/test and its browser binaries to 1.50 or newer. Also verify that your editor is resolving the workspace package rather than a global or nested older dependency.
Problem: toHaveAttribute('indeterminate', 'true') never passes -> Assert the DOM property with toHaveJSProperty('indeterminate', true) or use toBeChecked({ indeterminate: true }). The native state is a property, not a controlling HTML attribute.
Problem: the parent briefly becomes mixed, but the test sees unchecked -> Find code that recreates the input after setting its property. indeterminate is not preserved by HTML serialization, so a replacement node needs the property applied again after rendering. Use the retrying matcher, but fix the component lifecycle rather than adding a timeout.
Problem: clicking the mixed parent clears everything when you expected select all -> Define the product contract explicitly. Browser activation changes the native checkbox state, but application handlers can choose a different result. Test the agreed user outcome, and avoid assuming that every design handles mixed-state activation identically.
Problem: the dash appears, but the ARIA snapshot says unchecked -> The control may be a custom visual without accessible state. Use a native checkbox where possible. Otherwise update aria-checked to mixed, implement keyboard behavior, and keep the accessible-name locator assertion.
Problem: a click fails because another element intercepts it -> Treat the actionability failure as a product or timing signal. Wait for the actual loading condition or remove the overlay in the application flow. Do not reach for { force: true } unless the test intentionally bypasses user actionability.
Best Practices and Common Mistakes for Playwright Indeterminate Checkbox Testing Step by Step
Use these rules to keep mixed-state tests stable:
- Reach the state through child selections in at least one end-to-end scenario. Directly setting the property can be useful for component setup, but it does not test application wiring.
- Assert both entry and exit conditions. Mixed to checked and mixed to unchecked transitions reveal stale-property bugs.
- Prefer
getByRole('checkbox', { name }). It validates role and accessible naming while resisting markup refactors. - Use Playwright's web-first assertions. Do not read a property once and then add sleeps around the result.
- Keep visual regression optional. A screenshot can check custom styling, but it should supplement semantic and behavioral assertions.
- Avoid testing an
indeterminateattribute. It does not represent the live native state. - Do not treat mixed as a submitted value. Assert the child data that the application saves.
- Keep each test independent. Initialize the page in
beforeEachor through an isolated fixture.
A direct read such as await locator.evaluate(el => (el as HTMLInputElement).indeterminate) is valid for diagnostics or custom logic. For an expected condition, prefer the retrying matcher because it waits and produces a clearer failure.
Interview Questions and Answers
Use these prompts to explain the behavior in an automation interview. Concise model answers also appear in the structured interviewQnA field below.
Q: Why does an indeterminate checkbox need a separate assertion from unchecked?
The properties are independent. An input can have checked === false and indeterminate === true, so a negative checked assertion does not distinguish mixed from plain unchecked.
Q: What is the best Playwright assertion for mixed state?
Use await expect(locator).toBeChecked({ indeterminate: true }) with Playwright 1.50 or newer. It is retrying and expresses the user-visible state directly.
Q: Why is an attribute assertion usually wrong?
Indeterminate is a JavaScript DOM property, not a standard checkbox content attribute. Reading an attribute can pass for irrelevant markup or fail while the live control is correctly mixed.
Q: How would you test a custom tri-state checkbox?
Assert its checkbox role, accessible name, and aria-checked="mixed" representation, then test pointer and Space-key transitions. Also verify the underlying data selection because ARIA does not implement application behavior.
Q: Should you set indeterminate with page.evaluate() in the test?
Only when the test owns fixture setup or isolates rendering. A user-flow test should normally select child options and let production logic set the parent state.
Q: What makes the test cross-browser reliable?
Use native properties, role locators, and web-first assertions instead of CSS selectors, pixel assumptions, or fixed sleeps. Run the same transition suite in Chromium, Firefox, and WebKit.
Where To Go Next
You now have a complete behavior test for a deceptively complex control. Expand the approach according to the system around it:
- Review the complete Playwright 1.5x advanced automation guide for the larger test architecture.
- Build efficient setup with lazy Playwright fixture initialization.
- Scale permission scenarios using worker fixtures for multi-user Playwright tests.
- Stabilize accessible structure checks with ARIA snapshot matching on dynamic pages.
Choose the next guide based on the source of complexity. Use fixtures for setup complexity, worker scope for identity complexity, and ARIA snapshot techniques for dynamic accessibility trees.
Conclusion
Reliable Playwright indeterminate checkbox testing step by step starts with the right model: mixed is a live DOM and accessibility state, not a third form value. Drive the state through child actions, assert it with toBeChecked({ indeterminate: true }), and verify every path out of it.
Run the finished suite across all configured browsers. Then apply the same pattern to tree selectors, bulk-action tables, permission editors, and any control where a parent summarizes partial child selection.
Interview Questions and Answers
How would you assert an indeterminate checkbox in modern Playwright?
I would locate it by role and accessible name, drive it into partial selection through user actions, and call `await expect(locator).toBeChecked({ indeterminate: true })`. The matcher is web-first, so it retries for asynchronous UI updates. I may add `toHaveJSProperty` while diagnosing the underlying DOM state.
Why is not.toBeChecked insufficient for a mixed checkbox?
Checked and indeterminate are separate properties. A mixed parent is often not checked, so `not.toBeChecked()` can pass for both mixed and ordinary unchecked states. I assert indeterminate explicitly and verify its transition back to false.
What is the difference between the indeterminate property and aria-checked mixed?
The indeterminate property controls the live state of a native HTML checkbox, and the browser maps that state into accessibility semantics. `aria-checked="mixed"` is used by custom checkbox widgets to expose the mixed state. I prefer a native checkbox and avoid redundant ARIA when possible.
What transitions should an indeterminate checkbox test cover?
I cover none selected to mixed, mixed to all selected, all selected back to mixed, and mixed back to none. I also activate the mixed parent with pointer and keyboard. These paths catch stale indeterminate flags and incorrect bulk-selection logic.
Would you set the indeterminate property directly in an end-to-end test?
Usually no. I reach it by selecting children so the test exercises the production event and state logic. Direct property assignment is appropriate for a controlled component fixture or a focused rendering test, but it should not replace the user journey.
How do you prevent flaky mixed-state assertions?
I use role locators and Playwright's retrying assertions, initialize each test independently, and wait on meaningful application outcomes. I do not add fixed timeouts. If rerendering replaces the input, I fix the component so it reapplies the property to the current node.
How would you test a custom tri-state checkbox for accessibility?
I verify `role="checkbox"`, an accessible name, and an accessibility tree state of mixed. I test focus and Space-key activation as well as pointer behavior. Finally, I assert the selected data because correct ARIA alone does not prove business logic.
Frequently Asked Questions
How do I check an indeterminate checkbox in Playwright?
Locate the checkbox, drive the UI into a partial-selection state, and use `await expect(locator).toBeChecked({ indeterminate: true })`. This matcher requires Playwright 1.50 or newer and retries until the expected state is reached.
Can I use toHaveAttribute for the indeterminate state?
No, not for a native checkbox's live state. Indeterminate is a DOM property, so use `toBeChecked({ indeterminate: true })` or `toHaveJSProperty('indeterminate', true)`.
Is an indeterminate checkbox also checked?
Not necessarily. The `checked` and `indeterminate` properties are independent, and a common mixed parent has checked set to false while indeterminate is true. Assert the exact state your component contract requires.
Does an indeterminate checkbox submit a mixed value?
No. Indeterminate changes presentation and accessibility semantics, but form submission still follows the checked property. Test the selected child values or the application model separately.
How do I test aria-checked mixed in Playwright?
For a checkbox exposed to the accessibility tree, use an ARIA snapshot that expects `[checked=mixed]`. For custom controls, also verify the checkbox role, accessible name, focus behavior, and Space-key activation.
Should I use click or check on an indeterminate checkbox?
Use `click()` when you are testing how activation changes a mixed parent. Use `check()` when the desired contract is simply that a checkbox ends checked, such as selecting a child permission.
Why does indeterminate state disappear after a rerender?
The framework may have replaced the input node, and the property is not recreated from HTML markup. Apply the property to the current DOM node after rendering and test the lifecycle without fixed sleeps.
Related Guides
- Inspect WebSocket Frames with Playwright Step by Step
- K6 load testing tutorial: Step by Step (2026)
- Mount React Components in Playwright Step by Step
- Performance testing with k6 scripts: Step by Step (2026)
- Test Keyboard Focus Order with Playwright Step by Step
- Allure report in CI: Step by Step (2026)