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.
Domain Models and Entities
Section titled “Domain Models and Entities”Define your models and entities as usual. These are the same models you use in production code:
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,}) {}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 },})Set Up a Mock DynamoDB Client
Section titled “Set Up a Mock DynamoDB Client”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:
// Track calls for assertionslet putItemCalls: Array<unknown> = []let getItemCalls: Array<unknown> = []let queryCalls: Array<unknown> = []let transactWriteItemsCalls: Array<unknown> = []
// Configurable mock responseslet 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" }))Verify a Put Operation
Section titled “Verify a Put Operation”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))Assert on Error Paths
Section titled “Assert on Error Paths”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-errorconst 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))Test Query Results
Section titled “Test Query Results”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))Using with Vitest
Section titled “Using with Vitest”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:
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)) )})What’s Next?
Section titled “What’s Next?”- Getting Started — Quick start guide
- DynamoDB Streams — Process change events from DynamoDB Streams