Docs

Development & Testing

Learn how Kizlo runs a real WordPress on your machine: one to build against, one to test against.

Every WordPress project starts with the same chore: getting WordPress running on your machine. The usual route is a desktop app, one of the GUI installers WordPress points you to, and they're fine for a classic site.

But you're building a headless app, and they stop at the WordPress: no way to test against it, no seeded content, nothing tied to the project you're actually shipping. Kizlo gives you a setup built for that, the same in every project: local WordPress you build against by hand, and a disposable test WordPress your suite runs against. All you install is Docker.

npx kizlo dev
npx kizlo test

The CLI page covers both commands, their subcommands, and every config field. This page covers the ideas behind them: why a real WordPress, and which one does what.

Why a real WordPress

A mock of the WordPress REST API only proves your code agrees with your guesses about WordPress, not that the guesses are right. Kizlo's whole job is talking to a real WordPress, so building and testing against one is the point, not a compromise.

Pointing at a shared staging site isn't the answer either: you break other people's work when you break yours, and you wait on someone else to install a plugin. Local WordPress is yours alone: start it, fill it, wipe it, without asking anyone.

Docker Desktop installs in a click, and Kizlo drives it from there, and you never run a docker command yourself. The first run builds it; later runs reuse it, so both stay fast.

Two environments, one model

Both are the same Docker WordPress, and you can seed both from the same fixtures, so what you build against is what you test against. What differs is their purpose:

kizlo devkizlo test
ForBuilding your app by handRunning your suite
LifecyclePersistent, survives restartsReused warm, reset on demand
FilesFully editable on diskContainer-internal
Default URLhttp://localhost:8080http://localhost:8889
Admin loginRandom password, printed onceManaged for the test client

The two are fully independent: local WordPress can stay up while your tests spin a throwaway one beside it, and neither touches the other's data.

You seed each from fixtures in kizlo.config.ts, a separate list per command. Point both at the same ones and a post or plugin behaves identically whether you're building or testing; give them different sets when a test needs a world your dev site doesn't.

One stack per branch

Both environments are named after your project, so every checkout of it shares them. Work in one place at a time and that is what you want: the same containers and the same database, whichever directory you started from.

It stops being what you want once you keep several checkouts at once, typically git worktrees, one per branch. Two suites then truncate each other's tables while each reports its own URL. Set worktrees to give each branch its own pair:

kizlo.config.ts
export default defineConfig({
  worktrees: true,
  dev: { local: true },
  test: { local: true },
})

The branch is appended to the stack name, so shop on fix/checkout-total runs kizlo-shop-fix-checkout-total-dev. Ports are picked per stack, and an explicitly configured dev.port or test.port becomes a starting point rather than a fixed pin, since one pinned port cannot serve every branch at once.

Each stack holds its own database, so a new branch starts from your fixtures rather than from the state you left on the last one, and a branch you never return to keeps its containers until you remove them. Switching branches inside one checkout moves it to that branch's stack for the same reason.

Choosing the WordPress version

Both stacks boot current WordPress. Set version to hold one still, or to run an older release:

kizlo.config.ts
export default defineConfig({
  dev: { local: true, version: "7.1.0" },
  test: { local: true, version: "6.8.2" },
})

The value is the tag after wordpress: on Docker Hub. A bare version covers most cases; use a full tag like 6.8.2-php8.3-apache when you need a specific PHP. Each command reads its own, so you can build against current WordPress while your suite proves you still work on the oldest version you support.

Pin it once you commit a generated introspection. It describes the WordPress it was generated from, so on a moving version it goes stale the day WordPress ships, and the failure lands on whichever change you make next. A pinned version turns a core release into a diff you review.

An existing stack keeps the core files it was installed with, so changing version takes effect on the next kizlo dev reset or kizlo test reset. Kizlo warns you when a running stack and its config disagree.

Large uploads

Both stacks raise PHP's upload limit to 2 GB, well above WordPress's stock 2 MB, so a large media file or a full-site migration archive uploads without PHP rejecting the request first. It is a real server limit, not a WordPress display setting, and applies to dev and test alike whatever port a stack lands on. Production WordPress is unaffected; this only shapes the local Docker stacks.

