There's a specific kind of frustration that comes from staring at a failed test and not knowing what it's actually telling you. The test is called checkoutFlow_completesSuccessfully and it's failing — but is it the price calculation? The address validation? The payment confirmation? You re-run it locally, step through it manually, and spend twenty minutes discovering that the shipping cost was returning null because of a tax API change that had nothing to do with the three other things you were checking in that same test.
I've been in that situation more times than I care to count. It's not a tooling problem. It's a design problem.
The principle is simple to state: each test should have exactly one reason to fail. The implementation, however, requires you to rethink habits that feel productive on the surface — habits like grouping related assertions together, reusing expensive setup by packing more checks into a single test, and treating each test class as a comprehensive audit of a feature.
What "One Reason to Fail" Actually Means
This isn't a strict rule that each test file must contain exactly one expect() call. That interpretation leads to absurdity. A single logical assertion can sometimes require multiple technical assertion calls — checking that an API response has the right status code and that the body parses to the expected schema are arguably part of the same single concern: did this endpoint return a valid response?
What the principle actually means is that when a test fails, you should be able to describe the failure in a single sentence without using the word "and."
returnsCorrectTotal_whenApplyingPercentageDiscount— fails because the discount calculation is wrong.displaysErrorMessage_whenEmailFieldIsEmpty— fails because the validation message isn't rendering.redirectsToLogin_whenSessionExpires— fails because the redirect isn't happening.
Notice the structure. The test name is the assertion. If you can't name the test without using "and," it's doing too much.
Contrast that with how tests often get written under deadline pressure:
test('user profile page works correctly', async ({ page }) => {
await page.goto('/profile');
await expect(page.locator('[data-testid="username"]')).toHaveText('john_doe');
await expect(page.locator('[data-testid="email"]')).toHaveText('john@example.com');
await expect(page.locator('[data-testid="avatar"]')).toBeVisible();
await expect(page.locator('[data-testid="edit-button"]')).toBeEnabled();
await expect(page.locator('[data-testid="logout-button"]')).toBeVisible();
});
This test will fail if any one of five completely independent things breaks. And the Playwright test runner — even with --reporter=list — will only show you the first failure by default. The other four problems are invisible until you fix the first.
The Real Cost of Bundled Assertions
The argument for grouping assertions is usually about speed. One beforeEach setup, one page navigation, one API call — why run it five times when once will do?
This argument is correct about setup cost and incorrect about the tradeoff.
When I was working on an e-commerce platform test suite a couple of years back, we had tests averaging eight to twelve assertions each. Our CI pipeline on GitHub Actions ran in about fourteen minutes for the full suite. After we split the multi-assertion tests into focused single-concern tests, the suite grew from ~200 tests to ~600 tests. The pipeline time went up to eighteen minutes — but the diagnostic time when builds failed dropped dramatically. Previously, a failing build required an average of about forty minutes of investigation before someone understood what had actually broken. After the refactor, that number dropped to under ten minutes consistently.
Fourteen minutes of CI time is cheap. Forty minutes of senior engineer time is not.
There's also a subtler problem with bundled assertions: they create assertion shadowing. In most frameworks, including Playwright and Jest, a failing assertion stops test execution at that point. The assertions below it never run. So you don't just have one failed test — you have one failed test that's actively hiding other failures. During an active refactor or a deployment with multiple changes, this can give you a completely misleading picture of what's broken.
If you're using Playwright 1.43+, you can work around this partially with expect.soft(), which continues execution after failure:
await expect.soft(page.locator('[data-testid="username"]')).toHaveText('john_doe');
await expect.soft(page.locator('[data-testid="email"]')).toHaveText('john@example.com');
I use expect.soft() as a transitional tool, not a solution. It surfaces all the failures, which is better, but the test still has multiple reasons to fail and multiple things to diagnose.
Structuring Tests That Scale
Once you accept the principle, the practical question becomes: how do you structure tests so that splitting them doesn't create a maintenance nightmare?
The key is shared setup functions, not shared test bodies. Your setup — logging in, navigating to a page, seeding test data — should live in helpers or fixtures that multiple tests call independently. The tests themselves stay focused.
In Playwright, this maps directly to fixtures:
// fixtures.ts
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="submit"]');
await page.waitForURL('/dashboard');
await use(page);
}
});
// profile.spec.ts
test('displays username from profile data', async ({ loggedInPage }) => {
await loggedInPage.goto('/profile');
await expect(loggedInPage.locator('[data-testid="username"]')).toHaveText('john_doe');
});
test('displays email from profile data', async ({ loggedInPage }) => {
await loggedInPage.goto('/profile');
await expect(loggedInPage.locator('[data-testid="email"]')).toHaveText('john@example.com');
});
Yes, this navigates to /profile twice. Yes, that's acceptable. The login fixture cost is shared via browser context reuse when you configure storageState, and the actual page navigation is rarely the performance bottleneck you think it is.
For API testing with something like Supertest or RestAssured, the same principle applies but the setup cost is lower, so the excuse to bundle assertions is even weaker:
describe('GET /api/orders/:id', () => {
test('returns 200 for existing order', async () => {
const res = await request(app).get('/api/orders/123').set('Authorization', token);
expect(res.status).toBe(200);
});
test('returns order id in response body', async () => {
const res = await request(app).get('/api/orders/123').set('Authorization', token);
expect(res.body.id).toBe('123');
});
test('returns 404 for non-existent order', async () => {
const res = await request(app).get('/api/orders/999').set('Authorization', token);
expect(res.status).toBe(404);
});
});
Is this more code than a single test that checks all three? Yes. Is the additional code load meaningful? Not compared to the diagnostic clarity you get.
Where the Principle Has Limits
I don't apply this rule to smoke tests or sanity checks. When I'm writing a single end-to-end "is the system alive" test that runs before a deployment, I'll absolutely check multiple things in one test — that's the appropriate tool for that job. The goal there is fast go/no-go feedback, not diagnostic precision.
I also don't apply it blindly to tests that are genuinely checking a single composite behavior. Testing that a sorted list is in ascending order by checking items[0] <= items[1] <= items[2] involves multiple comparison operations, but it's one logical assertion: the sort order is correct.
The principle also doesn't excuse poorly named tests. I've seen codebases where developers split assertions into individual tests but kept names like test_scenario_3b or validates_correctly. The naming matters as much as the splitting. A test name should read like a specification: sortedResults_displayLowestPriceFirst. If your team reads the test name and can immediately understand what's broken when it fails, you've done it right.
The Long Game
Tests are documentation. Not the kind you write once and forget, but the kind that runs every time someone pushes code and tells you whether the system still does what it's supposed to do.
When I look at a well-structured test suite — one where each test has exactly one reason to fail, where names read like specs, where failures point directly to the broken thing — I can hand it to a developer who's never seen the codebase and they'll understand the system's behavior in an afternoon.
That's the actual goal. Not coverage percentages, not green CI badges, not test counts. A test suite that communicates clearly when things break and precisely what is broken.
Structuring your assertions around a single failure reason is one of the highest-leverage changes you can make to an existing test suite. It costs time upfront and pays back continuously every time your build breaks — which, if your suite is doing its job, should be fairly often.
Abdulkadir Akyurt is a QA Engineer and Test Automation Specialist.
Follow me on Medium for more articles · Connect on LinkedIn · Instagram · X / Twitter · Visit abdulkadirakyurt.com