Testing Effect with Bun instead of Vitest


If you're building apps with Effect, you've probably come across the @effect/vitest package. It's a nifty utility that integrates the Effect runtime into Vitest, making it a lot easier to test code that relies on effects, layers and other core constructs from the library.
But what if you're using Bun as your favorite package manager and runtime instead of Node.js and friends?
In this post, we'll dig into what @effect/vitest
actually does under the hood, why it's useful, and what's currently missing when trying to replicate the same setup with Bun. Finally, I'll walk you through how you can recreate some of that functionality with a custom setup tailored to Bun.
What does @effect/vitest
do?
First, let's look at a basic test where we just define a passing test in a describe
block:
import { describe, expect, it } from 'vitest'
describe('my test', () => {
it('should pass', () => {
expect(true).toBe(true)
})
})
Now let's say we have an Effect-based API from our service that we want to test. Technically, you could just keep using regular old Vitest. Vitest will happily await your test function if you return a promise.
// service.ts
import { Effect } from "effect"
const serviceCall = Effect.promise(
() => new Promise((resolve) => setTimeout(() => resolve("done"), 1000))
)
// service.test.ts
import { Effect } from "effect"
import { describe, expect, it } from "vitest"
describe("my test", () => {
it("should pass", () => {
return Effect.runPromise(serviceCall).then((result) => {
expect(result).toEqual("done")
})
})
})
Note: If your test runner only supports a callback-based API for signaling the end of a test, you can use
Effect.runCallback
instead.
But you'll quickly notice it becomes a bit cumbersome. You have to remember to return the promise every time, and in general it's a bit more verbose than it needs to be.
That's where the utilities from the Effect team come in.
What @effect/vitest
brings to the table
The @effect/vitest
package enhances Vitest by introducing several handy utilities:
it.effect
: Runs tests within aTestContext
, providing access to services likeTestClock
.it.live
: Executes tests using the live Effect environment. This means the clock will use the live clock.it.scoped
: Allows tests to run within a Scope, managing resource acquisition and release.it.scopedLive
: Combines scoped and live, running tests in a live environment with scope management.it.flakyTest
: Facilitates the execution of tests that might occasionally fail.
Here's how the example above would look using @effect/vitest
, where we can now just yield*
our effects and they get run automatically:
describe("my test", () => {
it.effect("should pass", () => Effect.gen(function* () {
const result = yield* serviceCall
expect(result).toEqual("done")
}))
})
This is especially handy when you want TestService
injected, which gives you control over the clock and more.
Sidenote: Effect provides a
Clock
service. If you use this instead ofDate.now()
, you'll be able to manipulate time usingTestClock
, rather than relying on Vitest's built-in time manipulation. Just be aware that by default,TestClock
is paused — so you'll need to manually advance time. This affects things likeEffect.sleep
andEffect.timeout
, as they internally use theClock
service.
The undocumented ones
Now let's talk about two APIs that aren't listed in the README but can be useful.
layer
There's a layer
utility that lets you provide a specific layer to the inner effects. It even gives you a custom it
inside the callback, which removes the layer requirement from your effect's type signature.
describe("layer", () => {
layer(Foo.LiveLayer)((it) => {
it.effect("adds context", () =>
Effect.gen(function* () {
const foo = yield* Foo
expect(foo).toEqual("foo")
}))
})
})
This is very convenient when writing tests for services or compositions that depend on specific layers.
it.prop
If you're deeper into the ecosystem and use Schemas a lot, you'll appreciate it.prop
. It's a utility built on top of fast-check.
Instead of hardcoding test inputs, you define the shape of your input, like "any string" and fast-check will generate a bunch of semi-random values for it. They're deterministic with a seed, so tests remain reproducible.
Combined with schemas, this becomes a very powerful tool.
So how is "it" done?
Let's take a look under the hood.
Types
// The Effect team likes to place type
// declarations under an umbrella
// namespace.
export namespace Vitest {
// This is the type for a single test,
// which can be passed to Vitest's `it(name, () => self, options)`.
export interface Test<R> {
<A, E>(
name: string,
self: TestFunction<A, E, R, [V.TestContext]>,
timeout?: number | V.TestOptions
): void
}
// The tester can have sub-commands,
// allowing for calls like `it()` and `it.skip()`.
export interface Tester<R> extends Vitest.Test<R> {
skip: Vitest.Test<R>
skipIf: (condition: unknown) => Vitest.Test<R>
...
// This defines the new it extension,
// which injects test services, as seen in `it.effect`.
export interface MethodsNonLive<R = never, ExcludeTestServices extends boolean = false> extends API {
readonly effect: Vitest.Tester<(ExcludeTestServices extends true ? never : TestServices.TestServices) | R>
// The same applies to live variants like `it.live`,
// which use the actual system clock.
export interface Methods<R = never> extends MethodsNonLive<R> {
readonly live: Vitest.Tester<R>
// Finally, we export the new methods by extending
// the existing `it` from Vitest.
export const it: Vitest.Methods = Object.assign(V.it, methods)
Creating the methods
Now that we've got the types, let's peek inside the internal package to see how these methods are actually implemented.
// internal.ts
// These are the `methods` being
// exported from the internal package.
export const {
effect,
...
} = makeMethods(V.it)
// index.ts
// Add these methods to it, so you can keep using it with or without the effect utils
export const it: Vitest.Methods = Object.assign(V.it, methods)
Here's where it gets interesting. You'll notice the methods are also assigned to it
early on. This might be a remnant of an older design, as they're reassigned later anyway. What's more important is how the TestEnv
gets provided. This is done by creating a mapEffect
(basically Effect.provide(TestEnv)
) and passing it along with the it
function.
This mapEffect
essentially gets applied after your effect using yourEffect.pipe(mapEffect)
export const makeMethods = (it: V.TestAPI): Vitest.Vitest.Methods =>
Object.assign(it, {
effect: makeTester<TestServices.TestServices>(Effect.provide(TestEnv), it),
scoped: makeTester<TestServices.TestServices | Scope.Scope>(
flow(Effect.scoped, Effect.provide(TestEnv)), it
),
live: makeTester<never>(identity, it),
...
Yeah, it looks a little convoluted, but this setup lets us support things like it.scoped
too, by passing in Effect.scoped
, which removes the Scope.Scope
requirement from your effect. Meanwhile, live
just passes the identity function, essentially defined as: (a) => a
Note:
flow
is just reverse-orderpipe
, so it could have been used instead as the order doesn't matter here.
Let's take a quick look at the TestEnv
:
const TestEnv = TestEnvironment.TestContext.pipe(
Layer.provide(Logger.remove(Logger.defaultLogger))
)
It's essentially the TestContext
(which includes TestClock
and more), but with the logger removed. If you want logging during your tests, you'll need to provide your own logging layer.
Personally, I like to define my own additional TestLayer
where I can pass in and configure these things manually.
So, how does the call to it.effect
actually result in a proper Vitest test?
This is where we wire everything up to Vitest's it
function. We also map helper functions like it.skip
, so things like skipped test counts still show up in the output nicely:
const makeTester = <R>(
mapEffect: <A, E>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, never>,
it: V.TestAPI = V.it
): Vitest.Vitest.Tester<R> => {
// the it(() => run())
const run = <A, E, TestArgs extends Array<unknown>>(
ctx: V.TestContext & object,
args: TestArgs,
self: Vitest.Vitest.TestFunction<A, E, R, TestArgs>
) => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx))
// map to actual it(name, …) call
const f: Vitest.Vitest.Test<R> = (name, self, timeout) =>
it(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self))
// map to the it.skip(name, …) call
const skip: Vitest.Vitest.Tester<R>["only"] = (name, self, timeout) =>
it.skip(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self))
...
Finally, we get to the execution part. The ultimate goal is to run your effect as a promise and return it - so Vitest knows to wait for async completion.
You'd think this happens in the last line. And it sort of does, but there's more going on.
We wrap our test effect using Effect.exit(effect)
, which captures the result regardless of whether it succeeds, fails, or "crashes" with a defect. We then fork this into a fiber.
Here's the clever bit: we hook into Vitest's internal test context, specifically onTestFinished
, which is called when the test ends. The documentation is a bit vague, but my guess is it also fires when your test times out. This is key, because it lets us gracefully interrupt your effect-based test, ensuring things like DB connections or file handles are properly closed.
Afterward, we join the fiber again to get the result of the execution. That result is returned as a function.
Why a function, you ask? Well… because logging is effectful. And since the logger isn't provided/enabled by default as we saw earlier, this may seem a bit convoluted - but it's consistent with Effect's design principles.
const runTest = (ctx?: Vitest.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => runPromise(ctx)(effect)
const runPromise = (ctx?: Vitest.TestContext) => <E, A>(effect: Effect.Effect<A, E>) =>
Effect.gen(function*() {
const exitFiber = yield* Effect.fork(Effect.exit(effect))
ctx?.onTestFinished(() =>
Fiber.interrupt(exitFiber).pipe(
Effect.asVoid,
Effect.runPromise
)
)
const exit = yield* Fiber.join(exitFiber)
if (Exit.isSuccess(exit)) {
return () => exit.value
} else {
const errors = Cause.prettyErrors(exit.cause)
for (let i = 1; i < errors.length; i++) {
yield* Effect.logError(errors[i])
}
return () => {
throw errors[0]
}
}
}).pipe(Effect.runPromise).then((f) => f())
If you always wanted to log test execution failures, you could instead do:
.pipe(Effect.runPromise).then(exitValue => {
if (Exit.isSuccess(exit)) {
return exit.value
} else {
const errors = Cause.prettyErrors(exit.cause)
console.error(errors)
throw errors[0]
}
})
And that's a wrap... or is it?
What About bun test
?
Bun unfortunately has a slightly different API than Vitest. For one, it doesn't pass a context object into your it
functions, not even a hidden one. That means we have to remove parts of the Vitest logic when adapting for Bun.
More importantly: since there's no test context, your test timeouts won't interrupt your effects. That means resources like DB connections might not be released cleanly unless you manually handle it.
If you're okay with that limitation (or your tests don't need resource cleanup), Bun works just fine - and fast. 🚀
I've forked the @effect/vitest
package and adapted it for Bun, so I can run my tests using bun test
.
I haven't implemented it.prop
yet, mainly because I don't have a personal need for it. Contributions are welcome, so feel free to open a PR if you need it!
You can find the source code here: https://github.com/DomiR/bun-test
bun add -D @domir/bun-test
Then import your test utilities from @domir/bun-test
just like you would with @effect/vitest
. You'll be able to use native bun test
, which is super fast and doesn't require any extra dependencies - perfect for Bun-first apps.
If the Effect team is interested, I'd be happy to contribute this work to the official repository. Please let me know!
Subscribe to my newsletter
Read articles from DomiR directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