Fixtures

A fixture is the unit of that shared content: a seed function that creates known records over the typed WordPress client, plus the plugins that content depends on. You author one with defineFixture:

fixtures/blog.ts
import { defineFixture } from "kizlo/test"

export const blogFixture = defineFixture({
  name: "blog",
  plugins: ["wordpress-seo"], // wp.org slug, zip URL, or a local { path }
  async seed(ctx) {
    // ctx.service is the typed WP client; ctx.adminId / ctx.userId are seeded users
    const post = await ctx.service.posts.create({
      slug: "hello-world-test",
      title: "Hello World Test",
      status: "publish",
      author: ctx.adminId,
    })
    if (post.error) throw post.error

    return { postId: post.data.id } // a live handle a test reads back, not a snapshot
  },
})

Each takes its own fixtures list. Share one to keep building and testing in step, or give them different sets when their worlds should differ:

kizlo.config.ts
import { defineConfig } from "kizlo/config"
import { blogFixture } from "./fixtures/blog"

export default defineConfig({
  dev: { local: true, fixtures: [blogFixture] },
  test: { local: true, fixtures: [blogFixture] },
})

Kizlo runs seed only when it provisions a fresh install, so reruns never double up.

Some setup the REST API can't express, like pretty permalinks or an option flag. For those, import wpCli from kizlo/test and call it inside seed to run any wp-cli command against WordPress.

An integration package can export a fixture beside its integration factory. The fixture stays a separate config value: drop it into dev.fixtures or test.fixtures to install the plugin and seed what the integration's procedures expect.

The development loop

You build against local WordPress like a site you installed yourself: it persists and stays editable (see the table above).

What keeps that loop tight is kizlo dev: edit a procedure and it regenerates the typed contract before you call it; change content in wp-admin and your app reads it on the next request. Nothing to restart to stay in sync.

Writing tests

Tests run against test WordPress, so they exercise real WordPress responses instead of guesses about them. You can hit a procedure at two levels.

Server-side

Call the procedure directly and get its data back, or its typed error thrown, the quickest check of what a procedure does. getKizloTestInstance wires an instance from the seeded credentials and the adapter mocks:

post.test.ts
import { beforeAll, expect, test } from "vitest"
import { getKizloTestInstance, type KizloTestInstance } from "kizlo/test"

let kizlo: KizloTestInstance
beforeAll(() => { kizlo = getKizloTestInstance() })

test("reads a seeded post by slug", async () => {
  const post = await kizlo.client.posts.get.call({ params: { identifier: "hello-world-test" } })
  expect(post.slug).toBe("hello-world-test")
})

End-to-end

Go through the real client and get back the { success } envelope your frontend sees. This catches what a direct call can't: serialization, and rules like internal procedures never leaving the server. getKizloClientTestInstance wraps a test instance with that client:

post.e2e.test.ts
import { beforeAll, expect, test } from "vitest"
import { getKizloClientTestInstance, getKizloTestInstance, type KizloClientTestInstance } from "kizlo/test"

let instance: KizloClientTestInstance
beforeAll(async () => { instance = await getKizloClientTestInstance(getKizloTestInstance()) })

test("reads a seeded post over the client", async () => {
  const result = await instance.client.posts.get({ params: { identifier: "hello-world-test" } })
  expect(result.success).toBe(true)
  if (result.success) expect(result.data.slug).toBe("hello-world-test")
})

Reach for WordPress only when a test depends on how WordPress actually responds. Pure logic, like a schema or a procedure's branching, runs on its own with no WordPress and no Docker, and those fast tests are what make the WordPress-backed ones worth trusting.

Even then, mocks live only at the edges. Identity, geo, captcha, and logging come through adapters, and each ships a mock so a test can pin a known user without a real provider. WordPress itself is never mocked.

When a provider integration bundles an adapter, append an adapter-only test integration to replace it. Integration adapters resolve left to right, so the last concrete value wins:

provider.test.ts
const testAdapters = createIntegration({
  id: "test-adapters",
  adapters: { auth: authMock({ mockUserId: 1 }) },
})

const kizlo = getKizloTestInstance({
  integrations: [provider(), testAdapters],
})

On this page