@dojocho/effect-ts 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DOJO.md +22 -0
- package/dojo.json +50 -0
- package/katas/001-hello-effect/SENSEI.md +72 -0
- package/katas/001-hello-effect/solution.test.ts +35 -0
- package/katas/001-hello-effect/solution.ts +16 -0
- package/katas/002-transform-with-map/SENSEI.md +72 -0
- package/katas/002-transform-with-map/solution.test.ts +33 -0
- package/katas/002-transform-with-map/solution.ts +16 -0
- package/katas/003-generator-pipelines/SENSEI.md +72 -0
- package/katas/003-generator-pipelines/solution.test.ts +40 -0
- package/katas/003-generator-pipelines/solution.ts +29 -0
- package/katas/004-flatmap-and-chaining/SENSEI.md +80 -0
- package/katas/004-flatmap-and-chaining/solution.test.ts +34 -0
- package/katas/004-flatmap-and-chaining/solution.ts +18 -0
- package/katas/005-pipe-composition/SENSEI.md +81 -0
- package/katas/005-pipe-composition/solution.test.ts +41 -0
- package/katas/005-pipe-composition/solution.ts +19 -0
- package/katas/006-handle-errors/SENSEI.md +86 -0
- package/katas/006-handle-errors/solution.test.ts +53 -0
- package/katas/006-handle-errors/solution.ts +30 -0
- package/katas/007-tagged-errors/SENSEI.md +79 -0
- package/katas/007-tagged-errors/solution.test.ts +82 -0
- package/katas/007-tagged-errors/solution.ts +37 -0
- package/katas/008-error-patterns/SENSEI.md +89 -0
- package/katas/008-error-patterns/solution.test.ts +41 -0
- package/katas/008-error-patterns/solution.ts +38 -0
- package/katas/009-option-type/SENSEI.md +96 -0
- package/katas/009-option-type/solution.test.ts +49 -0
- package/katas/009-option-type/solution.ts +26 -0
- package/katas/010-either-and-exit/SENSEI.md +86 -0
- package/katas/010-either-and-exit/solution.test.ts +33 -0
- package/katas/010-either-and-exit/solution.ts +17 -0
- package/katas/011-services-and-context/SENSEI.md +82 -0
- package/katas/011-services-and-context/solution.test.ts +23 -0
- package/katas/011-services-and-context/solution.ts +17 -0
- package/katas/012-layers/SENSEI.md +73 -0
- package/katas/012-layers/solution.test.ts +23 -0
- package/katas/012-layers/solution.ts +26 -0
- package/katas/013-testing-effects/SENSEI.md +88 -0
- package/katas/013-testing-effects/solution.test.ts +41 -0
- package/katas/013-testing-effects/solution.ts +20 -0
- package/katas/014-schema-basics/SENSEI.md +81 -0
- package/katas/014-schema-basics/solution.test.ts +35 -0
- package/katas/014-schema-basics/solution.ts +25 -0
- package/katas/015-domain-modeling/SENSEI.md +85 -0
- package/katas/015-domain-modeling/solution.test.ts +46 -0
- package/katas/015-domain-modeling/solution.ts +42 -0
- package/katas/016-retry-and-schedule/SENSEI.md +72 -0
- package/katas/016-retry-and-schedule/solution.test.ts +26 -0
- package/katas/016-retry-and-schedule/solution.ts +23 -0
- package/katas/017-parallel-effects/SENSEI.md +70 -0
- package/katas/017-parallel-effects/solution.test.ts +33 -0
- package/katas/017-parallel-effects/solution.ts +17 -0
- package/katas/018-race-and-timeout/SENSEI.md +75 -0
- package/katas/018-race-and-timeout/solution.test.ts +30 -0
- package/katas/018-race-and-timeout/solution.ts +27 -0
- package/katas/019-ref-and-state/SENSEI.md +72 -0
- package/katas/019-ref-and-state/solution.test.ts +29 -0
- package/katas/019-ref-and-state/solution.ts +16 -0
- package/katas/020-fibers/SENSEI.md +80 -0
- package/katas/020-fibers/solution.test.ts +23 -0
- package/katas/020-fibers/solution.ts +23 -0
- package/katas/021-acquire-release/SENSEI.md +57 -0
- package/katas/021-acquire-release/solution.test.ts +23 -0
- package/katas/021-acquire-release/solution.ts +22 -0
- package/katas/022-scoped-layers/SENSEI.md +52 -0
- package/katas/022-scoped-layers/solution.test.ts +35 -0
- package/katas/022-scoped-layers/solution.ts +19 -0
- package/katas/023-resource-patterns/SENSEI.md +52 -0
- package/katas/023-resource-patterns/solution.test.ts +20 -0
- package/katas/023-resource-patterns/solution.ts +13 -0
- package/katas/024-streams-basics/SENSEI.md +61 -0
- package/katas/024-streams-basics/solution.test.ts +30 -0
- package/katas/024-streams-basics/solution.ts +16 -0
- package/katas/025-stream-operations/SENSEI.md +59 -0
- package/katas/025-stream-operations/solution.test.ts +26 -0
- package/katas/025-stream-operations/solution.ts +17 -0
- package/katas/026-combining-streams/SENSEI.md +54 -0
- package/katas/026-combining-streams/solution.test.ts +20 -0
- package/katas/026-combining-streams/solution.ts +16 -0
- package/katas/027-data-pipelines/SENSEI.md +58 -0
- package/katas/027-data-pipelines/solution.test.ts +22 -0
- package/katas/027-data-pipelines/solution.ts +16 -0
- package/katas/028-logging-and-spans/SENSEI.md +58 -0
- package/katas/028-logging-and-spans/solution.test.ts +50 -0
- package/katas/028-logging-and-spans/solution.ts +20 -0
- package/katas/029-http-client/SENSEI.md +59 -0
- package/katas/029-http-client/solution.test.ts +49 -0
- package/katas/029-http-client/solution.ts +24 -0
- package/katas/030-capstone/SENSEI.md +63 -0
- package/katas/030-capstone/solution.test.ts +67 -0
- package/katas/030-capstone/solution.ts +55 -0
- package/katas/031-config-and-environment/SENSEI.md +77 -0
- package/katas/031-config-and-environment/solution.test.ts +38 -0
- package/katas/031-config-and-environment/solution.ts +11 -0
- package/katas/032-cause-and-defects/SENSEI.md +90 -0
- package/katas/032-cause-and-defects/solution.test.ts +50 -0
- package/katas/032-cause-and-defects/solution.ts +23 -0
- package/katas/033-pattern-matching/SENSEI.md +86 -0
- package/katas/033-pattern-matching/solution.test.ts +36 -0
- package/katas/033-pattern-matching/solution.ts +28 -0
- package/katas/034-deferred-and-coordination/SENSEI.md +85 -0
- package/katas/034-deferred-and-coordination/solution.test.ts +25 -0
- package/katas/034-deferred-and-coordination/solution.ts +24 -0
- package/katas/035-queue-and-backpressure/SENSEI.md +100 -0
- package/katas/035-queue-and-backpressure/solution.test.ts +25 -0
- package/katas/035-queue-and-backpressure/solution.ts +21 -0
- package/katas/036-schema-advanced/SENSEI.md +81 -0
- package/katas/036-schema-advanced/solution.test.ts +55 -0
- package/katas/036-schema-advanced/solution.ts +19 -0
- package/katas/037-cache-and-memoization/SENSEI.md +73 -0
- package/katas/037-cache-and-memoization/solution.test.ts +47 -0
- package/katas/037-cache-and-memoization/solution.ts +24 -0
- package/katas/038-metrics/SENSEI.md +91 -0
- package/katas/038-metrics/solution.test.ts +39 -0
- package/katas/038-metrics/solution.ts +23 -0
- package/katas/039-managed-runtime/SENSEI.md +75 -0
- package/katas/039-managed-runtime/solution.test.ts +29 -0
- package/katas/039-managed-runtime/solution.ts +19 -0
- package/katas/040-request-batching/SENSEI.md +87 -0
- package/katas/040-request-batching/solution.test.ts +56 -0
- package/katas/040-request-batching/solution.ts +32 -0
- package/package.json +22 -0
- package/skills/effect-patterns-building-apis/SKILL.md +2393 -0
- package/skills/effect-patterns-building-data-pipelines/SKILL.md +1876 -0
- package/skills/effect-patterns-concurrency/SKILL.md +2999 -0
- package/skills/effect-patterns-concurrency-getting-started/SKILL.md +351 -0
- package/skills/effect-patterns-core-concepts/SKILL.md +3199 -0
- package/skills/effect-patterns-domain-modeling/SKILL.md +1385 -0
- package/skills/effect-patterns-error-handling/SKILL.md +1212 -0
- package/skills/effect-patterns-error-handling-resilience/SKILL.md +179 -0
- package/skills/effect-patterns-error-management/SKILL.md +1668 -0
- package/skills/effect-patterns-getting-started/SKILL.md +237 -0
- package/skills/effect-patterns-making-http-requests/SKILL.md +1756 -0
- package/skills/effect-patterns-observability/SKILL.md +1586 -0
- package/skills/effect-patterns-platform/SKILL.md +1195 -0
- package/skills/effect-patterns-platform-getting-started/SKILL.md +179 -0
- package/skills/effect-patterns-project-setup--execution/SKILL.md +233 -0
- package/skills/effect-patterns-resource-management/SKILL.md +827 -0
- package/skills/effect-patterns-scheduling/SKILL.md +451 -0
- package/skills/effect-patterns-scheduling-periodic-tasks/SKILL.md +763 -0
- package/skills/effect-patterns-streams/SKILL.md +2052 -0
- package/skills/effect-patterns-streams-getting-started/SKILL.md +421 -0
- package/skills/effect-patterns-streams-sinks/SKILL.md +1181 -0
- package/skills/effect-patterns-testing/SKILL.md +1632 -0
- package/skills/effect-patterns-tooling-and-debugging/SKILL.md +1125 -0
- package/skills/effect-patterns-value-handling/SKILL.md +676 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,1632 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-patterns-testing
|
|
3
|
+
description: Effect-TS patterns for Testing. Use when working with testing in Effect-TS applications.
|
|
4
|
+
---
|
|
5
|
+
# Effect-TS Patterns: Testing
|
|
6
|
+
This skill provides 10 curated Effect-TS patterns for testing.
|
|
7
|
+
Use this skill when working on tasks related to:
|
|
8
|
+
- testing
|
|
9
|
+
- Best practices in Effect-TS applications
|
|
10
|
+
- Real-world patterns and solutions
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🟢 Beginner Patterns
|
|
15
|
+
|
|
16
|
+
### Your First Effect Test
|
|
17
|
+
|
|
18
|
+
**Rule:** Use Effect.runPromise in tests to run and assert on Effect results.
|
|
19
|
+
|
|
20
|
+
**Good Example:**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { describe, it, expect } from "vitest"
|
|
24
|
+
import { Effect } from "effect"
|
|
25
|
+
|
|
26
|
+
// ============================================
|
|
27
|
+
// Code to test
|
|
28
|
+
// ============================================
|
|
29
|
+
|
|
30
|
+
const add = (a: number, b: number): Effect.Effect<number> =>
|
|
31
|
+
Effect.succeed(a + b)
|
|
32
|
+
|
|
33
|
+
const divide = (a: number, b: number): Effect.Effect<number, Error> =>
|
|
34
|
+
b === 0
|
|
35
|
+
? Effect.fail(new Error("Cannot divide by zero"))
|
|
36
|
+
: Effect.succeed(a / b)
|
|
37
|
+
|
|
38
|
+
const fetchUser = (id: string): Effect.Effect<{ id: string; name: string }> =>
|
|
39
|
+
Effect.succeed({ id, name: `User ${id}` })
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// Tests
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
describe("Basic Effect Tests", () => {
|
|
46
|
+
it("should add two numbers", async () => {
|
|
47
|
+
const result = await Effect.runPromise(add(2, 3))
|
|
48
|
+
expect(result).toBe(5)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("should divide numbers", async () => {
|
|
52
|
+
const result = await Effect.runPromise(divide(10, 2))
|
|
53
|
+
expect(result).toBe(5)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("should fail on divide by zero", async () => {
|
|
57
|
+
await expect(Effect.runPromise(divide(10, 0))).rejects.toThrow(
|
|
58
|
+
"Cannot divide by zero"
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("should fetch a user", async () => {
|
|
63
|
+
const user = await Effect.runPromise(fetchUser("123"))
|
|
64
|
+
|
|
65
|
+
expect(user).toEqual({
|
|
66
|
+
id: "123",
|
|
67
|
+
name: "User 123",
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// ============================================
|
|
73
|
+
// Testing Effect.gen programs
|
|
74
|
+
// ============================================
|
|
75
|
+
|
|
76
|
+
const calculateDiscount = (price: number, quantity: number) =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
if (price <= 0) {
|
|
79
|
+
return yield* Effect.fail(new Error("Invalid price"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const subtotal = price * quantity
|
|
83
|
+
const discount = quantity >= 10 ? 0.1 : 0
|
|
84
|
+
const total = subtotal * (1 - discount)
|
|
85
|
+
|
|
86
|
+
return { subtotal, discount, total }
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("Effect.gen Tests", () => {
|
|
90
|
+
it("should calculate without discount", async () => {
|
|
91
|
+
const result = await Effect.runPromise(calculateDiscount(10, 5))
|
|
92
|
+
|
|
93
|
+
expect(result.subtotal).toBe(50)
|
|
94
|
+
expect(result.discount).toBe(0)
|
|
95
|
+
expect(result.total).toBe(50)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("should apply bulk discount", async () => {
|
|
99
|
+
const result = await Effect.runPromise(calculateDiscount(10, 10))
|
|
100
|
+
|
|
101
|
+
expect(result.subtotal).toBe(100)
|
|
102
|
+
expect(result.discount).toBe(0.1)
|
|
103
|
+
expect(result.total).toBe(90)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("should fail for invalid price", async () => {
|
|
107
|
+
await expect(
|
|
108
|
+
Effect.runPromise(calculateDiscount(-5, 10))
|
|
109
|
+
).rejects.toThrow("Invalid price")
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Rationale:**
|
|
115
|
+
|
|
116
|
+
Test Effect programs by running them with `Effect.runPromise` and using standard test assertions on the results.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
Testing Effect code is straightforward:
|
|
122
|
+
|
|
123
|
+
1. **Effects are values** - Build them in tests like any other value
|
|
124
|
+
2. **Run to get results** - Use `Effect.runPromise` to execute
|
|
125
|
+
3. **Assert normally** - Standard assertions work on the results
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### Test Effects with Services
|
|
132
|
+
|
|
133
|
+
**Rule:** Provide test implementations of services to make Effect programs testable.
|
|
134
|
+
|
|
135
|
+
**Good Example:**
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { describe, it, expect } from "vitest"
|
|
139
|
+
import { Effect, Context } from "effect"
|
|
140
|
+
|
|
141
|
+
// ============================================
|
|
142
|
+
// 1. Define a service
|
|
143
|
+
// ============================================
|
|
144
|
+
|
|
145
|
+
class UserRepository extends Context.Tag("UserRepository")<
|
|
146
|
+
UserRepository,
|
|
147
|
+
{
|
|
148
|
+
readonly findById: (id: string) => Effect.Effect<User | null>
|
|
149
|
+
readonly save: (user: User) => Effect.Effect<void>
|
|
150
|
+
}
|
|
151
|
+
>() {}
|
|
152
|
+
|
|
153
|
+
interface User {
|
|
154
|
+
id: string
|
|
155
|
+
name: string
|
|
156
|
+
email: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================
|
|
160
|
+
// 2. Code that uses the service
|
|
161
|
+
// ============================================
|
|
162
|
+
|
|
163
|
+
const getUser = (id: string) =>
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const repo = yield* UserRepository
|
|
166
|
+
const user = yield* repo.findById(id)
|
|
167
|
+
|
|
168
|
+
if (!user) {
|
|
169
|
+
return yield* Effect.fail(new Error(`User ${id} not found`))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return user
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const createUser = (name: string, email: string) =>
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
const repo = yield* UserRepository
|
|
178
|
+
|
|
179
|
+
const user: User = {
|
|
180
|
+
id: crypto.randomUUID(),
|
|
181
|
+
name,
|
|
182
|
+
email,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
yield* repo.save(user)
|
|
186
|
+
return user
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ============================================
|
|
190
|
+
// 3. Create a test implementation
|
|
191
|
+
// ============================================
|
|
192
|
+
|
|
193
|
+
const makeTestUserRepository = (initialUsers: User[] = []) => {
|
|
194
|
+
const users = new Map(initialUsers.map(u => [u.id, u]))
|
|
195
|
+
|
|
196
|
+
return UserRepository.of({
|
|
197
|
+
findById: (id) => Effect.succeed(users.get(id) ?? null),
|
|
198
|
+
save: (user) => Effect.sync(() => { users.set(user.id, user) }),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================
|
|
203
|
+
// 4. Write tests
|
|
204
|
+
// ============================================
|
|
205
|
+
|
|
206
|
+
describe("User Service Tests", () => {
|
|
207
|
+
it("should find an existing user", async () => {
|
|
208
|
+
const testUser: User = {
|
|
209
|
+
id: "123",
|
|
210
|
+
name: "Alice",
|
|
211
|
+
email: "alice@example.com",
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const testRepo = makeTestUserRepository([testUser])
|
|
215
|
+
|
|
216
|
+
const result = await Effect.runPromise(
|
|
217
|
+
getUser("123").pipe(
|
|
218
|
+
Effect.provideService(UserRepository, testRepo)
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(result).toEqual(testUser)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("should fail when user not found", async () => {
|
|
226
|
+
const testRepo = makeTestUserRepository([])
|
|
227
|
+
|
|
228
|
+
await expect(
|
|
229
|
+
Effect.runPromise(
|
|
230
|
+
getUser("999").pipe(
|
|
231
|
+
Effect.provideService(UserRepository, testRepo)
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
).rejects.toThrow("User 999 not found")
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("should create and save a user", async () => {
|
|
238
|
+
const savedUsers: User[] = []
|
|
239
|
+
|
|
240
|
+
const trackingRepo = UserRepository.of({
|
|
241
|
+
findById: () => Effect.succeed(null),
|
|
242
|
+
save: (user) => Effect.sync(() => { savedUsers.push(user) }),
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const result = await Effect.runPromise(
|
|
246
|
+
createUser("Bob", "bob@example.com").pipe(
|
|
247
|
+
Effect.provideService(UserRepository, trackingRepo)
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
expect(result.name).toBe("Bob")
|
|
252
|
+
expect(result.email).toBe("bob@example.com")
|
|
253
|
+
expect(savedUsers).toHaveLength(1)
|
|
254
|
+
expect(savedUsers[0].name).toBe("Bob")
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Rationale:**
|
|
260
|
+
|
|
261
|
+
When testing Effects that require services, provide test implementations using `Effect.provideService` or test layers.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
Effect's service pattern makes testing easy:
|
|
267
|
+
|
|
268
|
+
1. **Declare dependencies** - Effects specify what they need
|
|
269
|
+
2. **Inject test doubles** - Provide fake implementations for tests
|
|
270
|
+
3. **No mocking libraries** - Just provide different service implementations
|
|
271
|
+
4. **Type-safe** - Compiler ensures you provide all dependencies
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
## 🟡 Intermediate Patterns
|
|
279
|
+
|
|
280
|
+
### Accessing the Current Time with Clock
|
|
281
|
+
|
|
282
|
+
**Rule:** Use the Clock service to get the current time, enabling deterministic testing with TestClock.
|
|
283
|
+
|
|
284
|
+
**Good Example:**
|
|
285
|
+
|
|
286
|
+
This example shows a function that checks if a token is expired. Its logic depends on `Clock`, making it fully testable.
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
import { Effect, Clock, Duration } from "effect";
|
|
290
|
+
|
|
291
|
+
interface Token {
|
|
292
|
+
readonly value: string;
|
|
293
|
+
readonly expiresAt: number; // UTC milliseconds
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// This function is pure and testable because it depends on Clock
|
|
297
|
+
const isTokenExpired = (
|
|
298
|
+
token: Token
|
|
299
|
+
): Effect.Effect<boolean, never, Clock.Clock> =>
|
|
300
|
+
Clock.currentTimeMillis.pipe(
|
|
301
|
+
Effect.map((now) => now > token.expiresAt),
|
|
302
|
+
Effect.tap((expired) =>
|
|
303
|
+
Clock.currentTimeMillis.pipe(
|
|
304
|
+
Effect.flatMap((currentTime) =>
|
|
305
|
+
Effect.log(
|
|
306
|
+
`Token expired? ${expired} (current time: ${new Date(currentTime).toISOString()})`
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Create a test clock service that advances time
|
|
314
|
+
const makeTestClock = (timeMs: number): Clock.Clock => ({
|
|
315
|
+
currentTimeMillis: Effect.succeed(timeMs),
|
|
316
|
+
currentTimeNanos: Effect.succeed(BigInt(timeMs * 1_000_000)),
|
|
317
|
+
sleep: (duration: Duration.Duration) => Effect.succeed(void 0),
|
|
318
|
+
unsafeCurrentTimeMillis: () => timeMs,
|
|
319
|
+
unsafeCurrentTimeNanos: () => BigInt(timeMs * 1_000_000),
|
|
320
|
+
[Clock.ClockTypeId]: Clock.ClockTypeId,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Create a token that expires in 1 second
|
|
324
|
+
const token = { value: "abc", expiresAt: Date.now() + 1000 };
|
|
325
|
+
|
|
326
|
+
// Check token expiry with different clocks
|
|
327
|
+
const program = Effect.gen(function* () {
|
|
328
|
+
// Check with current time
|
|
329
|
+
yield* Effect.log("Checking with current time...");
|
|
330
|
+
yield* isTokenExpired(token);
|
|
331
|
+
|
|
332
|
+
// Check with past time
|
|
333
|
+
yield* Effect.log("\nChecking with past time (1 minute ago)...");
|
|
334
|
+
const pastClock = makeTestClock(Date.now() - 60_000);
|
|
335
|
+
yield* isTokenExpired(token).pipe(
|
|
336
|
+
Effect.provideService(Clock.Clock, pastClock)
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Check with future time
|
|
340
|
+
yield* Effect.log("\nChecking with future time (1 hour ahead)...");
|
|
341
|
+
const futureClock = makeTestClock(Date.now() + 3600_000);
|
|
342
|
+
yield* isTokenExpired(token).pipe(
|
|
343
|
+
Effect.provideService(Clock.Clock, futureClock)
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Run the program with default clock
|
|
348
|
+
Effect.runPromise(
|
|
349
|
+
program.pipe(Effect.provideService(Clock.Clock, makeTestClock(Date.now())))
|
|
350
|
+
);
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
**Anti-Pattern:**
|
|
356
|
+
|
|
357
|
+
Directly calling `Date.now()` inside your business logic. This creates an impure function that cannot be tested reliably without manipulating the system clock, which is a bad practice.
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
import { Effect } from "effect";
|
|
361
|
+
|
|
362
|
+
interface Token {
|
|
363
|
+
readonly expiresAt: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ❌ WRONG: This function's behavior changes every millisecond.
|
|
367
|
+
const isTokenExpiredUnsafely = (token: Token): Effect.Effect<boolean> =>
|
|
368
|
+
Effect.sync(() => Date.now() > token.expiresAt);
|
|
369
|
+
|
|
370
|
+
// Testing this function would require complex mocking of global APIs
|
|
371
|
+
// or would be non-deterministic.
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
**Rationale:**
|
|
375
|
+
|
|
376
|
+
Whenever you need to get the current time within an `Effect`, do not call `Date.now()` directly. Instead, depend on the `Clock` service and use one of its methods, such as `Clock.currentTimeMillis`.
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
Directly calling `Date.now()` makes your code impure and tightly coupled to the system clock. This makes testing difficult and unreliable, as the output of your function will change every time it's run.
|
|
382
|
+
|
|
383
|
+
The `Clock` service is Effect's solution to this problem. It's an abstraction for "the current time."
|
|
384
|
+
|
|
385
|
+
- In **production**, the default `Live` `Clock` implementation uses the real system time.
|
|
386
|
+
- In **tests**, you can provide the `TestClock` layer. This gives you a virtual clock that you can manually control, allowing you to set the time to a specific value or advance it by a specific duration.
|
|
387
|
+
|
|
388
|
+
This makes any time-dependent logic pure, deterministic, and easy to test with perfect precision.
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
### Write Tests That Adapt to Application Code
|
|
395
|
+
|
|
396
|
+
**Rule:** Write tests that adapt to application code.
|
|
397
|
+
|
|
398
|
+
**Good Example:**
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
import { Effect } from "effect";
|
|
402
|
+
|
|
403
|
+
// Define our types
|
|
404
|
+
interface User {
|
|
405
|
+
id: number;
|
|
406
|
+
name: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
class NotFoundError extends Error {
|
|
410
|
+
readonly _tag = "NotFoundError";
|
|
411
|
+
constructor(readonly id: number) {
|
|
412
|
+
super(`User ${id} not found`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Define database service interface
|
|
417
|
+
interface DatabaseServiceApi {
|
|
418
|
+
getUserById: (id: number) => Effect.Effect<User, NotFoundError>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Implement the service with mock data
|
|
422
|
+
class DatabaseService extends Effect.Service<DatabaseService>()(
|
|
423
|
+
"DatabaseService",
|
|
424
|
+
{
|
|
425
|
+
sync: () => ({
|
|
426
|
+
getUserById: (id: number) => {
|
|
427
|
+
// Simulate database lookup
|
|
428
|
+
if (id === 404) {
|
|
429
|
+
return Effect.fail(new NotFoundError(id));
|
|
430
|
+
}
|
|
431
|
+
return Effect.succeed({ id, name: `User ${id}` });
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
}
|
|
435
|
+
) {}
|
|
436
|
+
|
|
437
|
+
// Test service implementation for testing
|
|
438
|
+
class TestDatabaseService extends Effect.Service<TestDatabaseService>()(
|
|
439
|
+
"TestDatabaseService",
|
|
440
|
+
{
|
|
441
|
+
sync: () => ({
|
|
442
|
+
getUserById: (id: number) => {
|
|
443
|
+
// Test data with predictable responses
|
|
444
|
+
const testUsers = [
|
|
445
|
+
{ id: 1, name: "Test User 1" },
|
|
446
|
+
{ id: 2, name: "Test User 2" },
|
|
447
|
+
{ id: 123, name: "User 123" },
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
const user = testUsers.find((u) => u.id === id);
|
|
451
|
+
if (user) {
|
|
452
|
+
return Effect.succeed(user);
|
|
453
|
+
}
|
|
454
|
+
return Effect.fail(new NotFoundError(id));
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
}
|
|
458
|
+
) {}
|
|
459
|
+
|
|
460
|
+
// Business logic that uses the database service
|
|
461
|
+
const getUserWithFallback = (id: number) =>
|
|
462
|
+
Effect.gen(function* () {
|
|
463
|
+
const db = yield* DatabaseService;
|
|
464
|
+
return yield* Effect.gen(function* () {
|
|
465
|
+
const user = yield* db.getUserById(id);
|
|
466
|
+
return user;
|
|
467
|
+
}).pipe(
|
|
468
|
+
Effect.catchAll((error) =>
|
|
469
|
+
Effect.gen(function* () {
|
|
470
|
+
if (error instanceof NotFoundError) {
|
|
471
|
+
yield* Effect.logInfo(`User ${id} not found, using fallback`);
|
|
472
|
+
return { id, name: `Fallback User ${id}` };
|
|
473
|
+
}
|
|
474
|
+
return yield* Effect.fail(error);
|
|
475
|
+
})
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Create a program that demonstrates the service
|
|
481
|
+
const program = Effect.gen(function* () {
|
|
482
|
+
yield* Effect.logInfo(
|
|
483
|
+
"=== Writing Tests that Adapt to Application Code Demo ==="
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const db = yield* DatabaseService;
|
|
487
|
+
|
|
488
|
+
// Example 1: Successful user lookup
|
|
489
|
+
yield* Effect.logInfo("\n1. Looking up existing user 123...");
|
|
490
|
+
const user = yield* Effect.gen(function* () {
|
|
491
|
+
try {
|
|
492
|
+
return yield* db.getUserById(123);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
yield* Effect.logError(
|
|
495
|
+
`Failed to get user: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
496
|
+
);
|
|
497
|
+
return { id: -1, name: "Error" };
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
yield* Effect.logInfo(`Found user: ${JSON.stringify(user)}`);
|
|
501
|
+
|
|
502
|
+
// Example 2: Handle non-existent user with proper error handling
|
|
503
|
+
yield* Effect.logInfo("\n2. Looking up non-existent user 404...");
|
|
504
|
+
const notFoundUser = yield* Effect.gen(function* () {
|
|
505
|
+
try {
|
|
506
|
+
return yield* db.getUserById(404);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error instanceof NotFoundError) {
|
|
509
|
+
yield* Effect.logInfo(
|
|
510
|
+
`✅ Properly handled NotFoundError: ${error.message}`
|
|
511
|
+
);
|
|
512
|
+
return { id: 404, name: "Not Found" };
|
|
513
|
+
}
|
|
514
|
+
yield* Effect.logError(
|
|
515
|
+
`Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
516
|
+
);
|
|
517
|
+
return { id: -1, name: "Error" };
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
yield* Effect.logInfo(`Result: ${JSON.stringify(notFoundUser)}`);
|
|
521
|
+
|
|
522
|
+
// Example 3: Business logic with fallback
|
|
523
|
+
yield* Effect.logInfo("\n3. Business logic with fallback for missing user:");
|
|
524
|
+
const userWithFallback = yield* getUserWithFallback(999);
|
|
525
|
+
yield* Effect.logInfo(
|
|
526
|
+
`User with fallback: ${JSON.stringify(userWithFallback)}`
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Example 4: Testing with different service implementation
|
|
530
|
+
yield* Effect.logInfo("\n4. Testing with test service implementation:");
|
|
531
|
+
yield* Effect.provide(
|
|
532
|
+
Effect.gen(function* () {
|
|
533
|
+
const testDb = yield* TestDatabaseService;
|
|
534
|
+
|
|
535
|
+
// Test existing user
|
|
536
|
+
const testUser1 = yield* Effect.gen(function* () {
|
|
537
|
+
try {
|
|
538
|
+
return yield* testDb.getUserById(1);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
yield* Effect.logError(
|
|
541
|
+
`Test failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
542
|
+
);
|
|
543
|
+
return { id: -1, name: "Test Error" };
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
yield* Effect.logInfo(`Test user 1: ${JSON.stringify(testUser1)}`);
|
|
547
|
+
|
|
548
|
+
// Test non-existing user
|
|
549
|
+
const testUser404 = yield* Effect.gen(function* () {
|
|
550
|
+
try {
|
|
551
|
+
return yield* testDb.getUserById(404);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
yield* Effect.logInfo(
|
|
554
|
+
`✅ Test service properly threw NotFoundError: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
555
|
+
);
|
|
556
|
+
return { id: 404, name: "Test Not Found" };
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
yield* Effect.logInfo(`Test result: ${JSON.stringify(testUser404)}`);
|
|
560
|
+
}),
|
|
561
|
+
TestDatabaseService.Default
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
yield* Effect.logInfo(
|
|
565
|
+
"\n✅ Tests that adapt to application code demonstration completed!"
|
|
566
|
+
);
|
|
567
|
+
yield* Effect.logInfo(
|
|
568
|
+
"The same business logic works with different service implementations!"
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Run the program with the default database service
|
|
573
|
+
Effect.runPromise(
|
|
574
|
+
Effect.provide(program, DatabaseService.Default) as Effect.Effect<
|
|
575
|
+
void,
|
|
576
|
+
never,
|
|
577
|
+
never
|
|
578
|
+
>
|
|
579
|
+
);
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
**Explanation:**
|
|
583
|
+
Tests should reflect the real interface and behavior of your code, not force changes to it.
|
|
584
|
+
|
|
585
|
+
**Anti-Pattern:**
|
|
586
|
+
|
|
587
|
+
Any action where the test dictates a change to the application code. Do not modify a service file to add a method just because a test needs it. If a test fails, fix the test.
|
|
588
|
+
|
|
589
|
+
**Rationale:**
|
|
590
|
+
|
|
591
|
+
Tests are secondary artifacts that serve to validate the application. The application's code and interfaces are the source of truth. When a test fails, fix the test's logic or setup, not the production code.
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
Treating application code as immutable during testing prevents the introduction of bugs and false test confidence. The goal of a test is to verify real-world behavior; changing that behavior to suit the test invalidates its purpose.
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
### Use the Auto-Generated .Default Layer in Tests
|
|
599
|
+
|
|
600
|
+
**Rule:** Use the auto-generated .Default layer in tests.
|
|
601
|
+
|
|
602
|
+
**Good Example:**
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
import { Effect } from "effect";
|
|
606
|
+
|
|
607
|
+
// Define MyService using Effect.Service pattern
|
|
608
|
+
class MyService extends Effect.Service<MyService>()("MyService", {
|
|
609
|
+
sync: () => ({
|
|
610
|
+
doSomething: () =>
|
|
611
|
+
Effect.succeed("done").pipe(
|
|
612
|
+
Effect.tap(() => Effect.log("MyService did something!"))
|
|
613
|
+
),
|
|
614
|
+
}),
|
|
615
|
+
}) {}
|
|
616
|
+
|
|
617
|
+
// Create a program that uses MyService
|
|
618
|
+
const program = Effect.gen(function* () {
|
|
619
|
+
yield* Effect.log("Getting MyService...");
|
|
620
|
+
const service = yield* MyService;
|
|
621
|
+
|
|
622
|
+
yield* Effect.log("Calling doSomething()...");
|
|
623
|
+
const result = yield* service.doSomething();
|
|
624
|
+
|
|
625
|
+
yield* Effect.log(`Result: ${result}`);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Run the program with default service implementation
|
|
629
|
+
Effect.runPromise(Effect.provide(program, MyService.Default));
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
**Explanation:**
|
|
633
|
+
This approach ensures your tests are idiomatic, maintainable, and take full advantage of Effect's dependency injection system.
|
|
634
|
+
|
|
635
|
+
**Anti-Pattern:**
|
|
636
|
+
|
|
637
|
+
Do not create manual layers for your service in tests (`Layer.succeed(...)`) or try to provide the service class directly. This bypasses the intended dependency injection mechanism.
|
|
638
|
+
|
|
639
|
+
**Rationale:**
|
|
640
|
+
|
|
641
|
+
In your tests, provide service dependencies using the static `.Default` property that `Effect.Service` automatically attaches to your service class.
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
The `.Default` layer is the canonical way to provide a service in a test environment. It's automatically created, correctly scoped, and handles resolving any transitive dependencies, making tests cleaner and more robust.
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
### Mocking Dependencies in Tests
|
|
649
|
+
|
|
650
|
+
**Rule:** Provide mock service implementations via a test-specific Layer to isolate the unit under test.
|
|
651
|
+
|
|
652
|
+
**Good Example:**
|
|
653
|
+
|
|
654
|
+
We want to test a `Notifier` service that uses an `EmailClient` to send emails. In our test, we provide a mock `EmailClient` that doesn't actually send emails but just returns a success value.
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
import { Effect, Layer } from "effect";
|
|
658
|
+
|
|
659
|
+
// --- The Services ---
|
|
660
|
+
interface EmailClientService {
|
|
661
|
+
send: (address: string, body: string) => Effect.Effect<void>;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
class EmailClient extends Effect.Service<EmailClientService>()("EmailClient", {
|
|
665
|
+
sync: () => ({
|
|
666
|
+
send: (address: string, body: string) =>
|
|
667
|
+
Effect.sync(() => Effect.log(`Sending email to ${address}: ${body}`)),
|
|
668
|
+
}),
|
|
669
|
+
}) {}
|
|
670
|
+
|
|
671
|
+
interface NotifierService {
|
|
672
|
+
notifyUser: (userId: number, message: string) => Effect.Effect<void>;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
class Notifier extends Effect.Service<NotifierService>()("Notifier", {
|
|
676
|
+
effect: Effect.gen(function* () {
|
|
677
|
+
const emailClient = yield* EmailClient;
|
|
678
|
+
return {
|
|
679
|
+
notifyUser: (userId: number, message: string) =>
|
|
680
|
+
emailClient.send(`user-${userId}@example.com`, message),
|
|
681
|
+
};
|
|
682
|
+
}),
|
|
683
|
+
dependencies: [EmailClient.Default],
|
|
684
|
+
}) {}
|
|
685
|
+
|
|
686
|
+
// Create a program that uses the Notifier service
|
|
687
|
+
const program = Effect.gen(function* () {
|
|
688
|
+
yield* Effect.log("Using default EmailClient implementation...");
|
|
689
|
+
const notifier = yield* Notifier;
|
|
690
|
+
yield* notifier.notifyUser(123, "Your invoice is ready.");
|
|
691
|
+
|
|
692
|
+
// Create mock EmailClient that logs differently
|
|
693
|
+
yield* Effect.log("\nUsing mock EmailClient implementation...");
|
|
694
|
+
const mockEmailClient = Layer.succeed(EmailClient, {
|
|
695
|
+
send: (address: string, body: string) =>
|
|
696
|
+
// Directly return the Effect.log without nesting it in Effect.sync
|
|
697
|
+
Effect.log(`MOCK: Would send to ${address} with body: ${body}`),
|
|
698
|
+
} as EmailClientService);
|
|
699
|
+
|
|
700
|
+
// Run the same notification with mock client
|
|
701
|
+
yield* Effect.gen(function* () {
|
|
702
|
+
const notifier = yield* Notifier;
|
|
703
|
+
yield* notifier.notifyUser(123, "Your invoice is ready.");
|
|
704
|
+
}).pipe(Effect.provide(mockEmailClient));
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// Run the program
|
|
708
|
+
Effect.runPromise(Effect.provide(program, Notifier.Default));
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
**Anti-Pattern:**
|
|
714
|
+
|
|
715
|
+
Testing your business logic using the "live" implementation of its dependencies. This creates an integration test, not a unit test. It will be slow, unreliable, and may have real-world side effects (like actually sending an email).
|
|
716
|
+
|
|
717
|
+
```typescript
|
|
718
|
+
import { Effect } from "effect";
|
|
719
|
+
import { NotifierLive } from "./somewhere";
|
|
720
|
+
import { EmailClientLive } from "./somewhere"; // The REAL email client
|
|
721
|
+
|
|
722
|
+
// ❌ WRONG: This test will try to send a real email.
|
|
723
|
+
it("sends a real email", () =>
|
|
724
|
+
Effect.gen(function* () {
|
|
725
|
+
const notifier = yield* Notifier;
|
|
726
|
+
yield* notifier.notifyUser(123, "This is a test email!");
|
|
727
|
+
}).pipe(
|
|
728
|
+
Effect.provide(NotifierLive),
|
|
729
|
+
Effect.provide(EmailClientLive), // Using the live layer makes this an integration test
|
|
730
|
+
Effect.runPromise
|
|
731
|
+
));
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
**Rationale:**
|
|
735
|
+
|
|
736
|
+
To test a piece of code in isolation, identify its service dependencies and provide mock implementations for them using a test-specific `Layer`. The most common way to create a mock layer is with `Layer.succeed(ServiceTag, mockImplementation)`.
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
The primary goal of a unit test is to verify the logic of a single unit of code, independent of its external dependencies. Effect's dependency injection system is designed to make this easy and type-safe.
|
|
742
|
+
|
|
743
|
+
By providing a mock `Layer` in your test, you replace a real dependency (like an `HttpClient` that makes network calls) with a fake one that returns predictable data. This provides several key benefits:
|
|
744
|
+
|
|
745
|
+
- **Determinism:** Your tests always produce the same result, free from the flakiness of network or database connections.
|
|
746
|
+
- **Speed:** Tests run instantly without waiting for slow I/O operations.
|
|
747
|
+
- **Type Safety:** The TypeScript compiler ensures your mock implementation perfectly matches the real service's interface, preventing your tests from becoming outdated.
|
|
748
|
+
- **Explicitness:** The test setup clearly documents all the dependencies required for the code to run.
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
## 🟠 Advanced Patterns
|
|
756
|
+
|
|
757
|
+
### Organize Layers into Composable Modules
|
|
758
|
+
|
|
759
|
+
**Rule:** Organize services into modular Layers that are composed hierarchically to manage complexity in large applications.
|
|
760
|
+
|
|
761
|
+
**Good Example:**
|
|
762
|
+
|
|
763
|
+
This example shows a `BaseLayer` with a `Logger`, a `UserModule` that uses the `Logger`, and a final `AppLayer` that wires them together.
|
|
764
|
+
|
|
765
|
+
### 1. The Base Infrastructure Layer
|
|
766
|
+
|
|
767
|
+
```typescript
|
|
768
|
+
// src/core/Logger.ts
|
|
769
|
+
import { Effect } from "effect";
|
|
770
|
+
|
|
771
|
+
export class Logger extends Effect.Service<Logger>()("App/Core/Logger", {
|
|
772
|
+
sync: () => ({
|
|
773
|
+
log: (msg: string) => Effect.log(`[LOG] ${msg}`),
|
|
774
|
+
}),
|
|
775
|
+
}) {}
|
|
776
|
+
|
|
777
|
+
// src/features/User/UserRepository.ts
|
|
778
|
+
export class UserRepository extends Effect.Service<UserRepository>()(
|
|
779
|
+
"App/User/UserRepository",
|
|
780
|
+
{
|
|
781
|
+
// Define implementation that uses Logger
|
|
782
|
+
effect: Effect.gen(function* () {
|
|
783
|
+
const logger = yield* Logger;
|
|
784
|
+
return {
|
|
785
|
+
findById: (id: number) =>
|
|
786
|
+
Effect.gen(function* () {
|
|
787
|
+
yield* logger.log(`Finding user ${id}`);
|
|
788
|
+
return { id, name: `User ${id}` };
|
|
789
|
+
}),
|
|
790
|
+
};
|
|
791
|
+
}),
|
|
792
|
+
// Declare Logger dependency
|
|
793
|
+
dependencies: [Logger.Default],
|
|
794
|
+
}
|
|
795
|
+
) {}
|
|
796
|
+
|
|
797
|
+
// Example usage
|
|
798
|
+
const program = Effect.gen(function* () {
|
|
799
|
+
const repo = yield* UserRepository;
|
|
800
|
+
const user = yield* repo.findById(1);
|
|
801
|
+
return user;
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// Run with default implementations
|
|
805
|
+
Effect.runPromise(Effect.provide(program, UserRepository.Default));
|
|
806
|
+
|
|
807
|
+
const programWithLogging = Effect.gen(function* () {
|
|
808
|
+
const result = yield* program;
|
|
809
|
+
yield* Effect.log(`Program result: ${JSON.stringify(result)}`);
|
|
810
|
+
return result;
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
Effect.runPromise(Effect.provide(programWithLogging, UserRepository.Default));
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### 2. The Feature Module Layer
|
|
817
|
+
|
|
818
|
+
```typescript
|
|
819
|
+
// src/core/Logger.ts
|
|
820
|
+
import { Effect } from "effect";
|
|
821
|
+
|
|
822
|
+
export class Logger extends Effect.Service<Logger>()("App/Core/Logger", {
|
|
823
|
+
sync: () => ({
|
|
824
|
+
log: (msg: string) => Effect.sync(() => console.log(`[LOG] ${msg}`)),
|
|
825
|
+
}),
|
|
826
|
+
}) {}
|
|
827
|
+
|
|
828
|
+
// src/features/User/UserRepository.ts
|
|
829
|
+
export class UserRepository extends Effect.Service<UserRepository>()(
|
|
830
|
+
"App/User/UserRepository",
|
|
831
|
+
{
|
|
832
|
+
// Define implementation that uses Logger
|
|
833
|
+
effect: Effect.gen(function* () {
|
|
834
|
+
const logger = yield* Logger;
|
|
835
|
+
return {
|
|
836
|
+
findById: (id: number) =>
|
|
837
|
+
Effect.gen(function* () {
|
|
838
|
+
yield* logger.log(`Finding user ${id}`);
|
|
839
|
+
return { id, name: `User ${id}` };
|
|
840
|
+
}),
|
|
841
|
+
};
|
|
842
|
+
}),
|
|
843
|
+
// Declare Logger dependency
|
|
844
|
+
dependencies: [Logger.Default],
|
|
845
|
+
}
|
|
846
|
+
) {}
|
|
847
|
+
|
|
848
|
+
// Example usage
|
|
849
|
+
const program = Effect.gen(function* () {
|
|
850
|
+
const repo = yield* UserRepository;
|
|
851
|
+
const user = yield* repo.findById(1);
|
|
852
|
+
return user;
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Run with default implementations
|
|
856
|
+
Effect.runPromise(Effect.provide(program, UserRepository.Default)).then(
|
|
857
|
+
console.log
|
|
858
|
+
);
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
### 3. The Final Application Composition
|
|
862
|
+
|
|
863
|
+
```typescript
|
|
864
|
+
// src/layers.ts
|
|
865
|
+
import { Layer } from "effect";
|
|
866
|
+
import { BaseLayer } from "./core";
|
|
867
|
+
import { UserModuleLive } from "./features/User";
|
|
868
|
+
// import { ProductModuleLive } from "./features/Product";
|
|
869
|
+
|
|
870
|
+
const AllModules = Layer.mergeAll(UserModuleLive /*, ProductModuleLive */);
|
|
871
|
+
|
|
872
|
+
// Provide the BaseLayer to all modules at once, creating a self-contained AppLayer.
|
|
873
|
+
export const AppLayer = Layer.provide(AllModules, BaseLayer);
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
**Anti-Pattern:**
|
|
879
|
+
|
|
880
|
+
A flat composition strategy for a large application. While simple at first, it quickly becomes difficult to manage.
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
// ❌ This file becomes huge and hard to navigate in a large project.
|
|
884
|
+
const AppLayer = Layer.mergeAll(
|
|
885
|
+
LoggerLive,
|
|
886
|
+
ConfigLive,
|
|
887
|
+
DatabaseLive,
|
|
888
|
+
TracerLive,
|
|
889
|
+
UserServiceLive,
|
|
890
|
+
UserRepositoryLive,
|
|
891
|
+
ProductServiceLive,
|
|
892
|
+
ProductRepositoryLive,
|
|
893
|
+
BillingServiceLive
|
|
894
|
+
// ...and 50 other services
|
|
895
|
+
);
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
**Rationale:**
|
|
899
|
+
|
|
900
|
+
For large applications, avoid a single, flat list of services. Instead, structure your application by creating hierarchical layers:
|
|
901
|
+
|
|
902
|
+
1. **`BaseLayer`**: Provides application-wide infrastructure (Logger, Config, Database).
|
|
903
|
+
2. **`FeatureModule` Layers**: Provide the services for a specific business domain (e.g., `UserModule`, `ProductModule`). These depend on the `BaseLayer`.
|
|
904
|
+
3. **`AppLayer`**: The top-level layer that composes the feature modules by providing them with the `BaseLayer`.
|
|
905
|
+
|
|
906
|
+
---
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
As an application grows, a flat composition strategy where all services are merged into one giant layer becomes unwieldy and hard to reason about. The Composable Modules pattern solves this by introducing structure.
|
|
910
|
+
|
|
911
|
+
This approach creates a clean, scalable, and highly testable architecture where complexity is contained within each module. The top-level composition becomes a clear, high-level diagram of your application's architecture, and feature modules can be tested in isolation by providing them with a mocked `BaseLayer`.
|
|
912
|
+
|
|
913
|
+
---
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
### Test Streaming Effects
|
|
918
|
+
|
|
919
|
+
**Rule:** Use Stream.runCollect and assertions to verify stream behavior.
|
|
920
|
+
|
|
921
|
+
**Good Example:**
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
import { describe, it, expect } from "vitest"
|
|
925
|
+
import { Effect, Stream, Chunk, Ref } from "effect"
|
|
926
|
+
|
|
927
|
+
describe("Stream Testing", () => {
|
|
928
|
+
// ============================================
|
|
929
|
+
// 1. Test basic stream operations
|
|
930
|
+
// ============================================
|
|
931
|
+
|
|
932
|
+
it("should transform stream elements", async () => {
|
|
933
|
+
const result = await Effect.runPromise(
|
|
934
|
+
Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
|
|
935
|
+
Stream.map((n) => n * 2),
|
|
936
|
+
Stream.runCollect
|
|
937
|
+
)
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([2, 4, 6, 8, 10])
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
it("should filter stream elements", async () => {
|
|
944
|
+
const result = await Effect.runPromise(
|
|
945
|
+
Stream.fromIterable([1, 2, 3, 4, 5, 6]).pipe(
|
|
946
|
+
Stream.filter((n) => n % 2 === 0),
|
|
947
|
+
Stream.runCollect
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([2, 4, 6])
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
// ============================================
|
|
955
|
+
// 2. Test stream aggregation
|
|
956
|
+
// ============================================
|
|
957
|
+
|
|
958
|
+
it("should fold stream to single value", async () => {
|
|
959
|
+
const result = await Effect.runPromise(
|
|
960
|
+
Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
|
|
961
|
+
Stream.runFold(0, (acc, n) => acc + n)
|
|
962
|
+
)
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
expect(result).toBe(15)
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
it("should count stream elements", async () => {
|
|
969
|
+
const count = await Effect.runPromise(
|
|
970
|
+
Stream.fromIterable(["a", "b", "c", "d"]).pipe(
|
|
971
|
+
Stream.runCount
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
expect(count).toBe(4)
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
// ============================================
|
|
979
|
+
// 3. Test error handling in streams
|
|
980
|
+
// ============================================
|
|
981
|
+
|
|
982
|
+
it("should catch errors in stream", async () => {
|
|
983
|
+
const result = await Effect.runPromise(
|
|
984
|
+
Stream.fromIterable([1, 2, 3]).pipe(
|
|
985
|
+
Stream.mapEffect((n) =>
|
|
986
|
+
n === 2
|
|
987
|
+
? Effect.fail(new Error("Failed on 2"))
|
|
988
|
+
: Effect.succeed(n * 10)
|
|
989
|
+
),
|
|
990
|
+
Stream.catchAll((error) =>
|
|
991
|
+
Stream.succeed(-1) // Replace error with sentinel
|
|
992
|
+
),
|
|
993
|
+
Stream.runCollect
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([10, -1])
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it("should handle errors and continue with orElse", async () => {
|
|
1001
|
+
const failingStream = Stream.fail(new Error("Primary failed"))
|
|
1002
|
+
const fallbackStream = Stream.fromIterable([1, 2, 3])
|
|
1003
|
+
|
|
1004
|
+
const result = await Effect.runPromise(
|
|
1005
|
+
failingStream.pipe(
|
|
1006
|
+
Stream.orElse(() => fallbackStream),
|
|
1007
|
+
Stream.runCollect
|
|
1008
|
+
)
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([1, 2, 3])
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
// ============================================
|
|
1015
|
+
// 4. Test stream chunking
|
|
1016
|
+
// ============================================
|
|
1017
|
+
|
|
1018
|
+
it("should chunk stream elements", async () => {
|
|
1019
|
+
const result = await Effect.runPromise(
|
|
1020
|
+
Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
|
|
1021
|
+
Stream.grouped(2),
|
|
1022
|
+
Stream.runCollect
|
|
1023
|
+
)
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
const chunks = Chunk.toReadonlyArray(result).map(Chunk.toReadonlyArray)
|
|
1027
|
+
expect(chunks).toEqual([[1, 2], [3, 4], [5]])
|
|
1028
|
+
})
|
|
1029
|
+
|
|
1030
|
+
// ============================================
|
|
1031
|
+
// 5. Test stream with effects
|
|
1032
|
+
// ============================================
|
|
1033
|
+
|
|
1034
|
+
it("should run effects for each element", async () => {
|
|
1035
|
+
const processed: number[] = []
|
|
1036
|
+
|
|
1037
|
+
await Effect.runPromise(
|
|
1038
|
+
Stream.fromIterable([1, 2, 3]).pipe(
|
|
1039
|
+
Stream.tap((n) =>
|
|
1040
|
+
Effect.sync(() => {
|
|
1041
|
+
processed.push(n)
|
|
1042
|
+
})
|
|
1043
|
+
),
|
|
1044
|
+
Stream.runDrain
|
|
1045
|
+
)
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
expect(processed).toEqual([1, 2, 3])
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
// ============================================
|
|
1052
|
+
// 6. Test stream resource management
|
|
1053
|
+
// ============================================
|
|
1054
|
+
|
|
1055
|
+
it("should release resources on completion", async () => {
|
|
1056
|
+
const acquired: string[] = []
|
|
1057
|
+
const released: string[] = []
|
|
1058
|
+
|
|
1059
|
+
const managedStream = Stream.acquireRelease(
|
|
1060
|
+
Effect.gen(function* () {
|
|
1061
|
+
acquired.push("resource")
|
|
1062
|
+
return "resource"
|
|
1063
|
+
}),
|
|
1064
|
+
() =>
|
|
1065
|
+
Effect.sync(() => {
|
|
1066
|
+
released.push("resource")
|
|
1067
|
+
})
|
|
1068
|
+
).pipe(
|
|
1069
|
+
Stream.flatMap(() => Stream.fromIterable([1, 2, 3]))
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
await Effect.runPromise(Stream.runDrain(managedStream))
|
|
1073
|
+
|
|
1074
|
+
expect(acquired).toEqual(["resource"])
|
|
1075
|
+
expect(released).toEqual(["resource"])
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it("should release resources on error", async () => {
|
|
1079
|
+
const released: string[] = []
|
|
1080
|
+
|
|
1081
|
+
const managedStream = Stream.acquireRelease(
|
|
1082
|
+
Effect.succeed("resource"),
|
|
1083
|
+
() => Effect.sync(() => { released.push("released") })
|
|
1084
|
+
).pipe(
|
|
1085
|
+
Stream.flatMap(() =>
|
|
1086
|
+
Stream.fromEffect(Effect.fail(new Error("Oops")))
|
|
1087
|
+
)
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
await Effect.runPromise(
|
|
1091
|
+
Stream.runDrain(managedStream).pipe(
|
|
1092
|
+
Effect.catchAll(() => Effect.void)
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
expect(released).toEqual(["released"])
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
// ============================================
|
|
1100
|
+
// 7. Test stream timing with take/drop
|
|
1101
|
+
// ============================================
|
|
1102
|
+
|
|
1103
|
+
it("should take first N elements", async () => {
|
|
1104
|
+
const result = await Effect.runPromise(
|
|
1105
|
+
Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
|
|
1106
|
+
Stream.take(3),
|
|
1107
|
+
Stream.runCollect
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([1, 2, 3])
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
it("should drop first N elements", async () => {
|
|
1115
|
+
const result = await Effect.runPromise(
|
|
1116
|
+
Stream.fromIterable([1, 2, 3, 4, 5]).pipe(
|
|
1117
|
+
Stream.drop(2),
|
|
1118
|
+
Stream.runCollect
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
expect(Chunk.toReadonlyArray(result)).toEqual([3, 4, 5])
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
// ============================================
|
|
1126
|
+
// 8. Test stream merging
|
|
1127
|
+
// ============================================
|
|
1128
|
+
|
|
1129
|
+
it("should merge streams", async () => {
|
|
1130
|
+
const stream1 = Stream.fromIterable([1, 3, 5])
|
|
1131
|
+
const stream2 = Stream.fromIterable([2, 4, 6])
|
|
1132
|
+
|
|
1133
|
+
const result = await Effect.runPromise(
|
|
1134
|
+
Stream.merge(stream1, stream2).pipe(
|
|
1135
|
+
Stream.runCollect
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
const array = Chunk.toReadonlyArray(result)
|
|
1140
|
+
expect(array).toHaveLength(6)
|
|
1141
|
+
expect(array).toContain(1)
|
|
1142
|
+
expect(array).toContain(6)
|
|
1143
|
+
})
|
|
1144
|
+
})
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
**Rationale:**
|
|
1148
|
+
|
|
1149
|
+
Test streams by collecting results and verifying transformations, error handling, and resource management.
|
|
1150
|
+
|
|
1151
|
+
---
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
Stream tests verify:
|
|
1155
|
+
|
|
1156
|
+
1. **Transformations** - map, filter, flatMap work correctly
|
|
1157
|
+
2. **Error handling** - Failures are caught and handled
|
|
1158
|
+
3. **Resource safety** - Resources are released
|
|
1159
|
+
4. **Backpressure** - Data flow is controlled
|
|
1160
|
+
|
|
1161
|
+
---
|
|
1162
|
+
|
|
1163
|
+
---
|
|
1164
|
+
|
|
1165
|
+
### Test Concurrent Code
|
|
1166
|
+
|
|
1167
|
+
**Rule:** Use TestClock and controlled concurrency to make concurrent tests deterministic.
|
|
1168
|
+
|
|
1169
|
+
**Good Example:**
|
|
1170
|
+
|
|
1171
|
+
```typescript
|
|
1172
|
+
import { describe, it, expect } from "vitest"
|
|
1173
|
+
import { Effect, Fiber, Ref, TestClock, Duration, Deferred } from "effect"
|
|
1174
|
+
|
|
1175
|
+
describe("Concurrent Code Testing", () => {
|
|
1176
|
+
// ============================================
|
|
1177
|
+
// 1. Test parallel execution
|
|
1178
|
+
// ============================================
|
|
1179
|
+
|
|
1180
|
+
it("should run effects in parallel", async () => {
|
|
1181
|
+
const executionOrder: string[] = []
|
|
1182
|
+
|
|
1183
|
+
const task1 = Effect.gen(function* () {
|
|
1184
|
+
yield* Effect.sleep("100 millis")
|
|
1185
|
+
executionOrder.push("task1")
|
|
1186
|
+
return 1
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
const task2 = Effect.gen(function* () {
|
|
1190
|
+
yield* Effect.sleep("50 millis")
|
|
1191
|
+
executionOrder.push("task2")
|
|
1192
|
+
return 2
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
const program = Effect.all([task1, task2], { concurrency: 2 })
|
|
1196
|
+
|
|
1197
|
+
// Use TestClock to control time
|
|
1198
|
+
const result = await Effect.runPromise(
|
|
1199
|
+
Effect.gen(function* () {
|
|
1200
|
+
const fiber = yield* Effect.fork(program)
|
|
1201
|
+
|
|
1202
|
+
// Advance time to trigger both tasks
|
|
1203
|
+
yield* TestClock.adjust("100 millis")
|
|
1204
|
+
|
|
1205
|
+
return yield* Fiber.join(fiber)
|
|
1206
|
+
}).pipe(Effect.provide(TestClock.live))
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
expect(result).toEqual([1, 2])
|
|
1210
|
+
// With real time, task2 would complete first
|
|
1211
|
+
expect(executionOrder).toContain("task1")
|
|
1212
|
+
expect(executionOrder).toContain("task2")
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
// ============================================
|
|
1216
|
+
// 2. Test race conditions
|
|
1217
|
+
// ============================================
|
|
1218
|
+
|
|
1219
|
+
it("should handle race condition correctly", async () => {
|
|
1220
|
+
const counter = await Effect.runPromise(
|
|
1221
|
+
Effect.gen(function* () {
|
|
1222
|
+
const ref = yield* Ref.make(0)
|
|
1223
|
+
|
|
1224
|
+
// Simulate concurrent increments
|
|
1225
|
+
const increment = Ref.update(ref, (n) => n + 1)
|
|
1226
|
+
|
|
1227
|
+
// Run 100 concurrent increments
|
|
1228
|
+
yield* Effect.all(
|
|
1229
|
+
Array.from({ length: 100 }, () => increment),
|
|
1230
|
+
{ concurrency: "unbounded" }
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
return yield* Ref.get(ref)
|
|
1234
|
+
})
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
// Ref is atomic, so all increments should be counted
|
|
1238
|
+
expect(counter).toBe(100)
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
// ============================================
|
|
1242
|
+
// 3. Test with controlled fiber execution
|
|
1243
|
+
// ============================================
|
|
1244
|
+
|
|
1245
|
+
it("should test fiber lifecycle", async () => {
|
|
1246
|
+
const events: string[] = []
|
|
1247
|
+
|
|
1248
|
+
const program = Effect.gen(function* () {
|
|
1249
|
+
const fiber = yield* Effect.fork(
|
|
1250
|
+
Effect.gen(function* () {
|
|
1251
|
+
events.push("started")
|
|
1252
|
+
yield* Effect.sleep("1 second")
|
|
1253
|
+
events.push("completed")
|
|
1254
|
+
return "result"
|
|
1255
|
+
})
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
events.push("forked")
|
|
1259
|
+
|
|
1260
|
+
// Interrupt the fiber
|
|
1261
|
+
yield* Fiber.interrupt(fiber)
|
|
1262
|
+
events.push("interrupted")
|
|
1263
|
+
|
|
1264
|
+
const exit = yield* Fiber.await(fiber)
|
|
1265
|
+
return exit
|
|
1266
|
+
})
|
|
1267
|
+
|
|
1268
|
+
await Effect.runPromise(program)
|
|
1269
|
+
|
|
1270
|
+
expect(events).toEqual(["forked", "started", "interrupted"])
|
|
1271
|
+
expect(events).not.toContain("completed")
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
// ============================================
|
|
1275
|
+
// 4. Test timeout behavior
|
|
1276
|
+
// ============================================
|
|
1277
|
+
|
|
1278
|
+
it("should timeout slow operations", async () => {
|
|
1279
|
+
const slowOperation = Effect.gen(function* () {
|
|
1280
|
+
yield* Effect.sleep("10 seconds")
|
|
1281
|
+
return "completed"
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
const result = await Effect.runPromise(
|
|
1285
|
+
Effect.gen(function* () {
|
|
1286
|
+
const fiber = yield* Effect.fork(
|
|
1287
|
+
slowOperation.pipe(Effect.timeout("1 second"))
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
// Advance past the timeout
|
|
1291
|
+
yield* TestClock.adjust("2 seconds")
|
|
1292
|
+
|
|
1293
|
+
return yield* Fiber.join(fiber)
|
|
1294
|
+
}).pipe(Effect.provide(TestClock.live))
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
// Result is Option.None due to timeout
|
|
1298
|
+
expect(result._tag).toBe("None")
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
// ============================================
|
|
1302
|
+
// 5. Test with Deferred for synchronization
|
|
1303
|
+
// ============================================
|
|
1304
|
+
|
|
1305
|
+
it("should synchronize fibers correctly", async () => {
|
|
1306
|
+
const result = await Effect.runPromise(
|
|
1307
|
+
Effect.gen(function* () {
|
|
1308
|
+
const deferred = yield* Deferred.make<string>()
|
|
1309
|
+
const results: string[] = []
|
|
1310
|
+
|
|
1311
|
+
// Consumer waits for producer
|
|
1312
|
+
const consumer = Effect.fork(
|
|
1313
|
+
Effect.gen(function* () {
|
|
1314
|
+
const value = yield* Deferred.await(deferred)
|
|
1315
|
+
results.push(`consumed: ${value}`)
|
|
1316
|
+
})
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
// Producer completes the deferred
|
|
1320
|
+
const producer = Effect.gen(function* () {
|
|
1321
|
+
results.push("producing")
|
|
1322
|
+
yield* Deferred.succeed(deferred, "data")
|
|
1323
|
+
results.push("produced")
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
yield* consumer
|
|
1327
|
+
yield* producer
|
|
1328
|
+
|
|
1329
|
+
// Wait for consumer to process
|
|
1330
|
+
yield* Effect.sleep("10 millis")
|
|
1331
|
+
|
|
1332
|
+
return results
|
|
1333
|
+
})
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
expect(result).toContain("producing")
|
|
1337
|
+
expect(result).toContain("produced")
|
|
1338
|
+
expect(result).toContain("consumed: data")
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
// ============================================
|
|
1342
|
+
// 6. Test for absence of deadlocks
|
|
1343
|
+
// ============================================
|
|
1344
|
+
|
|
1345
|
+
it("should not deadlock with proper resource ordering", async () => {
|
|
1346
|
+
const result = await Effect.runPromise(
|
|
1347
|
+
Effect.gen(function* () {
|
|
1348
|
+
const ref1 = yield* Ref.make(0)
|
|
1349
|
+
const ref2 = yield* Ref.make(0)
|
|
1350
|
+
|
|
1351
|
+
// Two fibers accessing refs in same order (no deadlock)
|
|
1352
|
+
const fiber1 = yield* Effect.fork(
|
|
1353
|
+
Effect.gen(function* () {
|
|
1354
|
+
yield* Ref.update(ref1, (n) => n + 1)
|
|
1355
|
+
yield* Ref.update(ref2, (n) => n + 1)
|
|
1356
|
+
})
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
const fiber2 = yield* Effect.fork(
|
|
1360
|
+
Effect.gen(function* () {
|
|
1361
|
+
yield* Ref.update(ref1, (n) => n + 1)
|
|
1362
|
+
yield* Ref.update(ref2, (n) => n + 1)
|
|
1363
|
+
})
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
yield* Fiber.join(fiber1)
|
|
1367
|
+
yield* Fiber.join(fiber2)
|
|
1368
|
+
|
|
1369
|
+
return [yield* Ref.get(ref1), yield* Ref.get(ref2)]
|
|
1370
|
+
}).pipe(Effect.timeout("1 second"))
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
expect(result._tag).toBe("Some")
|
|
1374
|
+
expect(result.value).toEqual([2, 2])
|
|
1375
|
+
})
|
|
1376
|
+
})
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
**Rationale:**
|
|
1380
|
+
|
|
1381
|
+
Use Effect's TestClock and fiber control to make concurrent tests deterministic and repeatable.
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
Concurrent code is hard to test:
|
|
1387
|
+
|
|
1388
|
+
1. **Non-determinism** - Different runs, different results
|
|
1389
|
+
2. **Race conditions** - Timing-dependent bugs
|
|
1390
|
+
3. **Deadlocks** - Hard to reproduce
|
|
1391
|
+
4. **Flaky tests** - Pass sometimes, fail others
|
|
1392
|
+
|
|
1393
|
+
Effect's test utilities provide control over timing and concurrency.
|
|
1394
|
+
|
|
1395
|
+
---
|
|
1396
|
+
|
|
1397
|
+
---
|
|
1398
|
+
|
|
1399
|
+
### Property-Based Testing with Effect
|
|
1400
|
+
|
|
1401
|
+
**Rule:** Use property-based testing to find edge cases your example-based tests miss.
|
|
1402
|
+
|
|
1403
|
+
**Good Example:**
|
|
1404
|
+
|
|
1405
|
+
```typescript
|
|
1406
|
+
import { describe, it, expect } from "vitest"
|
|
1407
|
+
import { Effect, Option, Either, Schema } from "effect"
|
|
1408
|
+
import * as fc from "fast-check"
|
|
1409
|
+
|
|
1410
|
+
describe("Property-Based Testing with Effect", () => {
|
|
1411
|
+
// ============================================
|
|
1412
|
+
// 1. Test pure function properties
|
|
1413
|
+
// ============================================
|
|
1414
|
+
|
|
1415
|
+
it("should satisfy array reverse properties", () => {
|
|
1416
|
+
fc.assert(
|
|
1417
|
+
fc.property(fc.array(fc.integer()), (arr) => {
|
|
1418
|
+
// Reversing twice returns original
|
|
1419
|
+
const reversed = arr.slice().reverse()
|
|
1420
|
+
const doubleReversed = reversed.slice().reverse()
|
|
1421
|
+
|
|
1422
|
+
return JSON.stringify(arr) === JSON.stringify(doubleReversed)
|
|
1423
|
+
})
|
|
1424
|
+
)
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
it("should satisfy sort idempotence", () => {
|
|
1428
|
+
fc.assert(
|
|
1429
|
+
fc.property(fc.array(fc.integer()), (arr) => {
|
|
1430
|
+
const sorted = arr.slice().sort((a, b) => a - b)
|
|
1431
|
+
const sortedTwice = sorted.slice().sort((a, b) => a - b)
|
|
1432
|
+
|
|
1433
|
+
return JSON.stringify(sorted) === JSON.stringify(sortedTwice)
|
|
1434
|
+
})
|
|
1435
|
+
)
|
|
1436
|
+
})
|
|
1437
|
+
|
|
1438
|
+
// ============================================
|
|
1439
|
+
// 2. Test Effect operations
|
|
1440
|
+
// ============================================
|
|
1441
|
+
|
|
1442
|
+
it("should map then flatMap equals flatMap with mapping", async () => {
|
|
1443
|
+
await fc.assert(
|
|
1444
|
+
fc.asyncProperty(fc.integer(), async (n) => {
|
|
1445
|
+
const f = (x: number) => x * 2
|
|
1446
|
+
const g = (x: number) => Effect.succeed(x + 1)
|
|
1447
|
+
|
|
1448
|
+
// map then flatMap
|
|
1449
|
+
const result1 = await Effect.runPromise(
|
|
1450
|
+
Effect.succeed(n).pipe(
|
|
1451
|
+
Effect.map(f),
|
|
1452
|
+
Effect.flatMap(g)
|
|
1453
|
+
)
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
// flatMap with mapping inside
|
|
1457
|
+
const result2 = await Effect.runPromise(
|
|
1458
|
+
Effect.succeed(n).pipe(
|
|
1459
|
+
Effect.flatMap((x) => g(f(x)))
|
|
1460
|
+
)
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
return result1 === result2
|
|
1464
|
+
})
|
|
1465
|
+
)
|
|
1466
|
+
})
|
|
1467
|
+
|
|
1468
|
+
// ============================================
|
|
1469
|
+
// 3. Test Option properties
|
|
1470
|
+
// ============================================
|
|
1471
|
+
|
|
1472
|
+
it("should satisfy Option map identity", () => {
|
|
1473
|
+
fc.assert(
|
|
1474
|
+
fc.property(fc.option(fc.integer(), { nil: undefined }), (maybeN) => {
|
|
1475
|
+
const option = maybeN === undefined ? Option.none() : Option.some(maybeN)
|
|
1476
|
+
|
|
1477
|
+
// Mapping identity function returns same Option
|
|
1478
|
+
const mapped = Option.map(option, (x) => x)
|
|
1479
|
+
|
|
1480
|
+
return Option.getOrElse(option, () => -1) ===
|
|
1481
|
+
Option.getOrElse(mapped, () => -1)
|
|
1482
|
+
})
|
|
1483
|
+
)
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
// ============================================
|
|
1487
|
+
// 4. Test Schema encode/decode roundtrip
|
|
1488
|
+
// ============================================
|
|
1489
|
+
|
|
1490
|
+
it("should roundtrip through Schema", async () => {
|
|
1491
|
+
const UserSchema = Schema.Struct({
|
|
1492
|
+
name: Schema.String,
|
|
1493
|
+
age: Schema.Number.pipe(Schema.int(), Schema.positive()),
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1496
|
+
const userArbitrary = fc.record({
|
|
1497
|
+
name: fc.string({ minLength: 1 }),
|
|
1498
|
+
age: fc.integer({ min: 1, max: 120 }),
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
await fc.assert(
|
|
1502
|
+
fc.asyncProperty(userArbitrary, async (user) => {
|
|
1503
|
+
const encode = Schema.encode(UserSchema)
|
|
1504
|
+
const decode = Schema.decode(UserSchema)
|
|
1505
|
+
|
|
1506
|
+
// Encode then decode should return equivalent value
|
|
1507
|
+
const encoded = await Effect.runPromise(encode(user))
|
|
1508
|
+
const decoded = await Effect.runPromise(decode(encoded))
|
|
1509
|
+
|
|
1510
|
+
return decoded.name === user.name && decoded.age === user.age
|
|
1511
|
+
})
|
|
1512
|
+
)
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
// ============================================
|
|
1516
|
+
// 5. Test error handling properties
|
|
1517
|
+
// ============================================
|
|
1518
|
+
|
|
1519
|
+
it("should recover from any error", async () => {
|
|
1520
|
+
await fc.assert(
|
|
1521
|
+
fc.asyncProperty(
|
|
1522
|
+
fc.string(),
|
|
1523
|
+
fc.string(),
|
|
1524
|
+
async (errorMsg, fallback) => {
|
|
1525
|
+
const failing = Effect.fail(new Error(errorMsg))
|
|
1526
|
+
|
|
1527
|
+
const result = await Effect.runPromise(
|
|
1528
|
+
failing.pipe(
|
|
1529
|
+
Effect.catchAll(() => Effect.succeed(fallback))
|
|
1530
|
+
)
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
return result === fallback
|
|
1534
|
+
}
|
|
1535
|
+
)
|
|
1536
|
+
)
|
|
1537
|
+
})
|
|
1538
|
+
|
|
1539
|
+
// ============================================
|
|
1540
|
+
// 6. Custom generators for domain types
|
|
1541
|
+
// ============================================
|
|
1542
|
+
|
|
1543
|
+
interface Email {
|
|
1544
|
+
readonly _tag: "Email"
|
|
1545
|
+
readonly value: string
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const emailArbitrary = fc.emailAddress().map((value): Email => ({
|
|
1549
|
+
_tag: "Email",
|
|
1550
|
+
value,
|
|
1551
|
+
}))
|
|
1552
|
+
|
|
1553
|
+
interface UserId {
|
|
1554
|
+
readonly _tag: "UserId"
|
|
1555
|
+
readonly value: string
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const userIdArbitrary = fc.uuid().map((value): UserId => ({
|
|
1559
|
+
_tag: "UserId",
|
|
1560
|
+
value,
|
|
1561
|
+
}))
|
|
1562
|
+
|
|
1563
|
+
it("should handle domain types correctly", () => {
|
|
1564
|
+
fc.assert(
|
|
1565
|
+
fc.property(emailArbitrary, userIdArbitrary, (email, userId) => {
|
|
1566
|
+
// Test your domain functions with generated domain types
|
|
1567
|
+
return email.value.includes("@") && userId.value.length > 0
|
|
1568
|
+
})
|
|
1569
|
+
)
|
|
1570
|
+
})
|
|
1571
|
+
|
|
1572
|
+
// ============================================
|
|
1573
|
+
// 7. Test algebraic properties
|
|
1574
|
+
// ============================================
|
|
1575
|
+
|
|
1576
|
+
it("should satisfy monoid properties for string concat", () => {
|
|
1577
|
+
const empty = ""
|
|
1578
|
+
const concat = (a: string, b: string) => a + b
|
|
1579
|
+
|
|
1580
|
+
fc.assert(
|
|
1581
|
+
fc.property(fc.string(), fc.string(), fc.string(), (a, b, c) => {
|
|
1582
|
+
// Identity: empty + a = a = a + empty
|
|
1583
|
+
const leftIdentity = concat(empty, a) === a
|
|
1584
|
+
const rightIdentity = concat(a, empty) === a
|
|
1585
|
+
|
|
1586
|
+
// Associativity: (a + b) + c = a + (b + c)
|
|
1587
|
+
const associative = concat(concat(a, b), c) === concat(a, concat(b, c))
|
|
1588
|
+
|
|
1589
|
+
return leftIdentity && rightIdentity && associative
|
|
1590
|
+
})
|
|
1591
|
+
)
|
|
1592
|
+
})
|
|
1593
|
+
|
|
1594
|
+
// ============================================
|
|
1595
|
+
// 8. Test with constraints
|
|
1596
|
+
// ============================================
|
|
1597
|
+
|
|
1598
|
+
it("should handle positive numbers", () => {
|
|
1599
|
+
fc.assert(
|
|
1600
|
+
fc.property(
|
|
1601
|
+
fc.integer({ min: 1, max: 1000000 }),
|
|
1602
|
+
fc.integer({ min: 1, max: 1000000 }),
|
|
1603
|
+
(a, b) => {
|
|
1604
|
+
// Division of positives is positive
|
|
1605
|
+
const result = a / b
|
|
1606
|
+
return result > 0
|
|
1607
|
+
}
|
|
1608
|
+
)
|
|
1609
|
+
)
|
|
1610
|
+
})
|
|
1611
|
+
})
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
**Rationale:**
|
|
1615
|
+
|
|
1616
|
+
Use property-based testing with fast-check to test invariants and find edge cases automatically.
|
|
1617
|
+
|
|
1618
|
+
---
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
Property-based testing finds bugs that example tests miss:
|
|
1622
|
+
|
|
1623
|
+
1. **Edge cases** - Empty arrays, negative numbers, unicode
|
|
1624
|
+
2. **Invariants** - Properties that should always hold
|
|
1625
|
+
3. **Shrinking** - Minimal failing examples
|
|
1626
|
+
4. **Coverage** - Many inputs from one test
|
|
1627
|
+
|
|
1628
|
+
---
|
|
1629
|
+
|
|
1630
|
+
---
|
|
1631
|
+
|
|
1632
|
+
|