6.0 KiB
Effect Test Migration
Move tests that exercise Effect services out of Promise-land and into the
shared testEffect pattern.
This file is guidance, not a live inventory. Before claiming a migration,
search current dev for the exact anti-pattern and update any PR notes
with what you actually changed.
Target Pattern
Every Effect service test should have one local runner near the top:
const it = testEffect(layer)
Use the runner method that matches the behavior:
it.effect("pure service behavior", () =>
Effect.gen(function* () {
const service = yield* SomeService.Service
expect(yield* service.run()).toEqual("ok")
}),
)
it.instance("instance-local behavior", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
it.live("live filesystem or process behavior", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
// real clock / fs / git / process work
}),
)
Choosing The Runner
it.effect(...)— pure Effect behavior withTestClockandTestConsole.it.instance(...)— service behavior that needs one scoped opencode instance.it.live(...)— real time, filesystem mtimes, child processes, git, locks, servers, watchers, or OS behavior.
Most integration-style tests use it.live(...) or it.instance(...).
Layer Rules
Compose tests from open service layers when a dependency needs replacing.
Do not use a closed defaultLayer and then try to override an inner
dependency after it has already been provided.
Prefer small reusable fake boundary layers in test/fake/*:
AuthTest.empty
AccountTest.empty
NpmTest.noop
SkillTest.empty
ProviderTest.fake().layer
Use Layer.mock for partial service stubs. Missing methods should fail
loudly if the test accidentally calls them.
Do not add generic test-layer builders until repeated local compositions prove the need.
Fixture Rules
Use Effect-aware fixtures from test/fixture/fixture.ts:
TestInstanceinsideit.instance(...)for the current temp instance.tmpdirScoped(...)insideEffect.genfor extra temp directories.provideInstance(dir)(effect)when one test needs to switch instance context.provideTmpdirInstance((dir) => effect, options)when a live test needs custom instance setup or multiple instance scopes.disposeAllInstances()inafterEachonly for integration tests that intentionally touch shared instance registries.
Avoid mutable global setup. If a global mutation is unavoidable during a migration, scope it with acquire/release and treat it as temporary.
Long term, tests should not toggle process.env, Global.Path, or
mutable flags when behavior can be modeled with services. Prefer layers
such as RuntimeFlags.layer(...) or focused fake services.
Anti-Patterns To Remove
test(..., async () => Effect.runPromise(...))- local
run(...),load(...),svc(...), orruntime.runPromise(...)wrappers that only provide a layer tmpdir()plus legacy instance provision in Promise test bodies- custom
ManagedRuntime.make(...)in test files - Promise
try/catcharound Effect failures Promise.withResolvers,Bun.sleep, orsetTimeoutfor synchronization when events,Deferred, fibers, or deterministic state checks fit- mutable env/global/flag changes after layers are built
Promise helpers are acceptable at non-Effect boundaries, but yield them from
inside an Effect body with Effect.promise(...) rather than making them the
test harness.
Conversion Recipe
- Identify the real service under test and whether its open
layeror closeddefaultLayeris appropriate. - Build one top-level
layerwith real dependencies where relevant and fake layers at slow or external boundaries. - Replace local Promise wrappers with Effect helpers.
- Convert
test(..., async () => { ... })toit.effect,it.instance, orit.live. - Move
awaitcalls insideEffect.genasyield*calls. - Replace
await using tmp = await tmpdir(...)withyield* tmpdirScoped(...)when the temp directory lives inside the Effect test. - Replace Promise failure assertions with
Effect.exit,Effect.flip, or focused assertion helpers. - Preserve concurrency with fibers,
Deferred, andEffect.all(..., { concurrency: "unbounded" }); do not accidentally serialize formerly parallel behavior. - Run the focused test file and
bun typecheckfrompackages/opencode.
Good Examples
Use current examples as patterns, but re-check them before copying because test migrations are active:
test/effect/instance-state.test.ts— scoped directories, instance switching, disposal, and concurrency.test/bus/bus-effect.test.ts—Deferred, streams, scoped fibers.test/agent/plugin-agent-regression.test.ts— real service layers plus fake boundary layers.test/account/service.test.ts— service-level live tests, typed errors, fake HTTP clients.
Migration Queue Policy
Do not maintain a long file checklist here. It goes stale quickly.
When looking for the next target, search for current anti-patterns:
git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test
Then choose one file or one small cluster, keep the PR focused, and mention the focused verification in the PR body.
Rough Edges To Watch
- Failure assertions against
Exit/Causecan get verbose. Add helpers only after the same shape repeats across multiple files. - Some tests still need
Effect.promise(...)around Node/Bun APIs. Prefer Effect platform services when the surrounding code already uses them, but do not block useful migrations on perfect abstraction. - Layer composition can be noisy when a test needs real service subtrees plus
fake boundaries. Extract small
test/fake/*layers before inventing larger builders. - Concurrency tests can get harder to read after replacing Promise resolvers. Look for repeated patterns that deserve named helpers.