Agent Skills: Effect-TS Testing Patterns

Comprehensive testing patterns for Effect-TS services, errors, layers, and effects. Use this skill when writing tests for Effect-based code.

UncategorizedID: enitrat/skill-issue/effect-testing

Install this agent skill to your local

pnpm dlx add-skill https://github.com/enitrat/skill-issue/tree/HEAD/plugins/personal-skills/skills/effect-testing

Skill Files

Browse the full folder contents for effect-testing.

Download Skill

Loading file tree…

plugins/personal-skills/skills/effect-testing/SKILL.md

Skill Metadata

Name
effect-testing
Description
Comprehensive testing patterns for Effect-TS services, errors, layers, and effects. Use this skill when writing tests for Effect-based code.

Effect-TS Testing Patterns

Comprehensive testing patterns for Effect-TS services, errors, layers, and effects. Use this skill when writing tests for Effect-based code.

Core Testing Setup

@effect/vitest Integration

import { describe, it, expect } from "@effect/vitest"
import { Effect } from "effect"

// Basic test - it.effect provides TestContext automatically
it.effect("test name", () =>
  Effect.gen(function* () {
    const result = yield* someEffect
    expect(result).toBe(expected)
  })
)

// Test with layers - provide dependencies to all tests
it.layer(MyServiceLive)("test with service", () =>
  Effect.gen(function* () {
    const service = yield* MyService
    const result = yield* service.doSomething()
    expect(result).toBe(expected)
  })
)

// Scoped tests - automatically handles resource cleanup
it.scoped("test with resources", () =>
  Effect.gen(function* () {
    const resource = yield* acquireResource
    // resource automatically released after test
    yield* useResource(resource)
  })
)

Key Features:

  • it.effect - automatic TestContext provision (TestClock, TestRandom, etc.)
  • it.layer - provide layers to test suite, shared across tests
  • it.scoped - automatic resource cleanup
  • Full fiber dumps with causes, spans, and logs for better errors

Testing Services

Mock Service with Layer.succeed

import { Effect, Context, Layer } from "effect"

// Service definition
class DatabaseService extends Context.Tag("DatabaseService")<
  DatabaseService,
  {
    readonly query: (sql: string) => Effect.Effect<unknown>
  }
>() {}

// Live implementation (production)
export const DatabaseServiceLive = Layer.succeed(
  DatabaseService,
  {
    query: (sql) => Effect.promise(() => realDatabase.query(sql))
  }
)

// Test implementation (mocked)
export const DatabaseServiceTest = Layer.succeed(
  DatabaseService,
  {
    query: (sql) => Effect.succeed({ rows: [{ id: 1, name: "test" }] })
  }
)

// Usage in test
it.layer(DatabaseServiceTest)("queries database", () =>
  Effect.gen(function* () {
    const db = yield* DatabaseService
    const result = yield* db.query("SELECT * FROM users")
    expect(result).toEqual({ rows: [{ id: 1, name: "test" }] })
  })
)

Convention: Use "Live" suffix for production, "Test" suffix for mocks.

Mock Service with Layer.mock

import { Layer } from "effect"

// Partial mock - only implement methods you need
const PartialDatabaseMock = Layer.mock(DatabaseService, {
  query: (sql) => Effect.succeed({ rows: [] })
  // Other methods throw UnimplementedError when called
})

// Full mock with test doubles
const MockWithSpy = Layer.succeed(DatabaseService, {
  query: vi.fn().mockReturnValue(Effect.succeed({ rows: [] }))
})

Testing Services with Dependencies

class UserService extends Context.Tag("UserService")<
  UserService,
  {
    readonly getUser: (id: number) => Effect.Effect<User, UserNotFound>
  }
>() {}

class EmailService extends Context.Tag("EmailService")<
  EmailService,
  {
    readonly sendEmail: (to: string, body: string) => Effect.Effect<void>
  }
>() {}

// Service that depends on other services
class NotificationService extends Context.Tag("NotificationService")<
  NotificationService,
  {
    readonly notifyUser: (userId: number) => Effect.Effect<void, UserNotFound>
  }
>() {
  static Live = Layer.effect(
    NotificationService,
    Effect.gen(function* () {
      const users = yield* UserService
      const email = yield* EmailService

      return {
        notifyUser: (userId) =>
          Effect.gen(function* () {
            const user = yield* users.getUser(userId)
            yield* email.sendEmail(user.email, "Notification")
          })
      }
    })
  )
}

