1/**
2 * @title Testing services with shared layers
3 *
4 * How to test Effect services that depend on other services.
5 */
6import { assert, describe, it, layer } from "@effect/vitest"
7import { Array, Context, Effect, Layer, Ref } from "effect"
8
9export interface Todo {
10 readonly id: number
11 readonly title: string
12}
13
14// Create a test ref service that can be used to store and manipulate test data
15// in layers.
16export class TodoRepoTestRef extends Context.Service<TodoRepoTestRef, Ref.Ref<Array<Todo>>>()("app/TodoRepoTestRef") {
17 static readonly layer = Layer.effect(TodoRepoTestRef, Ref.make(Array.empty()))
18}
19
20class TodoRepo extends Context.Service<TodoRepo, {
21 create(title: string): Effect.Effect<Todo>
22 readonly list: Effect.Effect<ReadonlyArray<Todo>>
23}>()("app/TodoRepo") {
24 static readonly layerTest = Layer.effect(
25 TodoRepo,
26 Effect.gen(function*() {
27 const store = yield* TodoRepoTestRef
28
29 const create = Effect.fn("TodoRepo.create")(function*(title: string) {
30 const todos = yield* Ref.get(store)
31 const todo = { id: todos.length + 1, title }
32 yield* Ref.set(store, [...todos, todo])
33 return todo
34 })
35
36 const list = Ref.get(store)
37
38 return TodoRepo.of({
39 create,
40 list
41 })
42 })
43 ).pipe(
44 // Provide the test ref layer as a dependency for the test repo layer.
45 // Use Layer.provideMerge so the tests can also access the test ref directly
46 // if needed.
47 Layer.provideMerge(TodoRepoTestRef.layer)
48 )
49}
50
51class TodoService extends Context.Service<TodoService, {
52 addAndCount(title: string): Effect.Effect<number>
53 readonly titles: Effect.Effect<ReadonlyArray<string>>
54}>()("app/TodoService") {
55 static readonly layerNoDeps = Layer.effect(
56 TodoService,
57 Effect.gen(function*() {
58 const repo = yield* TodoRepo
59
60 const addAndCount = Effect.fn("TodoService.addAndCount")(function*(title: string) {
61 yield* repo.create(title)
62 const todos = yield* repo.list
63 return todos.length
64 })
65
66 const titles = repo.list.pipe(
67 Effect.map((todos) => todos.map((todo) => todo.title))
68 )
69
70 return TodoService.of({
71 addAndCount,
72 titles
73 })
74 })
75 )
76
77 // You would also add a live layer here that provides real dependencies for
78 // production code.
79 //
80 // static readonly layer = Layer.effect(TodoService, ...).pipe(
81 // Layer.provide(TodoRepo.layer)
82 // )
83
84 static readonly layerTest = this.layerNoDeps.pipe(
85 // Provide the test repo layer as a dependency for the test service layer.
86 // Use `Layer.provideMerge` so the tests can also access the test repo
87 // directly if needed, as well as the test ref through the repo layer.
88 Layer.provideMerge(TodoRepo.layerTest)
89 )
90}
91
92// `layer(...)` creates one shared layer for the block and tears it down in
93// `afterAll`, so all tests inside can access the same service context.
94layer(TodoRepo.layerTest)("TodoRepo", (it) => {
95 it.effect("tests repository behavior", () =>
96 Effect.gen(function*() {
97 const repo = yield* TodoRepo
98 const before = (yield* repo.list).length
99 assert.strictEqual(before, 0)
100
101 yield* repo.create("Write docs")
102
103 const after = (yield* repo.list).length
104 assert.strictEqual(after, 1)
105 }))
106
107 it.effect("layer is shared", () =>
108 Effect.gen(function*() {
109 const repo = yield* TodoRepo
110 const before = (yield* repo.list).length
111 assert.strictEqual(before, 1)
112
113 yield* repo.create("Write docs again")
114
115 // because the layer is shared between tests, the todo created in the
116 // previous test is still present, so the count should be 2, not 1
117 const after = (yield* repo.list).length
118 assert.strictEqual(after, 2)
119 }))
120})
121
122describe("TodoService", () => {
123 it.effect("tests higher-level service logic", () =>
124 Effect.gen(function*() {
125 const ref = yield* TodoRepoTestRef
126 const service = yield* TodoService
127 const count = yield* service.addAndCount("Review docs")
128 const titles = yield* service.titles
129
130 assert.isTrue(count >= 1)
131 assert.isTrue(titles.some((title) => title.includes("Review docs")))
132
133 // You can also access the test ref directly to make assertions about the
134 // underlying data.
135 const todos = yield* Ref.get(ref)
136 assert.isTrue(todos.length >= 1)
137 }).pipe(Effect.provide(TodoService.layerTest)))
138})