Temporal Workflow Testing: Unit Tests, Replays, and Test Server
Temporal workflows are deterministic, durable, and long-running — which makes testing them different from testing ordinary async code. The key challenges are: testing workflow logic without running a full Temporal server, testing activity implementations, and verifying that histories replay correctly after code changes. This guide covers all three.
Why Temporal Testing Is Different
Temporal workflows execute as a series of commands recorded in an event history. A workflow must replay identically when re-run from history — otherwise you get non-determinism errors in production. Your tests must cover:
- Workflow logic — does the workflow make the right decisions?
- Activity logic — does each activity do the right work?
- Replay correctness — does updated code still replay old histories without errors?
TypeScript SDK: Testing Workflows
Setup
npm install @temporalio/testing @temporalio/worker @temporalio/workflow @temporalio/activity
npm install -D jest @types/jest ts-jestTesting Workflow Logic
// workflows/onboardingWorkflow.ts
import { proxyActivities, sleep } from '@temporalio/workflow'
import type * as activities from '../activities/onboardingActivities'
const { createAccount, sendWelcomeEmail, scheduleOnboardingCall } =
proxyActivities<typeof activities>({
startToCloseTimeout: '10 minutes',
})
export async function onboardingWorkflow(userId: string): Promise<{ accountId: string }> {
const account = await createAccount(userId)
await sendWelcomeEmail(userId, account.email)
// Give users 24 hours to schedule a call
await sleep('24 hours')
await scheduleOnboardingCall(userId)
return { accountId: account.id }
}// workflows/onboardingWorkflow.test.ts
import { TestWorkflowEnvironment } from '@temporalio/testing'
import { Worker } from '@temporalio/worker'
import { onboardingWorkflow } from './onboardingWorkflow'
describe('onboardingWorkflow', () => {
let testEnv: TestWorkflowEnvironment
beforeAll(async () => {
testEnv = await TestWorkflowEnvironment.createLocal()
})
afterAll(async () => {
await testEnv?.teardown()
})
it('completes onboarding and returns account id', async () => {
const { client, nativeConnection } = testEnv
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'onboarding-test',
workflowsPath: require.resolve('./onboardingWorkflow'),
activities: {
createAccount: async (userId: string) => ({
id: `acc-${userId}`,
email: `${userId}@example.com`,
}),
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
scheduleOnboardingCall: jest.fn().mockResolvedValue(undefined),
},
})
const result = await worker.runUntil(
client.workflow.execute(onboardingWorkflow, {
taskQueue: 'onboarding-test',
workflowId: 'test-onboarding-1',
args: ['user-1'],
})
)
expect(result).toEqual({ accountId: 'acc-user-1' })
})
it('skips time to test sleep behaviour', async () => {
const { client, nativeConnection } = testEnv
const scheduleCallMock = jest.fn().mockResolvedValue(undefined)
const worker = await Worker.create({
connection: nativeConnection,
taskQueue: 'onboarding-test-2',
workflowsPath: require.resolve('./onboardingWorkflow'),
activities: {
createAccount: async (userId: string) => ({
id: `acc-${userId}`,
email: `${userId}@example.com`,
}),
sendWelcomeEmail: jest.fn().mockResolvedValue(undefined),
scheduleOnboardingCall: scheduleCallMock,
},
})
const workflowPromise = client.workflow.execute(onboardingWorkflow, {
taskQueue: 'onboarding-test-2',
workflowId: 'test-onboarding-2',
args: ['user-2'],
})
// Skip the 24-hour sleep
await testEnv.sleep('24 hours')
await worker.runUntil(workflowPromise)
expect(scheduleCallMock).toHaveBeenCalledWith('user-2')
})
})Testing Activities
Activities are plain async functions — test them independently of any workflow:
// activities/onboardingActivities.ts
import { db } from '../db'
import { emailService } from '../services/email'
export async function createAccount(userId: string) {
const user = await db.users.findById(userId)
if (!user) throw new Error(`User ${userId} not found`)
const account = await db.accounts.create({ userId, plan: 'free' })
return { id: account.id, email: user.email }
}
export async function sendWelcomeEmail(userId: string, email: string) {
await emailService.send({
to: email,
template: 'welcome',
data: { userId },
})
}// activities/onboardingActivities.test.ts
import { createAccount, sendWelcomeEmail } from './onboardingActivities'
import { db } from '../db'
import { emailService } from '../services/email'
jest.mock('../db')
jest.mock('../services/email')
const mockDb = db as jest.Mocked<typeof db>
const mockEmail = emailService as jest.Mocked<typeof emailService>
describe('createAccount', () => {
it('creates an account for an existing user', async () => {
mockDb.users.findById.mockResolvedValue({
id: 'u1',
email: 'alice@example.com',
})
mockDb.accounts.create.mockResolvedValue({ id: 'acc-1' })
const result = await createAccount('u1')
expect(result).toEqual({ id: 'acc-1', email: 'alice@example.com' })
expect(mockDb.accounts.create).toHaveBeenCalledWith({
userId: 'u1',
plan: 'free',
})
})
it('throws if user does not exist', async () => {
mockDb.users.findById.mockResolvedValue(null)
await expect(createAccount('nonexistent')).rejects.toThrow('User nonexistent not found')
})
})
describe('sendWelcomeEmail', () => {
it('sends welcome email via email service', async () => {
mockEmail.send.mockResolvedValue({ messageId: 'msg-1' })
await sendWelcomeEmail('u1', 'alice@example.com')
expect(mockEmail.send).toHaveBeenCalledWith({
to: 'alice@example.com',
template: 'welcome',
data: { userId: 'u1' },
})
})
})Go SDK: Testing Workflows
// workflows/onboarding_workflow_test.go
package workflows_test
import (
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"go.temporal.io/sdk/testsuite"
"myapp/activities"
"myapp/workflows"
)
type OnboardingWorkflowTestSuite struct {
suite.Suite
testsuite.WorkflowTestSuite
env *testsuite.TestWorkflowEnvironment
}
func (s *OnboardingWorkflowTestSuite) SetupTest() {
s.env = s.NewTestWorkflowEnvironment()
}
func (s *OnboardingWorkflowTestSuite) TearDownTest() {
s.env.AssertExpectations(s.T())
}
func (s *OnboardingWorkflowTestSuite) Test_OnboardingWorkflow_HappyPath() {
s.env.OnActivity(activities.CreateAccount, mock.Anything, "user-1").
Return(&activities.AccountResult{ID: "acc-1", Email: "user-1@example.com"}, nil)
s.env.OnActivity(activities.SendWelcomeEmail, mock.Anything, "user-1", "user-1@example.com").
Return(nil)
// Register timers to skip sleep
s.env.RegisterDelayedCallback(func() {
// Called when the 24-hour sleep completes
}, 24*time.Hour)
s.env.OnActivity(activities.ScheduleOnboardingCall, mock.Anything, "user-1").
Return(nil)
s.env.ExecuteWorkflow(workflows.OnboardingWorkflow, "user-1")
s.True(s.env.IsWorkflowCompleted())
s.NoError(s.env.GetWorkflowError())
var result workflows.OnboardingResult
s.NoError(s.env.GetWorkflowResult(&result))
s.Equal("acc-1", result.AccountID)
}
func (s *OnboardingWorkflowTestSuite) Test_OnboardingWorkflow_UserNotFound() {
s.env.OnActivity(activities.CreateAccount, mock.Anything, "missing-user").
Return(nil, fmt.Errorf("user missing-user not found"))
s.env.ExecuteWorkflow(workflows.OnboardingWorkflow, "missing-user")
s.True(s.env.IsWorkflowCompleted())
s.Error(s.env.GetWorkflowError())
s.Contains(s.env.GetWorkflowError().Error(), "user missing-user not found")
}
func TestOnboardingWorkflowTestSuite(t *testing.T) {
suite.Run(t, new(OnboardingWorkflowTestSuite))
}Replay Testing
Replay tests verify that updated workflow code can still process old event histories without non-determinism errors. This is critical before deploying workflow changes to production:
// workflows/onboardingWorkflow.replay.test.ts
import { WorkflowReplayer } from '@temporalio/testing'
import { onboardingWorkflow } from './onboardingWorkflow'
describe('Workflow replay compatibility', () => {
it('replays a known good history without errors', async () => {
const replayer = new WorkflowReplayer({
workflows: [onboardingWorkflow],
})
// Load a history file captured from a completed production run
await replayer.replayWorkflowHistory(
require('./fixtures/onboarding_history_v1.json')
)
// If this throws, your workflow code change is non-deterministic
})
})To capture a history for replay testing:
temporal workflow show \
--workflow-id "onboarding-user-123" \
--output json > workflows/fixtures/onboarding_history_v1.jsonCommon Non-Determinism Pitfalls
Non-deterministic changes that will fail replay:
- Adding a new
await activity.someActivity()call before an existing one - Changing the order of parallel activities in
Promise.all() - Using
Math.random(),Date.now(), ornew Date()directly in workflow code - Adding or removing
sleep()calls
Safe changes that pass replay:
- Modifying activity implementations (not their signatures)
- Changing retry policies
- Adding branches that are never reached in old histories
What Automated Tests Miss
Temporal unit tests and replay tests cover logic but won't catch:
- Worker memory leaks in long-running workflow workers
- Activity timeout misconfiguration that only manifests under production load
- History size limits for very long-running workflows
- Cross-namespace visibility issues in multi-tenant setups
HelpMeTest monitors your Temporal-driven application end-to-end — scheduling real browser tests that trigger workflows and assert on the final state. Catch regressions before they affect users.
Summary
Temporal testing layers:
| Layer | Tool | What it tests |
|---|---|---|
| Activity unit | Jest/Go testing | Function logic, mocked dependencies |
| Workflow unit | TestWorkflowEnvironment |
Workflow decisions, time skipping, signals |
| Replay | WorkflowReplayer |
Non-determinism after code changes |
| E2E | Temporal dev server + real workers | Full stack, real broker, real activities |
Write replay tests for every workflow before deploying changes. A failing replay test means you need to use workflow versioning (workflow.patched() in TypeScript, workflow.GetVersion() in Go) to maintain backward compatibility.