diff --git a/Sources/Temporal/Documentation.docc/Implementing-Activities.md b/Sources/Temporal/Documentation.docc/Implementing-Activities.md index 413d158..5946153 100644 --- a/Sources/Temporal/Documentation.docc/Implementing-Activities.md +++ b/Sources/Temporal/Documentation.docc/Implementing-Activities.md @@ -171,3 +171,5 @@ func longRunningCalculation(input: Int) async throws -> Int { return result } ``` + +To learn how to test your activities, see . diff --git a/Sources/Temporal/Documentation.docc/Temporal.md b/Sources/Temporal/Documentation.docc/Temporal.md index b3f16ea..3ad409a 100644 --- a/Sources/Temporal/Documentation.docc/Temporal.md +++ b/Sources/Temporal/Documentation.docc/Temporal.md @@ -40,6 +40,13 @@ Build activities and workflows. - - +### Testing + +Test your workflows and activities. + +- +- + ### Developing workflows Create workflows that orchestrate business logic and coordinate activities. diff --git a/Sources/Temporal/Documentation.docc/Testing/Testing-Activities.md b/Sources/Temporal/Documentation.docc/Testing/Testing-Activities.md new file mode 100644 index 0000000..1ba5ca9 --- /dev/null +++ b/Sources/Temporal/Documentation.docc/Testing/Testing-Activities.md @@ -0,0 +1,127 @@ +# Testing activities + +Test activities in isolation with the activity test environment, or against a +local Temporal test server. + +## Overview + +Activities run the side-effecting work in your Temporal application — +calls to external services, database operations, and other actions a +workflow can't do directly. You can test activities in isolation, verify +heartbeat details, simulate cancellation, and run activities as part of +a full workflow test. There are two approaches: + +1. **Unit tests** with `withActivityTestEnvironment` cover activity logic, + heartbeats, and cancellation without starting a server. +2. **Integration tests** with `TemporalTestServer` verify the full + activity-workflow interaction, including serialization and retries. + +> Tip: Add `TemporalTestKit` as a test dependency in your Package.swift to +> access the test environment and test server. + +### Unit testing activities with the test environment + +Use `withActivityTestEnvironment` from the `TemporalTestKit` module to test +activities that depend on ``ActivityExecutionContext``. This sets up a context +without requiring a Temporal server. + +``ActivityExecutionContext/Info`` has a convenience initializer that fills in +defaults for every field. Pass only the values your test cares about: + +```swift +import Temporal +import TemporalTestKit +import Testing + +@Test +func sayHelloReturnsGreeting() async throws { + let info = try await ActivityExecutionContext.Info() + + try await withActivityTestEnvironment(info: info) { + let result = GreetingActivities().sayHello(input: "World") + #expect(result == "Hello, World!") + } +} +``` + +To test an activity that reads its execution context — for example, one that +resumes from the last heartbeat on retry — pass specific values through the +initializer: + +```swift +@Test +func activityResumesFromHeartbeat() async throws { + let info = try await ActivityExecutionContext.Info( + attempt: 2, + heartbeatDetails: 42 + ) + + try await withActivityTestEnvironment(info: info) { + let context = try #require(ActivityExecutionContext.current) + let lastProgress = try await context.info.heartbeatDetails( + as: Int.self + ) + #expect(lastProgress == 42) + } +} +``` + +### Verifying heartbeats + +Use the `assertHeartbeatDetails` closure to inspect heartbeats recorded by +the activity. The closure receives a +`HeartbeatDetailsSequence` that yields each heartbeat's details as an array +from the activity: + +```swift +@Test +func activityRecordsHeartbeats() async throws { + let info = try await ActivityExecutionContext.Info() + + try await withActivityTestEnvironment(info: info) { + let context = try #require(ActivityExecutionContext.current) + context.heartbeat(details: "step-1") + context.heartbeat(details: "step-2") + } assertHeartbeatDetails: { heartbeats in + var recorded: [String] = [] + for try await details in heartbeats { + guard let values = details as? [String] else { continue } + recorded.append(contentsOf: values) + } + #expect(recorded == ["step-1", "step-2"]) + } +} +``` + +### Testing cancellation + +Activity cancellation in Temporal propagates through Swift's built-in task +cancellation. To test that your activity handles cancellation correctly, +cancel the task from outside: + +```swift +@Test +func activityHandlesCancellation() async throws { + let info = try await ActivityExecutionContext.Info() + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await withActivityTestEnvironment(info: info) { + try await Task.sleep(for: .seconds(60)) + } + } + group.cancelAll() + } +} +``` + +You can also pass a `cancellationReason` to make a specific reason available +through ``ActivityExecutionContext/cancellationReason``. See +``ActivityCancellationReason`` for the full list of reasons. + +### Integration testing of activities with a workflow + +For end-to-end testing of activities as part of a workflow execution, see +. The integration test pattern is the same: apply a test +trait, register your activities with `TemporalTestServer.withConnectedWorker`, +and start the workflow through a connected client. diff --git a/Sources/Temporal/Documentation.docc/Testing/Testing-Workflows.md b/Sources/Temporal/Documentation.docc/Testing/Testing-Workflows.md new file mode 100644 index 0000000..af6e2ad --- /dev/null +++ b/Sources/Temporal/Documentation.docc/Testing/Testing-Workflows.md @@ -0,0 +1,257 @@ +# Testing workflows + +Run workflows against a local test server, and catch non-deterministic code +changes with replay tests before they reach production. + +## Overview + +Temporal workflows are deterministic and replay-safe, so the same input +history produces the same result. This property makes workflows especially +testable, but it also means that innocent-looking code changes can break +replay in production. + +You can run workflows against a local test server; test signals, queries, +and updates; skip time for long-running workflows; and verify that code +changes stay deterministic with replay testing. There are two main approaches: + +1. **Integration tests** with `TemporalTestServer` that run workflows against + a local Temporal server. +2. **Replay tests** with ``WorkflowReplayer`` that verify workflow code changes + are compatible with recorded execution histories. + +### Integration testing a workflow + +Apply the `.temporalTestServer` test trait to start a local Temporal server. +Use `TemporalTestServer.withConnectedWorker` and +`TemporalTestServer.withConnectedClient` to set up a worker and client +connected to the test server: + +```swift +import Logging +import Temporal +import TemporalTestKit +import Testing + +@Suite(.temporalTestServer) +struct GreetingWorkflowTests { + @Test + func greetingReturnsHello() async throws { + let testServer = TemporalTestServer.testServer! + let taskQueue = "test-\(UUID())" + let logger = Logger(label: "test") + + let config = TemporalWorker.Configuration( + namespace: "default", + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + try await testServer.withConnectedWorker( + configuration: config, + activities: GreetingActivities().allActivities, + workflows: [GreetingWorkflow.self] + ) { _ in + try await testServer.withConnectedClient( + logger: logger + ) { client in + let handle = try await client.startWorkflow( + type: GreetingWorkflow.self, + options: .init( + id: "wf-\(UUID())", + taskQueue: taskQueue + ), + input: "World" + ) + let result = try await handle.result() + #expect(result == "Hello, World!") + } + } + } +} +``` + +> Important: The `.temporalTestServer` trait manages the server lifecycle. +> It starts before your tests run and shuts down when they complete. + +### Testing signals, queries, and updates + +Send signals, run queries, and execute updates on a running workflow to verify +handler behavior. For how to define these handlers, see +. + +The following workflow defines signal, query, and update handlers: + +```swift +@Workflow +struct CounterWorkflow { + private var count = 0 + + mutating func run( + context: WorkflowContext, + input: Void + ) async throws -> Int { + try await context.condition { $0.count >= 3 } + return count + } + + @WorkflowSignal + mutating func increment(input: Void) { + count += 1 + } + + @WorkflowQuery + func currentCount(input: Void) -> Int { + count + } + + @WorkflowUpdate + mutating func setCount(input: Int) -> Int { + count = input + return count + } +} +``` + +Test each handler type through the workflow handle. The worker and client +setup is the same as the integration test above. The test body sends signals, +runs a query, and executes an update: + +```swift +let handle = try await client.startWorkflow( + type: CounterWorkflow.self, + options: .init(id: "wf-\(UUID())", taskQueue: taskQueue) +) + +try await handle.signal(signalType: CounterWorkflow.Increment.self) +try await handle.signal(signalType: CounterWorkflow.Increment.self) + +let count = try await handle.query( + queryType: CounterWorkflow.CurrentCount.self +) +#expect(count == 2) + +// Set the counter directly via an update +let updated = try await handle.executeUpdate( + updateType: CounterWorkflow.SetCount.self, + input: 10 +) +#expect(updated == 10) + +let result = try await handle.result() +#expect(result == 10) +``` + +### Testing time-dependent workflows + +Use the `.temporalTimeSkippingTestServer` test trait to test workflows that +sleep or use timers. The time-skipping server fast-forwards time when the +workflow is idle, so tests complete in seconds regardless of the sleep +duration: + +```swift +@Workflow +struct DelayedGreetingWorkflow { + mutating func run( + context: WorkflowContext, + input: String + ) async throws -> String { + try await context.sleep(for: .seconds(86_400)) + return "Hello, \(input)!" + } +} +``` + +```swift +@Suite(.temporalTimeSkippingTestServer) +struct TimeSkippingTests { + @Test + func delayedGreetingCompletesQuickly() async throws { + let testServer = TemporalTestServer.timeSkippingTestServer! + let taskQueue = "test-\(UUID())" + let logger = Logger(label: "test") + + let config = TemporalWorker.Configuration( + namespace: "default", + taskQueue: taskQueue, + instrumentation: .init(serverHostname: "localhost") + ) + + try await testServer.withConnectedWorker( + configuration: config, + workflows: [DelayedGreetingWorkflow.self] + ) { _ in + try await testServer.withConnectedClient( + logger: logger + ) { client in + let start = ContinuousClock.now + let handle = try await client.startWorkflow( + type: DelayedGreetingWorkflow.self, + options: .init( + id: "wf-\(UUID())", + taskQueue: taskQueue + ), + input: "World" + ) + let result = try await handle.result() + #expect(result == "Hello, World!") + + let elapsed = ContinuousClock.now - start + #expect(elapsed < .seconds(30)) + } + } + } +} +``` + +> Tip: `TemporalTestServer.withConnectedClient` installs the +> time-skipping interceptor for you. + +### Replay testing with WorkflowReplayer + +``WorkflowReplayer`` replays a workflow against a recorded history, so you +can verify that code changes stay deterministic. + +> Important: A workflow must produce the same sequence of commands when replayed +> against its recorded history. Adding, removing, or reordering activities, +> timers, or child workflows breaks replay. + +Common causes of replay failure include reordering activity calls, adding or +changing timer durations, branching on `Date()` or `UUID()` instead of +workflow context APIs, and non-deterministic collection order (for example, +iterating a dictionary). + +Export a workflow history from the Temporal CLI: + +```bash +temporal workflow show --workflow-id my-workflow --output json > history.json +``` + +Then replay it in a test: + +```swift +@Test +func replayCompatibility() async throws { + var config = WorkflowReplayer.Configuration() + config.workflows.append(GreetingWorkflow.self) + + let replayer = WorkflowReplayer(configuration: config) + + let jsonData = try Data( + contentsOf: URL(fileURLWithPath: "history.json") + ) + let history = try WorkflowHistory.fromJSON( + workflowID: "my-workflow", + jsonData: jsonData + ) + + let result = try await replayer.replayWorkflow( + history: history, + throwOnReplayFailure: false + ) + #expect(result.replayFailure == nil) +} +``` + +You can also skip the CLI export by calling +``WorkflowHandle/fetchHistory(waitNewEvent:eventFilterType:skipArchival:callOptions:)`` +on a completed workflow handle and replaying the result directly in your test. diff --git a/Sources/Temporal/Documentation.docc/Workflows/Developing-Workflows.md b/Sources/Temporal/Documentation.docc/Workflows/Developing-Workflows.md index 09541a8..f0801a8 100644 --- a/Sources/Temporal/Documentation.docc/Workflows/Developing-Workflows.md +++ b/Sources/Temporal/Documentation.docc/Workflows/Developing-Workflows.md @@ -345,3 +345,5 @@ capitalized method name. You can customize the update name using the ```swift @WorkflowUpdate(name: "CustomUpdateUserName") ``` + +To learn how to test your workflows, see .