// Test with all dependencies mocked
const TestLayer = Layer.mergeAll(
  UserServiceTest,
  EmailServiceTest
).pipe(Layer.provideMerge(NotificationService.Live))

it.layer(TestLayer)("sends notification", () =>
  Effect.gen(function* () {
    const notif = yield* NotificationService
    yield* notif.notifyUser(1)
    // Verify email was sent using mocked EmailService
  })
)

Testing Errors

Expected Error Testing

import { Effect, Exit } from "effect"

class MyError extends Data.TaggedError("MyError")<{
  readonly message: string
}> {}

it.effect("handles expected errors", () =>
  Effect.gen(function* () {
    const result = yield* Effect.exit(
      Effect.fail(new MyError({ message: "test error" }))
    )

    // Check error occurred
    expect(Exit.isFailure(result)).toBe(true)

    // Check error type
    if (Exit.isFailure(result)) {
      const cause = result.cause
      expect(Cause.isFailType(cause)).toBe(true)

      // Extract error value
      const error = Cause.failureOption(cause)
      expect(error).toBeSome()
      expect(Option.getOrThrow(error)).toBeInstanceOf(MyError)
    }
  })
)

// Alternative: use catchTag to verify error
it.effect("catches specific error type", () =>
  Effect.gen(function* () {
    let caught = false

    yield* effectThatFails.pipe(
      Effect.catchTag("MyError", (error) =>
        Effect.sync(() => {
          caught = true
          expect(error.message).toBe("test error")
        })
      )
    )

    expect(caught).toBe(true)
  })
)

Multiple Error Types

type UserServiceError = UserNotFound | DatabaseError | ValidationError

it.effect("handles multiple error types", () =>
  Effect.gen(function* () {
    const result = yield* Effect.either(service.getUser(999))

    expect(Either.isLeft(result)).toBe(true)

    if (Either.isLeft(result)) {
      // Pattern match on error type
      const error = result.left
      if (error._tag === "UserNotFound") {
        expect(error.userId).toBe(999)
      }
    }
  })
)

Defect Testing (Unexpected Errors)

it.effect("handles unexpected errors (defects)", () =>
  Effect.gen(function* () {
    const result = yield* Effect.exit(
      Effect.die(new Error("Unexpected"))
    )

    expect(Exit.isFailure(result)).toBe(true)

    if (Exit.isFailure(result)) {
      expect(Cause.isDie(result.cause)).toBe(true)
    }
  })
)

Testing with TestClock

import { TestClock } from "effect"

it.effect("delays execution", () =>
  Effect.gen(function* () {
    let executed = false

    // Fork effect that delays 1 second
    const fiber = yield* Effect.fork(
      Effect.delay("1 second")(
        Effect.sync(() => { executed = true })
      )
    )

    // Verify not executed yet
    expect(executed).toBe(false)

    // Advance time by 1 second
    yield* TestClock.adjust("1 second")

    // Wait for fiber to complete
    yield* Fiber.join(fiber)

    // Verify executed after time advance
    expect(executed).toBe(true)
  })
)

// Testing intervals
it.effect("processes scheduled tasks", () =>
  Effect.gen(function* () {
    const results: number[] = []

    const fiber = yield* Effect.fork(
      Effect.repeat(
        Effect.sync(() => results.push(Date.now())),
        Schedule.spaced("100 millis")
      ).pipe(Effect.timeout("1 second"))
    )

    // Advance time in increments
    yield* TestClock.adjust("100 millis")
    yield* TestClock.adjust("100 millis")
    yield* TestClock.adjust("100 millis")

    yield* Fiber.join(fiber)

    expect(results.length).toBeGreaterThan(0)
  })
)

Testing with TestRandom

import { TestRandom } from "effect"

it.effect("deterministic random values", () =>
  Effect.gen(function* () {
    // Set fixed random seed for reproducibility
    yield* TestRandom.setSeed(42)

    const random1 = yield* Random.next
    const random2 = yield* Random.next

    // Reset seed - same values again
    yield* TestRandom.setSeed(42)
    const random3 = yield* Random.next

    expect(random3).toBe(random1)
  })
)

Testing Layers

Fresh Layers Per Test

// Helper to create fresh layer for each test
const makeFreshLayer = () =>
  Layer.succeed(MyService, {
    state: { counter: 0 }, // Fresh state per test
    increment: function() {
      this.state.counter++
      return Effect.succeed(this.state.counter)
    }
  })

