<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged testcontainers</title>
<link>https://coderlegion.com/tag/testcontainers</link>
<description>Powered by Question2Answer</description>
<item>
<title>How We Eliminated Container Boilerplate from Playwright Integration Tests</title>
<link>https://coderlegion.com/13525/how-we-eliminated-container-boilerplate-from-playwright-integration-tests</link>
<description>&lt;p&gt;Integration tests should be honest. When a service talks to Postgres, the test should talk to Postgres too — not a mock that pretends to be Postgres but lacks constraints, triggers, and the dozen behaviors that actually matter in production.&lt;/p&gt;
&lt;p&gt;The blocker has never been philosophy. It has been plumbing. Starting a container, waiting for it to be ready, passing the connection details to the test, stopping it after — and doing all of that reliably when a test throws — is tedious boilerplate that sits between you and the test you actually want to write.&lt;/p&gt;
&lt;p&gt;We solved this with &lt;code&gt;@playwright-labs/fixture-testcontainers&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-was-wrong-with-the-existing-approach&quot;&gt;What was wrong with the existing approach&lt;/h3&gt;
&lt;p&gt;The Testcontainers Node.js library is excellent. The missing piece was ergonomic integration with Playwright's test lifecycle. The typical pattern before this package looked like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { GenericContainer, StartedTestContainer } from &quot;testcontainers&quot;;
import { test } from &quot;@playwright/test&quot;;

let pg: StartedTestContainer;

test.beforeAll(async () =&amp;gt; {
  pg = await new GenericContainer(&quot;postgres:16&quot;)
    .withEnvironment({ POSTGRES_PASSWORD: &quot;secret&quot; })
    .withExposedPorts(5432)
    .start();
});

test.afterAll(async () =&amp;gt; {
  await pg?.stop();
});

