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.

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 extension ships its data the same way: it exports a fixture that installs its plugin and seeds what its procedures expect. Drop it into your config and both have a world to run in.

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.

On this page