<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>TestBot Chronicles</title><description>Long-form notes on testing, behavioral contracts, AI tooling for QA, and the slow rebuild of how I think about quality.</description><link>https://testbot-chronicles.com/</link><language>en-us</language><copyright>CC BY-NC 4.0</copyright><item><title>Why Playwright fixtures stop scaling at three layers deep</title><link>https://testbot-chronicles.com/posts/playwright-fixtures-stop-scaling/</link><guid isPermaLink="true">https://testbot-chronicles.com/posts/playwright-fixtures-stop-scaling/</guid><description>A failure post-mortem about a fixture graph I built in a hurry, four layers deep. The lesson was structural, not stylistic.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a fixture graph in a hurry, four layers deep, and could not unwind it for a sprint and a half. The lesson was structural, not stylistic — and it is the kind of lesson Playwright&apos;s docs cannot give you, because they teach the mechanism, not the cost.&lt;/p&gt;
&lt;aside&gt;
  You should have written at least one Playwright test, and you should know what
  a fixture is. If you want the wider context first, the previous part in this
  series — [The page-object pattern, and what I replaced it
  with](/posts/page-object-pattern-replaced) — sets up the architectural
  backdrop. External reading: the official [Playwright fixtures
  docs](https://playwright.dev/docs/test-fixtures) are excellent and I will not
  duplicate them here.
&lt;/aside&gt;
&lt;p&gt;The Playwright fixtures API is a cleanly designed abstraction. You declare a fixture, you depend on it from another fixture, you compose, you reuse. Two layers feels great. Three layers still feels great. &lt;strong&gt;At four layers, something gives.&lt;/strong&gt;[^1]&lt;/p&gt;
&lt;p&gt;This is a post about why that happens, what the cost looks like in practice, and the refactor I landed on after deleting most of the graph and starting over.&lt;/p&gt;
&lt;h2&gt;When the graph was still tractable&lt;/h2&gt;
&lt;p&gt;Before the refactor, three Playwright API surfaces could plausibly carry a fixture graph this size. I tried two of them. The table is what I wrote on the whiteboard before picking &lt;code&gt;test.extend&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Composition Style&lt;/th&gt;
&lt;th&gt;Teardown&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test.extend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;declarative · auto DI by name&lt;/td&gt;
&lt;td&gt;fixture body after use()&lt;/td&gt;
&lt;td&gt;chosen — feels native, but the cost was hidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;base.extend + project.use&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inheritance · per-project override&lt;/td&gt;
&lt;td&gt;override at config layer&lt;/td&gt;
&lt;td&gt;rejected — couples test to project config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;worker fixtures&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;singleton per worker&lt;/td&gt;
&lt;td&gt;manual at end-of-worker&lt;/td&gt;
&lt;td&gt;rejected for our case — auth state is per-test, not per-worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/table&gt;
&lt;p&gt;Here is the fixture from the project that ran fine for about ten months. Two layers: an &lt;code&gt;authedPage&lt;/code&gt; that depends on a &lt;code&gt;browser&lt;/code&gt;, and a &lt;code&gt;checkoutPage&lt;/code&gt; that depends on the &lt;code&gt;authedPage&lt;/code&gt;. Nothing surprising.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;fixtures.ts&quot; {13}
import { test, expect } from &quot;@playwright/test&quot;;
import { login } from &quot;./helpers/auth&quot;;

// Two layers: browser → authedPage → checkoutPage. Tractable.
export const fixtures = {
  authedPage: async ({ browser }, use) =&amp;gt; {
    const ctx = await browser.newContext();
    const page = await ctx.newPage();
    await login(page);
    await use(page);
    await ctx.close();
  }, // ~600ms cold start, predictable
  checkoutPage: async ({ authedPage }, use) =&amp;gt; {
    await authedPage.goto(&quot;/checkout&quot;);
    await use(authedPage);
  },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The line I want you to look at is line 13 — the comment that says &lt;code&gt;~600ms cold start, predictable&lt;/code&gt;. That comment is the only thing in this file that has anything to say about &lt;em&gt;cost&lt;/em&gt;, and it is wrong, but it is wrong in a useful way. It is wrong because the cost was already non-linear, I just couldn&apos;t see it from inside two layers.[^2]&lt;/p&gt;
&lt;h2&gt;What the graph actually looks like yes!&lt;/h2&gt;
&lt;figure&gt;
&lt;p&gt;I had a mental model of the suite. I instrumented the run. The two are different enough that I want to put them next to each other before any diagram.&lt;/p&gt;
&lt;p&gt;&amp;lt;Compare labels={[&quot;A · MENTAL MODEL&quot;, &quot;B · MEASURED&quot;]} caption=&quot;what I told myself vs what the test runner reported&quot;&amp;gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Three layers, one chain.&lt;/li&gt;
&lt;li&gt;Five fixture nodes. Five edges.&lt;/li&gt;
&lt;li&gt;Cost is roughly the cold-start of &lt;code&gt;browser.newContext()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Adding a test is free.&lt;/li&gt;
&lt;li&gt;Total edge time: ~2.4s.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;ul&gt;
&lt;li&gt;Four layers, branching at the third.&lt;/li&gt;
&lt;li&gt;Nine nodes. Eleven edges.&lt;/li&gt;
&lt;li&gt;Cost is dominated by the fan-out at &lt;code&gt;flow.*&lt;/code&gt;, not the page object setup.&lt;/li&gt;
&lt;li&gt;Adding a test costs +2.4s.&lt;/li&gt;
&lt;li&gt;Total edge time: ~14.7s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The discrepancy is the whole article.&lt;/p&gt;
&lt;h2&gt;What I landed on after deleting most of it&lt;/h2&gt;
&lt;p&gt;The rewrite collapses the four-layer graph to two layers plus a context bag. The context bag is not a fixture — it&apos;s a plain object — and that is the entire trick.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;rewrite.ts&quot;
// Two layers + a plain context bag.
// The bag is NOT a fixture. That is the whole trick.

export const test = base.extend&amp;lt;Ctx&amp;gt;({
  ctx: async ({ authedPage }, use) =&amp;gt; {
    const bag = { page: authedPage, order: null };
    await use(bag);
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The graph is now flat at the test level. Composition happens in plain function calls inside the test body, not inside the fixture chain. Cost is now linear in fixtures, not in test count.&lt;/p&gt;
&lt;p&gt;[^1]: &quot;Something gives&quot; is doing a lot of work in this sentence. The thing that gives is your ability to predict the runtime cost of adding a new test. I&apos;ll get there.&lt;/p&gt;
&lt;p&gt;[^2]: The honest statement is: the cost was linear in the number of dependent fixtures, but my mental model treated it as constant. At three deps that&apos;s a 3× error. At seven, it&apos;s a sprint.&lt;/p&gt;
&lt;/figure&gt;</content:encoded><category>playwright</category><category>testing</category><category>fixtures</category><category>typescript</category></item></channel></rss>