test(&quot;insert and query&quot;, async () =&amp;gt; {
  // finally, the actual test
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern has several failure modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Shared state across tests.&lt;/strong&gt; Multiple tests modify the same database instance, producing order-dependent failures that are nearly impossible to reproduce in isolation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Leaking containers.&lt;/strong&gt; If &lt;code&gt;beforeAll&lt;/code&gt; throws after partially initializing, &lt;code&gt;afterAll&lt;/code&gt; may receive &lt;code&gt;undefined&lt;/code&gt; for &lt;code&gt;pg&lt;/code&gt; and fail to clean up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File-level coupling.&lt;/strong&gt; Adding a second container means another &lt;code&gt;let&lt;/code&gt;, another &lt;code&gt;beforeAll&lt;/code&gt; call, another &lt;code&gt;afterAll&lt;/code&gt; call. The infrastructure setup grows in proportion to your test file, not to your test logic.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fixture-model-fixes-all-three&quot;&gt;The fixture model fixes all three&lt;/h3&gt;
&lt;p&gt;Playwright's fixture system already manages this lifecycle for browsers, pages, and any other shared state. Fixtures are set up on demand, scoped to the test (or file, or worker), and torn down after the test ends — even if the test throws.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@playwright-labs/fixture-testcontainers&lt;/code&gt; brings that same model to Docker containers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test } from &quot;@playwright-labs/fixture-testcontainers&quot;;
import { Wait } from &quot;testcontainers&quot;;

test(&quot;insert and query&quot;, async ({ useContainer }) =&amp;gt; {
  const pg = await useContainer(&quot;postgres:16&quot;, {
    ports: 5432,
    environment: { POSTGRES_PASSWORD: &quot;secret&quot; },
    waitStrategy: Wait.forLogMessage(&quot;ready to accept connections&quot;),
  });

  // this container is scoped to this test
  // it stops automatically when the test ends
  const port = pg.getMappedPort(5432);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each test that needs a database gets its own container. No shared state, no cleanup code, no coupling between tests.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;realistic-usage-building-domain-fixtures&quot;&gt;Realistic usage: building domain fixtures&lt;/h3&gt;
&lt;p&gt;The most powerful use of this package is not the raw &lt;code&gt;useContainer&lt;/code&gt; fixture — it is using &lt;code&gt;useContainer&lt;/code&gt; as a building block for your own fixtures.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// test-helpers/fixtures.ts
import { test as base } from &quot;@playwright-labs/fixture-testcontainers&quot;;
import { Wait } from &quot;testcontainers&quot;;
import { Pool } from &quot;pg&quot;;

type Fixtures = {
  db: Pool;
  redisUrl: string;
};

export const test = base.extend&amp;lt;Fixtures&amp;gt;({
  db: async ({ useContainer }, use) =&amp;gt; {
    const container = await useContainer(&quot;postgres:16&quot;, {
      ports: 5432,
      environment: { POSTGRES_PASSWORD: &quot;secret&quot; },
      waitStrategy: Wait.forLogMessage(&quot;ready to accept connections&quot;),
    });
    const pool = new Pool({
      host: container.getHost(),
      port: container.getMappedPort(5432),
      password: &quot;secret&quot;,
      database: &quot;postgres&quot;,
      user: &quot;postgres&quot;,
    });
    await use(pool);
    await pool.end();
  },

  redisUrl: async ({ useContainer }, use) =&amp;gt; {
    const container = await useContainer(&quot;redis:8&quot;, { ports: 6379 });
    await use(`redis://${container.getHost()}:${container.getMappedPort(6379)}`);
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tests import from &lt;code&gt;fixtures.ts&lt;/code&gt; and receive ready-to-use clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { test } from &quot;./test-helpers/fixtures&quot;;
import { expect } from &quot;@playwright-labs/fixture-testcontainers&quot;;

test(&quot;user record persists&quot;, async ({ db }) =&amp;gt; {
  await db.query(`INSERT INTO users (name) VALUES ($1)`, [&quot;Alice&quot;]);
  const { rows } = await db.query(`SELECT name FROM users WHERE name = $1`, [&quot;Alice&quot;]);
  expect(rows[0].name).toBe(&quot;Alice&quot;);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The test has no idea containers are involved. It receives a &lt;code&gt;Pool&lt;/code&gt;. The infrastructure is an implementation detail of the fixture.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;all-the-options-none-of-the-extra-api&quot;&gt;All the options, none of the extra API&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;ContainerOpts&lt;/code&gt; maps one-to-one with &lt;code&gt;GenericContainer.with*&lt;/code&gt; methods. There is nothing new to learn — if you know Testcontainers, you know the options:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Option &lt;/th&gt;&lt;th&gt;Testcontainers method&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ports&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withExposedPorts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;environment&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withEnvironment&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;waitStrategy&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withWaitStrategy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;healthCheck&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withHealthCheck&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;network&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withNetwork&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bindMounts&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withBindMounts&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;copyFiles&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withCopyFilesToContainer&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pullPolicy&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withPullPolicy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;resourcesQuota&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;withResourcesQuota&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;... and 20 more &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For cases where you need the full builder, pass a pre-configured &lt;code&gt;GenericContainer&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { GenericContainer } from &quot;testcontainers&quot;;

const container = await useContainer(
  new GenericContainer(&quot;postgres:16&quot;)
    .withEnvironment({ POSTGRES_PASSWORD: &quot;secret&quot; })
    .withExposedPorts(5432)
    .withReuse(),
);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;container-assertions&quot;&gt;Container assertions&lt;/h3&gt;
&lt;p&gt;The package exports an extended &lt;code&gt;expect&lt;/code&gt; with matchers specific to &lt;code&gt;StartedTestContainer&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { expect } from &quot;@playwright-labs/fixture-testcontainers&quot;;

// state
await expect(container).toBeContainerRunning();
await expect(container).toBeContainerHealthy();
await expect(container).not.toBeContainerStopped();

// logs
await expect(container).toMatchContainerLogMessage(&quot;ready to accept connections&quot;);
await expect(container).not.toMatchContainerLogMessage(&quot;FATAL&quot;);

// ports
expect(container).toBeContainerPort(5432);
expect(container).toMatchContainerPortInRange(5432, { min: 1024, max: 65535 });

// metadata
expect(container).toHaveContainerLabel(&quot;env&quot;, &quot;test&quot;);
expect(container).toHaveContainerNetwork(&quot;app-net&quot;);
await expect(container).toHaveContainerUser(&quot;postgres&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These matchers turn container state inspection from raw Docker API calls into readable assertions that belong in test output.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting started&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -D @playwright-labs/fixture-testcontainers testcontainers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; &lt;code&gt;@playwright/test &amp;gt;= 1.57.0&lt;/code&gt;, &lt;code&gt;testcontainers &amp;gt;= 10.0.0&lt;/code&gt;, Docker available in your environment.&lt;/p&gt;
&lt;p&gt;The package is part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/vitalics/playwright-labs&quot;&gt;playwright-labs&lt;/a&gt;, an open-source collection of Playwright utilities.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you maintain a Playwright test suite and have been using mocks for anything that should really be a database or a cache, this is a practical path to replacing them without rewriting your entire test setup.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13525/how-we-eliminated-container-boilerplate-from-playwright-integration-tests</guid>
<pubDate>Thu, 26 Mar 2026 19:20:06 +0000</pubDate>
</item>
</channel>
</rss>