describe("isolated tests", () => {
  it.effect("test 1", () =>
    Effect.gen(function* () {
      const service = yield* MyService
      const result = yield* service.increment()
      expect(result).toBe(1) // Fresh state
    }).pipe(Effect.provide(makeFreshLayer()))
  )

  it.effect("test 2", () =>
    Effect.gen(function* () {
      const service = yield* MyService
      const result = yield* service.increment()
      expect(result).toBe(1) // Fresh state again
    }).pipe(Effect.provide(makeFreshLayer()))
  )
})

Layer Composition Testing

// Test layer dependencies are wired correctly
it.effect("layer composition", () =>
  Effect.gen(function* () {
    // This test verifies all dependencies resolve
    const service = yield* TopLevelService
    const result = yield* service.operation()
    expect(result).toBeDefined()
  }).pipe(
    Effect.provide(
      TopLevelService.Live.pipe(
        Layer.provide(MiddleService.Live),
        Layer.provide(BottomService.Live)
      )
    )
  )
)

Testing ConfigProvider

import { ConfigProvider, Layer } from "effect"

// Mock config for tests
const TestConfig = Layer.setConfigProvider(
  ConfigProvider.fromMap(
    new Map([
      ["API_KEY", "test-key"],
      ["DATABASE_URL", "postgres://test"],
      ["PORT", "3000"]
    ])
  )
)

it.layer(TestConfig)("uses test configuration", () =>
  Effect.gen(function* () {
    const apiKey = yield* Config.string("API_KEY")
    expect(apiKey).toBe("test-key")
  })
)

Testing HTTP Clients

import { FetchHttpClient, HttpClient } from "@effect/platform"

// Mock fetch for testing
const MockFetch = Layer.succeed(
  FetchHttpClient.Fetch,
  () => Promise.resolve(
    new Response(JSON.stringify({ data: "test" }), {
      status: 200,
      headers: { "content-type": "application/json" }
    })
  )
)

it.layer(MockFetch)("makes HTTP request", () =>
  Effect.gen(function* () {
    const client = yield* HttpClient.HttpClient
    const response = yield* client.get("https://api.example.com/data")
    const json = yield* response.json
    expect(json).toEqual({ data: "test" })
  })
)

Testing Concurrent Effects

it.effect("race condition handling", () =>
  Effect.gen(function* () {
    const ref = yield* Ref.make(0)

    // Run 100 concurrent increments
    yield* Effect.all(
      Array.from({ length: 100 }, () =>
        Ref.update(ref, (n) => n + 1)
      ),
      { concurrency: "unbounded" }
    )

    const final = yield* Ref.get(ref)
    expect(final).toBe(100) // Ref is atomic
  })
)

it.effect("parallel execution", () =>
  Effect.gen(function* () {
    const fiber1 = yield* Effect.fork(longRunningTask1)
    const fiber2 = yield* Effect.fork(longRunningTask2)

    const [result1, result2] = yield* Effect.all([
      Fiber.join(fiber1),
      Fiber.join(fiber2)
    ])

    expect(result1).toBeDefined()
    expect(result2).toBeDefined()
  })
)

Testing Streams

import { Stream, Chunk } from "effect"

it.effect("stream processing", () =>
  Effect.gen(function* () {
    const stream = Stream.range(1, 5)
    const result = yield* Stream.runCollect(
      stream.pipe(Stream.map((n) => n * 2))
    )

    expect(Chunk.toArray(result)).toEqual([2, 4, 6, 8, 10])
  })
)

it.effect("stream error handling", () =>
  Effect.gen(function* () {
    const stream = Stream.range(1, 10).pipe(
      Stream.map((n) =>
        n === 5 ? Effect.fail("error") : Effect.succeed(n)
      ),
      Stream.flatMap(identity)
    )

    const result = yield* Effect.exit(Stream.runCollect(stream))
    expect(Exit.isFailure(result)).toBe(true)
  })
)

Testing Effect.runPromise

// For integrating with Promise-based test frameworks
describe("promise integration", () => {
  it("converts effect to promise", async () => {
    const effect = Effect.succeed(42)
    const result = await Effect.runPromise(effect)
    expect(result).toBe(42)
  })

  it("rejects on failure", async () => {
    const effect = Effect.fail(new MyError({ message: "fail" }))

    await expect(Effect.runPromise(effect)).rejects.toThrow()
  })

  it("provides dependencies for promise", async () => {
    const effect = Effect.gen(function* () {
      const service = yield* MyService
      return yield* service.doSomething()
    })

    const result = await Effect.runPromise(
      effect.pipe(Effect.provide(MyServiceTest))
    )

    expect(result).toBeDefined()
  })
})

