# A test loop your coding agent can run
Runs on: your Mac. You set this up once per app. After that, the agent runs
the loop on every change with the `dev:test-loop` skill.
A coding agent that cannot check its own work says "fixed" when it is not.
This guide gives it the checks: strict types, a linter, fast unit tests, tests
against a real database, a dev database full of edge cases, and click-through
scripts it follows in the iOS Simulator. Then one short block in `AGENTS.md`
makes every agent use them.
## What it costs
- **Free.** Everything runs on your Mac: TypeScript, ESLint, Vitest or Jest,
`dotnet test`, Docker, and the iOS Simulator that comes with Xcode.
- **Time:** about an hour for an existing app. Most of it goes into the first
seed and fixing what the stricter compiler finds.
- **You need:** Xcode ([xcode.md](https://onebox.lokkesveen.com/guides/xcode.md)), Docker Desktop or another
Docker engine, and an Expo app with a development build
([expo-app.md](https://onebox.lokkesveen.com/guides/expo-app.md), step 5). For the backend shape, see
[backend.md](https://onebox.lokkesveen.com/guides/backend.md).
## Steps
### 1. Strict TypeScript
In the app's `tsconfig.json`:
```jsonc
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true, // arr[i] may be undefined
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"types": ["jest", "node"] // the test runner's globals, and Node's
}
}
```
TypeScript 6 no longer loads every `@types/*` package on its own. List the
ones your code uses in `types`: `"jest"` for Jest's `test` and `expect`,
`"node"` for `fs`, `path` and `__dirname` in tests and config. With Vitest,
import `test` and `expect` from `vitest` and list only `"node"`. Without the
list, the first test fails with `Cannot find name 'test'`. Add each package
as a direct dev dependency (step 4). One that is there only through another
package can go away on the next install.
`noUncheckedIndexedAccess` finds the most real bugs, and it also finds the
most code to change. Turn it on first, fix what it reports, then add the rest.
`exactOptionalPropertyTypes` is stricter still. Try it, and drop it if library
types fight it.
Add a script, so every agent calls the same command:
```json
"scripts": { "typecheck": "tsc --noEmit" }
```
### 2. ESLint
From the Expo app folder:
```bash
npx expo lint
```
The first run installs `eslint` and `eslint-config-expo`, writes
`eslint.config.js` and adds a `lint` script to `package.json`. It can then
crash with `Cannot find module 'eslint'` (from `lintAsync.js`). Run
`npx expo lint` a second time. The second run works. Then:
- Add `"ios/*"`, `"android/*"` and `"dist/*"` to `ignores`. With Continuous
Native Generation those folders are generated. Lint errors there come back
at every prebuild.
- A new `eslint-config-expo` can turn on new rules as errors. When a
dependency bump brings many old findings, set those rules to `"warn"` with
a comment that says why, and fix them later. Do not turn them off silently.
### 3. Strict .NET
In a `Directory.Build.props` at the backend root, so it applies to every
project:
```xml
enable
true
latest-recommended
```
If warnings as errors slow you down locally, remove that line and run
`dotnet build -warnaserror` in CI and in the agent loop instead.
Next to it, an `.editorconfig`:
```ini
# EF Core writes the migrations. Analyzers skip generated code, so a
# composite index (CA1861) does not fail the strict build.
[**/Migrations/*.cs]
generated_code = true
```
Write log lines as source-generated `[LoggerMessage]` methods. Under these
settings, `log.LogInformation(...)` fails the build with CA1848. One class
holds them all:
```csharp
namespace MyApp.Api;
// CA1848 refuses the LogInformation(...) extension methods. Never log a token,
// an email or a request body (backend.md, "Protect the API", step 9).
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Warning, Message = "Apple identity token refused: {Reason}")]
public static partial void AppleTokenRefused(ILogger log, string reason);
[LoggerMessage(Level = LogLevel.Error, Message = "Job {JobId} failed")]
public static partial void JobFailed(ILogger log, Exception error, Guid jobId);
}
```
Call it as `Log.AppleTokenRefused(log, "expired")`. An `Exception`
parameter becomes the log entry's exception, not a placeholder.
### 4. Unit tests for the app
Pick one runner:
- **Jest with `jest-expo`** is Expo's default. It mocks the native modules for
you. Start here if you have no tests yet. From the Expo app folder:
```bash
npx expo install jest-expo jest @types/jest @types/node -- --save-dev
```
Then add `"jest": { "preset": "jest-expo" }` to `package.json`.
- **Vitest** is faster. It needs an alias for each React Native package that
cannot load in Node, which is more setup. One working split: `*.test.ts` for
pure logic in the `node` environment, and `*.test.tsx` for components in
`jsdom` with `react-native` aliased to `react-native-web`. Know the limit of
the second lane: it has no keyboard, no native layout and no real scroll.
Either way:
- Pin the time zone in the test config, to one with DST (`TZ: "Europe/Berlin"`
or `"America/New_York"`). Date code that only ever runs in UTC is untested.
- Add a coverage provider now (`@vitest/coverage-v8` for Vitest; Jest has one
built in). The `dev:trim-tests` skill needs it later.
- Add the script: `"test": "vitest run"` or `"test": "jest"`. For Jest, the
script can pin the time zone too: `"test": "TZ=Europe/Berlin jest"`.
### 5. Tests against a real database
An in-memory database skips SQL translation, constraints and query filters,
which are where the bugs are. Test against Postgres, the same major version
you deploy.
**.NET:** add `Testcontainers.PostgreSql`, `Microsoft.AspNetCore.Mvc.Testing`
and `coverlet.collector` to the test project. Start one container per test
run, and create one database per test fixture, so parallel test classes
cannot see each other's rows:
```csharp
// One server for the run. Pin the image to the major version you deploy.
static readonly PostgreSqlContainer Server =
new PostgreSqlBuilder("postgres:17-alpine").Build();
```
Testcontainers 4.14 and later take the image in the constructor. The empty
constructor is obsolete, and with warnings as errors it fails the build. Set
`MaxPoolSize` to a small number (5) in each fixture's connection string.
Twenty fixtures with the default pool size exhaust Postgres' 100
connections, and the tests fail with errors that look like a deadlock.
Hand each fixture's connection string to a `WebApplicationFactory`,
so the tests call the real HTTP endpoints.
**Node:** `@testcontainers/postgresql` does the same:
```ts
const pg = await new PostgreSqlContainer("postgres:17-alpine").start();
process.env.DATABASE_URL = pg.getConnectionUri();
```
Start it once per run, in Vitest's `globalSetup`, and make one database per
test file. The `start:new-app` skill has the code
(`references/node-api.md`, "Tests").
Or run a separate Postgres for tests in Docker Compose, on its own port, with
its data in memory:
```yaml
services:
db-test:
image: postgres:17-alpine
environment: { POSTGRES_PASSWORD: test }
ports: ["127.0.0.1:5439:5432"]
tmpfs: /var/lib/postgresql/data
```
When the app has its own backend, write the two tests from
[backend.md](https://onebox.lokkesveen.com/guides/backend.md), "Keep each user's data apart": one that fails when a
table has no filter, and one where user B asks for user A's row and gets 404.
The backend comes in Phase 3 of [start-here.md](https://onebox.lokkesveen.com/guides/start-here.md), so come back
to this step then. Until then, the test database is enough.
### 6. A dev database with edge cases
Write a seed that runs only in Development and creates named users for the
hard cases: an empty account, very long names, emoji and right-to-left text,
many rows for pagination, missing images, an expired subscription, a second
user with look-alike data, and dates around midnight and DST. Add a
Development-only sign-in, because Sign in with Apple does not work in the
Simulator.
The full checklist, and how to grow it from real bugs, is in the skill:
`plugins/dev/skills/test-loop/references/seed-data.md`.
Add `db:seed` and `db:reset` scripts.
### 7. One Metro port per app and per worktree
Each app, and each git worktree of it, needs its own Metro port. Otherwise the
simulator quietly runs another worktree's code, or even another app's code.
Do not leave an app on the default 8081: a second app on the same Mac uses it
too. Add the small `scripts/metro-port.sh` from
`plugins/dev/skills/test-loop/references/preflight.md`. It gives the main
checkout a port in 8200-8299 from the repo's folder name, and each worktree a
port in 8100-8199 from its path.
The script goes in the Expo app folder, next to its `package.json`
(`apps/mobile/scripts/` in a monorepo), because `package.json` scripts run
in that folder. In a new app, add it after `reset-project`, which deletes
`scripts/`. Use it in both scripts:
```json
"start": "expo start --dev-client --port $(sh scripts/metro-port.sh)",
"ios": "expo run:ios --port $(sh scripts/metro-port.sh)"
```
### 8. A simulator tool for the agent
The agent needs a way to see and tap the simulator. Some agent apps include an
iOS Simulator tool (screenshot, tap, swipe, type). If yours does not, add an
iOS Simulator MCP server. Without any tool the agent can still take
screenshots and open deep links with `xcrun simctl`, but it cannot tap.
Give the app a URL scheme (`"scheme": "myapp"` in `app.json`), so the agent
can open a screen directly: `xcrun simctl openurl "myapp://settings"`.
### 9. The rules in AGENTS.md
Copy `plugins/dev/skills/test-loop/assets/AGENTS.snippet.md` into the repo's
`AGENTS.md` (or `CLAUDE.md`). Replace the placeholders with your commands. Run
the discovery script to find them:
```bash
node /plugins/dev/skills/test-loop/scripts/discover.mjs .
```
### 10. The first flow
Pick the feature that would hurt most if it broke: sign-in, the paywall, the
main create action. Write `.flow.md` next to its code, with 5 to 15
numbered steps and an `Expect:` line after each step. The format and a full
example are in `plugins/dev/skills/test-loop/references/flows.md`.
Then ask the agent: "run the flows for ".
## Where the values go
Nothing goes into the onebox config. All of it lives in the app repo:
| What | Where |
|---|---|
| Strict flags | `tsconfig.json`, `Directory.Build.props` |
| Commands | `package.json` scripts (`lint`, `typecheck`, `test`, `db:seed`, `db:reset`) |
| Test database | the test project (Testcontainers) or `docker-compose.yml` (`db-test`) |
| Seed | the API project, run at startup in Development only |
| Agent rules | `AGENTS.md` or `CLAUDE.md` |
| Flows | `src/features//.flow.md` |
| Native build marker | `.expo/dev-loop-fingerprint.json` (Expo already git-ignores `.expo/`) |
## Check it works
1. `npm run lint`, `npm run typecheck` and `npm test` (or the pnpm or bun
equivalents) each exit 0.
2. `dotnet test` passes on a clean checkout with only Docker running. No
connection string to set.
3. Break an owner filter on purpose. The isolation test turns red. Revert.
4. Start Metro and the app, then run the preflight from the app folder:
```bash
node /plugins/dev/skills/test-loop/scripts/preflight.mjs
```
It ends with `PREFLIGHT OK`. Build once with `npx expo run:ios`, then run
it with `--mark-built`.
5. Ask the agent to change a label and verify it. Its report names the
commands it ran and gives a screenshot path, and the screenshot shows the
new label.
## Common errors
- **`dotnet test` hangs at the start.** A stale Testcontainers container or
its reaper is still running from an earlier run. List them with
`docker ps -a --filter label=org.testcontainers` and remove them with
`docker rm -f `.
- **`Docker is either not running or misconfigured`** from Testcontainers:
start Docker. The agent must report "database tests did not run", not skip
them quietly.
- **A change does not show in the simulator.** Run the preflight. The two
usual causes: the app is on another worktree's Metro, or a native package
was added after the binary was built.
- **A view renders as an empty white box.** The binary lacks that view's
native code. Rebuild. Style changes cannot fix it.
- **`expo lint` added dependencies you did not expect.** That is its setup
step. Commit the changes to `package.json` and the lockfile.
- **Tests pass locally and fail in CI on dates.** The machines are in
different time zones. Pin `TZ` in the test config (step 4).