Skip to content

Testing

This guide covers how to test effect-dynamodb entities using Effect’s service-based architecture. You will learn how to mock the DynamoDB client, verify CRUD operations, assert on error paths, and test queries.

Define your models and entities as usual. These are the same models you use in production code:

models.ts
class Employee extends Schema.Class<Employee>("Employee")({
employeeId: Schema.String,
email: Schema.String,
displayName: Schema.NonEmptyString,
department: Schema.String,
createdBy: Schema.String,
}) {}
const EmployeeModel = DynamoModel.configure(Employee, {
createdBy: { immutable: true },
})
class Task extends Schema.Class<Task>("Task")({
taskId: Schema.String,
projectId: Schema.String,
title: Schema.NonEmptyString,
status: Schema.Literals(["active", "completed", "archived"]),
assignee: Schema.String,
}) {}
entities.ts
const AppSchema = DynamoSchema.make({ name: "myapp", version: 1 })
const EmployeeEntity = Entity.make({
model: EmployeeModel,
entityType: "Employee",
primaryKey: {
pk: { field: "pk", composite: ["employeeId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byEmail: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["email"] },
sk: { field: "gsi1sk", composite: [] },
},
},
unique: { email: ["email"] },
timestamps: true,
versioned: true,
})
const TaskEntity = Entity.make({
model: Task,
entityType: "Task",
primaryKey: {
pk: { field: "pk", composite: ["taskId"] },
sk: { field: "sk", composite: [] },
},
indexes: {
byProject: {
name: "gsi1",
pk: { field: "gsi1pk", composite: ["projectId"] },
sk: { field: "gsi1sk", composite: ["status"] },
type: "clustered",
},
},
timestamps: true,
})
const MainTable = Table.make({
schema: AppSchema,
entities: { EmployeeEntity, TaskEntity },
})

effect-dynamodb uses DynamoClient as an Effect Service (Context.Service). In tests, provide a mock implementation via Layer.succeed. Each method receives the raw AWS SDK input and returns an Effect:

mock-client.ts
// Track calls for assertions
let putItemCalls: Array<unknown> = []
let getItemCalls: Array<unknown> = []
let queryCalls: Array<unknown> = []
let transactWriteItemsCalls: Array<unknown> = []
// Configurable mock responses
let putItemResponse: Effect.Effect<unknown, DynamoError> = Effect.succeed({})
let getItemResponse: Effect.Effect<unknown, DynamoError> = Effect.succeed({})
let queryResponse: Effect.Effect<unknown, DynamoError> = Effect.succeed({})
let transactWriteItemsResponse: Effect.Effect<unknown, DynamoError> = Effect.succeed({})
const resetMocks = () => {
putItemCalls = []
getItemCalls = []
queryCalls = []
transactWriteItemsCalls = []
putItemResponse = Effect.succeed({})
getItemResponse = Effect.succeed({})
queryResponse = Effect.succeed({})
transactWriteItemsResponse = Effect.succeed({})
}
const MockDynamoClient = Layer.succeed(DynamoClient, {
putItem: (input) => {
putItemCalls.push(input)
return putItemResponse as any
},
getItem: (input) => {
getItemCalls.push(input)
return getItemResponse as any
},
query: (input) => {
queryCalls.push(input)
return queryResponse as any
},
updateItem: () => Effect.die("not used"),
deleteItem: () => Effect.die("not used"),
transactWriteItems: (input) => {
transactWriteItemsCalls.push(input)
return transactWriteItemsResponse as any
},
batchGetItem: () => Effect.die("not used"),
batchWriteItem: () => Effect.die("not used"),
transactGetItems: () => Effect.die("not used"),
scan: () => Effect.die("not used"),
createTable: () => Effect.die("not used"),
deleteTable: () => Effect.die("not used"),
describeTable: () => Effect.die("not used"),
updateTable: () => Effect.die("not used"),
listTables: () => Effect.die("not used"),
createBackup: () => Effect.die("not used"),
deleteBackup: () => Effect.die("not used"),
listBackups: () => Effect.die("not used"),
restoreTableFromBackup: () => Effect.die("not used"),
describeContinuousBackups: () => Effect.die("not used"),
updateContinuousBackups: () => Effect.die("not used"),
restoreTableToPointInTime: () => Effect.die("not used"),
exportTableToPointInTime: () => Effect.die("not used"),
describeExport: () => Effect.die("not used"),
updateTimeToLive: () => Effect.die("not used"),
describeTimeToLive: () => Effect.die("not used"),
tagResource: () => Effect.die("not used"),
untagResource: () => Effect.die("not used"),
listTagsOfResource: () => Effect.die("not used"),
})
const TestLayer = Layer.merge(MockDynamoClient, MainTable.layer({ name: "test-table" }))

Create a typed client with DynamoClient.make() and assert that the entity’s put method calls the underlying DynamoDB client correctly. Wire the mock layer with Effect.provide(TestLayer):

const testPutOperation = Effect.gen(function* () {
resetMocks()
// EmployeeEntity has unique constraints, so it uses transactWriteItems
transactWriteItemsResponse = Effect.succeed({})
const db = yield* DynamoClient.make({
entities: { EmployeeEntity, TaskEntity },
})
const result = yield* db.entities.EmployeeEntity.put({
employeeId: "emp-1",
email: "alice@acme.com",
displayName: "Alice",
department: "Engineering",
createdBy: "admin",
})
// Verify the result
console.assert(result.employeeId === "emp-1", "employeeId should be emp-1")
console.assert(result.displayName === "Alice", "displayName should be Alice")
console.assert(transactWriteItemsCalls.length === 1, "transactWriteItems should be called once")
yield* Console.log(" Put operation: PASSED")
}).pipe(Effect.provide(TestLayer))

Mock the client to return a failure and use Effect.flip to inspect the error channel. When a unique constraint entity receives a TransactionCanceledException, effect-dynamodb translates it to a UniqueConstraintViolation:

// #region test-error
const testErrorPath = Effect.gen(function* () {
resetMocks()
// Simulate a TransactionCanceledException (unique constraint violation).
// Index 0 = main item (succeeds), index 1 = sentinel (fails with ConditionalCheckFailed).
transactWriteItemsResponse = Effect.fail(
new DynamoError({
operation: "TransactWriteItems",
cause: {
name: "TransactionCanceledException",
CancellationReasons: [{ Code: "None" }, { Code: "ConditionalCheckFailed" }],
},
}),
)
const db = yield* DynamoClient.make({
entities: { EmployeeEntity, TaskEntity },
})
const result = yield* db.entities.EmployeeEntity.put({
employeeId: "emp-2",
email: "taken@example.com",
displayName: "Bob",
department: "Sales",
createdBy: "admin",
})
.asEffect()
.pipe(Effect.flip)
// The entity layer translates the DynamoDB error to a domain error
console.assert(
result._tag === "UniqueConstraintViolation",
`expected UniqueConstraintViolation, got ${result._tag}`,
)
yield* Console.log(" Error path (UniqueConstraintViolation): PASSED")
}).pipe(Effect.provide(TestLayer))

Return mock query results from the client and verify the decoded output. Use toAttributeMap from the Marshaller to build DynamoDB-formatted items:

const testQueryResults = Effect.gen(function* () {
resetMocks()
// Return mock query results from the client
queryResponse = Effect.succeed({
Items: [
toAttributeMap({
pk: "$myapp#v1#task#t-1",
sk: "$myapp#v1#task",
gsi1pk: "$myapp#v1#task#proj-alpha",
gsi1sk: "$myapp#v1#tasksByProject#task#active",
__edd_e__: "Task",
taskId: "t-1",
projectId: "proj-alpha",
title: "Implement feature",
status: "active",
assignee: "alice",
createdAt: "2024-01-15T10:30:00.000Z",
updatedAt: "2024-01-15T10:30:00.000Z",
}),
toAttributeMap({
pk: "$myapp#v1#task#t-2",
sk: "$myapp#v1#task",
gsi1pk: "$myapp#v1#task#proj-alpha",
gsi1sk: "$myapp#v1#tasksByProject#task#active",
__edd_e__: "Task",
taskId: "t-2",
projectId: "proj-alpha",
title: "Write tests",
status: "active",
assignee: "bob",
createdAt: "2024-01-16T09:00:00.000Z",
updatedAt: "2024-01-16T09:00:00.000Z",
}),
],
LastEvaluatedKey: undefined,
})
const db = yield* DynamoClient.make({
entities: { EmployeeEntity, TaskEntity },
})
const results = yield* db.entities.TaskEntity.byProject({ projectId: "proj-alpha" })
.filter({ status: "active" })
.collect()
// Verify decoded results
console.assert(results.length === 2, `expected 2 results, got ${results.length}`)
console.assert(results[0]!.taskId === "t-1", "first task should be t-1")
console.assert(results[1]!.taskId === "t-2", "second task should be t-2")
console.assert(results[0]!.title === "Implement feature", "first task title should match")
console.assert(queryCalls.length === 1, "query should be called once")
yield* Console.log(" Query results: PASSED")
}).pipe(Effect.provide(TestLayer))

In a real test suite, use @effect/vitest with it.effect instead of running assertions manually. The Layer.succeed and Effect.provide patterns are identical:

employees.test.ts
import { describe, expect, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { DynamoClient } from "effect-dynamodb"
import { beforeEach, vi } from "vitest"
describe("EmployeeEntity", () => {
beforeEach(() => vi.resetAllMocks())
it.effect("creates an employee", () =>
Effect.gen(function* () {
// ... same pattern as above, but with vi.fn() and expect()
const db = yield* DynamoClient.make({
entities: { EmployeeEntity, TaskEntity },
tables: { MainTable },
})
const result = yield* db.entities.EmployeeEntity.put({
employeeId: "emp-1",
email: "alice@acme.com",
displayName: "Alice",
department: "Engineering",
createdBy: "admin",
})
expect(result.employeeId).toBe("emp-1")
expect(result.displayName).toBe("Alice")
}).pipe(Effect.provide(TestLayer))
)
})