Temporal Workflow Testing: Unit Tests, Replays, and Test Server

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:

  1. Workflow logic — does the workflow make the right decisions?
  2. Activity logic — does each activity do the right work?
  3. 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-jest

Testing 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.json

Common 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(), or new 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.

Read more