Best Practices

1. Use Descriptive Test Names

// Good
it.effect("returns user when found in database", () => ...)
it.effect("fails with UserNotFound when user does not exist", () => ...)

// Bad
it.effect("test 1", () => ...)
it.effect("works", () => ...)

2. Test Happy Path and Error Cases

describe("UserService.getUser", () => {
  it.effect("returns user when exists", () => ...)
  it.effect("fails with UserNotFound when not exists", () => ...)
  it.effect("fails with DatabaseError on connection failure", () => ...)
})

3. Keep Tests Isolated

// Good - each test gets fresh state
it.effect("test 1", () =>
  Effect.provide(effect, makeFreshLayer())
)

// Bad - shared state between tests
const sharedLayer = makeLayer()
it.layer(sharedLayer)("test 1", () => ...)
it.layer(sharedLayer)("test 2", () => ...) // May see state from test 1

4. Use TestClock for Time-Based Tests

// Good - instant, deterministic
it.effect("delays 1 hour", () =>
  Effect.gen(function* () {
    const fiber = yield* Effect.fork(Effect.delay("1 hour")(task))
    yield* TestClock.adjust("1 hour")
    yield* Fiber.join(fiber)
  })
)

// Bad - actually waits 1 hour
it.effect("delays 1 hour", () =>
  Effect.delay("1 hour")(task)
)

5. Mock External Dependencies

// Good - all external services mocked
const TestLayer = Layer.mergeAll(
  DatabaseServiceTest,
  EmailServiceTest,
  PaymentServiceTest
)

// Bad - tests hit real services (slow, flaky, expensive)
const TestLayer = Layer.mergeAll(
  DatabaseServiceLive, // ❌ Real DB
  EmailServiceLive,    // ❌ Sends real emails
  PaymentServiceLive   // ❌ Charges real money
)

6. Test Error Propagation

it.effect("propagates errors through effect chain", () =>
  Effect.gen(function* () {
    const result = yield* Effect.exit(
      service.getUser(999).pipe(
        Effect.flatMap((user) => service.processUser(user)),
        Effect.flatMap((processed) => service.saveUser(processed))
      )
    )

    // Verify UserNotFound from getUser propagated through chain
    expect(Exit.isFailure(result)).toBe(true)
  })
)

7. Use it.scoped for Resource Management

// Good - automatic cleanup
it.scoped("uses database connection", () =>
  Effect.gen(function* () {
    const conn = yield* acquireConnection // Scope manages lifecycle
    yield* useConnection(conn)
    // Connection automatically closed after test
  })
)

// Bad - manual cleanup (easy to forget)
it.effect("uses database connection", () =>
  Effect.gen(function* () {
    const conn = yield* acquireConnection
    try {
      yield* useConnection(conn)
    } finally {
      yield* releaseConnection(conn)
    }
  })
)

Common Patterns

Testing Retry Logic

it.effect("retries on failure", () =>
  Effect.gen(function* () {
    let attempts = 0

    const effect = Effect.gen(function* () {
      attempts++
      if (attempts < 3) {
        yield* Effect.fail("retry me")
      }
      return "success"
    }).pipe(
      Effect.retry(Schedule.recurs(5))
    )

    const result = yield* effect
    expect(result).toBe("success")
    expect(attempts).toBe(3)
  })
)

Testing Timeouts

it.effect("times out long operations", () =>
  Effect.gen(function* () {
    const longOp = Effect.delay("5 seconds")(Effect.succeed("done"))

    const result = yield* Effect.exit(
      longOp.pipe(Effect.timeout("1 second"))
    )

    expect(Exit.isFailure(result)).toBe(true)
    // Verify it's a TimeoutException
  }).pipe(Effect.provide(TestClock.layer))
)

Testing Interruption

it.effect("handles interruption gracefully", () =>
  Effect.gen(function* () {
    const ref = yield* Ref.make("initial")

    const fiber = yield* Effect.fork(
      Effect.gen(function* () {
        yield* Ref.set(ref, "started")
        yield* Effect.sleep("1 hour")
        yield* Ref.set(ref, "completed") // Should never reach here
      })
    )

    yield* TestClock.adjust("1 second")
    yield* Fiber.interrupt(fiber)

    const value = yield* Ref.get(ref)
    expect(value).toBe("started") // Not "completed"
  })
)

Sources