Temporal.io Testing Guide: Unit Testing Workflows and Activities
Temporal.io solves distributed workflow reliability by making long-running processes durable, resumable, and observable. The tradeoff is a testing model that differs significantly from standard service testing. Workflows must be deterministic — the same history must always produce the same decisions — and Activities are where all the non-deterministic work happens.
This guide covers the complete testing strategy for Temporal applications: unit testing with TestWorkflowEnvironment, activity testing, history replay for regression detection, and integration tests against a real Temporal server.
Understanding the Temporal Testing Model
Before writing tests, understand what Temporal's execution model means for testing:
Workflows are deterministic state machines. They schedule activities, handle signals, and manage timers — but they must never make non-deterministic calls (random numbers, current time, external I/O) directly. Temporal replays workflow history to reconstruct state, so any non-determinism causes "non-deterministic error" panics.
Activities are where you make external calls: database writes, API calls, file I/O. Activities are not required to be deterministic. They can fail and be retried automatically by Temporal.
This separation creates a clean testing boundary:
- Test workflows with mocked activities using
TestWorkflowEnvironment - Test activities as regular functions with real dependencies or mocks
- Test the integration with a running Temporal server
Setting Up TestWorkflowEnvironment (Go)
// order_workflow_test.go
package workflows_test
import (
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.temporal.io/sdk/testsuite"
"go.temporal.io/sdk/temporal"
"myapp/workflows"
"myapp/activities"
)
type OrderWorkflowTestSuite struct {
suite.Suite
testsuite.WorkflowTestSuite
env *testsuite.TestWorkflowEnvironment
}
func (s *OrderWorkflowTestSuite) SetupTest() {
s.env = s.NewTestWorkflowEnvironment()
}
func (s *OrderWorkflowTestSuite) AfterTest(suiteName, testName string) {
s.env.AssertExpectations(s.T())
}
func TestOrderWorkflow(t *testing.T) {
suite.Run(t, new(OrderWorkflowTestSuite))
}Testing Workflow Happy Path
func (s *OrderWorkflowTestSuite) Test_OrderWorkflow_HappyPath() {
order := workflows.Order{
ID: "order-123",
Items: []string{"widget-a", "widget-b"},
Customer: "customer-456",
}
// Mock each activity the workflow calls
s.env.OnActivity(activities.ValidateInventory, mock.Anything, order.Items).
Return(nil).
Once()
s.env.OnActivity(activities.ChargeCustomer, mock.Anything, order.Customer, mock.AnythingOfType("float64")).
Return(&activities.ChargeResult{TransactionID: "txn-789"}, nil).
Once()
s.env.OnActivity(activities.FulfillOrder, mock.Anything, mock.Anything).
Return(nil).
Once()
s.env.OnActivity(activities.SendConfirmationEmail, mock.Anything, mock.Anything).
Return(nil).
Once()
// Execute the workflow
s.env.ExecuteWorkflow(workflows.OrderWorkflow, order)
require.True(s.T(), s.env.IsWorkflowCompleted())
require.NoError(s.T(), s.env.GetWorkflowError())
var result workflows.OrderResult
require.NoError(s.T(), s.env.GetWorkflowResult(&result))
require.Equal(s.T(), "completed", result.Status)
require.Equal(s.T(), "txn-789", result.TransactionID)
}
func (s *OrderWorkflowTestSuite) Test_OrderWorkflow_InventoryFailed_CancelsOrder() {
order := workflows.Order{ID: "order-999", Items: []string{"out-of-stock-item"}}
// Inventory check fails — workflow should compensate
s.env.OnActivity(activities.ValidateInventory, mock.Anything, order.Items).
Return(temporal.NewApplicationError("Item out of stock", "OUT_OF_STOCK")).
Once()
// Charge should NOT be called if inventory fails
s.env.OnActivity(activities.NotifyCustomerUnavailable, mock.Anything, mock.Anything).
Return(nil).
Once()
s.env.ExecuteWorkflow(workflows.OrderWorkflow, order)
require.True(s.T(), s.env.IsWorkflowCompleted())
var result workflows.OrderResult
s.env.GetWorkflowResult(&result)
require.Equal(s.T(), "cancelled", result.Status)
require.Equal(s.T(), "OUT_OF_STOCK", result.CancellationReason)
}Testing Signals and Timers
Temporal supports signals (external events sent to running workflows) and timers. Both can be tested with TestWorkflowEnvironment:
func (s *OrderWorkflowTestSuite) Test_OrderWorkflow_CancellationSignal() {
order := workflows.Order{ID: "order-cancel-test", Items: []string{"widget-a"}}
s.env.OnActivity(activities.ValidateInventory, mock.Anything, mock.Anything).
Return(nil).
Once()
// Charge takes a while — send cancel signal before it completes
s.env.OnActivity(activities.ChargeCustomer, mock.Anything, mock.Anything, mock.Anything).
Return(nil, temporal.NewCanceledError()).
Once()
s.env.OnActivity(activities.RefundCustomer, mock.Anything, mock.Anything).
Return(nil).
Maybe() // May be called if charge partially succeeded
// Register signal sender — fires after 2 simulated seconds
s.env.RegisterDelayedCallback(2*time.Second, func() {
s.env.SignalWorkflow("cancel-order", nil)
})
s.env.ExecuteWorkflow(workflows.OrderWorkflow, order)
require.True(s.T(), s.env.IsWorkflowCompleted())
var result workflows.OrderResult
s.env.GetWorkflowResult(&result)
require.Equal(s.T(), "cancelled", result.Status)
}
func (s *OrderWorkflowTestSuite) Test_OrderWorkflow_Timeout() {
// TestWorkflowEnvironment fast-forwards timers — no real waiting
order := workflows.Order{ID: "order-timeout-test", Items: []string{"widget-a"}}
s.env.OnActivity(activities.ValidateInventory, mock.Anything, mock.Anything).
Return(nil).
Once()
// Simulate charge hanging — will hit the workflow's 30-minute timeout
s.env.OnActivity(activities.ChargeCustomer, mock.Anything, mock.Anything, mock.Anything).
After(31 * time.Minute). // Exceeds timeout
Return(nil, temporal.NewTimeoutError(enumspb.TIMEOUT_TYPE_SCHEDULE_TO_CLOSE, nil)).
Once()
s.env.ExecuteWorkflow(workflows.OrderWorkflow, order)
require.True(s.T(), s.env.IsWorkflowCompleted())
require.Error(s.T(), s.env.GetWorkflowError())
}Testing Activities
Activities are regular functions — test them independently with real or mocked dependencies:
// activities/charge_customer_test.go
package activities_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/testsuite"
"myapp/activities"
)
func TestChargeCustomer_Success(t *testing.T) {
testSuite := &testsuite.WorkflowTestSuite{}
env := testSuite.NewTestActivityEnvironment()
// Register the activity
env.RegisterActivity(&activities.Activities{
PaymentClient: &mockPaymentClient{
chargeResult: &PaymentResult{TransactionID: "txn-001", Success: true},
},
})
val, err := env.ExecuteActivity(activities.ChargeCustomer, "customer-123", 99.99)
require.NoError(t, err)
var result activities.ChargeResult
require.NoError(t, val.Get(&result))
assert.Equal(t, "txn-001", result.TransactionID)
}
func TestChargeCustomer_RetryableError(t *testing.T) {
// Verify that network errors are returned as retryable (not ApplicationError)
env := newTestEnv()
env.RegisterActivity(&activities.Activities{
PaymentClient: &mockPaymentClient{
err: &net.OpError{Op: "connect", Err: errors.New("connection refused")},
},
})
_, err := env.ExecuteActivity(activities.ChargeCustomer, "customer-123", 99.99)
// Should be retryable — not an application error
var appErr *temporal.ApplicationError
require.False(t, errors.As(err, &appErr) && appErr.NonRetryable(),
"Network errors should be retryable")
}History Replay for Regression Testing
Temporal's history replay lets you catch non-determinism bugs. Capture a workflow execution history, then replay it against your updated code:
// tests/replay/replay_test.go
package replay_test
import (
"os"
"testing"
"go.temporal.io/sdk/worker"
"myapp/workflows"
)
func TestWorkflowHistoryReplay(t *testing.T) {
// Histories captured from production: golden files
historyFiles, err := filepath.Glob("testdata/histories/*.json")
require.NoError(t, err)
require.NotEmpty(t, historyFiles, "No history files found in testdata/histories/")
replayer, err := worker.NewWorkflowReplayer()
require.NoError(t, err)
replayer.RegisterWorkflow(workflows.OrderWorkflow)
for _, historyFile := range historyFiles {
t.Run(filepath.Base(historyFile), func(t *testing.T) {
err := replayer.ReplayWorkflowHistoryFromJSONFile(nil, historyFile)
require.NoError(t, err, "Non-determinism detected in %s", historyFile)
})
}
}Capture histories from your Temporal server:
# Capture a workflow history for replay testing
temporal workflow show \
--workflow-id order-123 \
--run-id <run-id> \
--output json > tests/replay/testdata/histories/order_happy_path.jsonPython SDK: Using TestWorkflowEnvironment
The Python SDK provides equivalent test utilities:
# tests/test_order_workflow.py
import asyncio
import pytest
from unittest.mock import AsyncMock
from temporalio import activity
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from myapp.workflows import OrderWorkflow, OrderInput
from myapp.activities import validate_inventory, charge_customer, fulfill_order
@pytest.mark.asyncio
async def test_order_workflow_happy_path():
async with await WorkflowEnvironment.start_time_skipping() as env:
async with Worker(
env.client,
task_queue="test-queue",
workflows=[OrderWorkflow],
activities=[validate_inventory, charge_customer, fulfill_order],
):
# Mock activities via patching
validate_inventory_mock = AsyncMock(return_value=None)
charge_customer_mock = AsyncMock(return_value={"transaction_id": "txn-001"})
fulfill_order_mock = AsyncMock(return_value=None)
result = await env.client.execute_workflow(
OrderWorkflow.run,
OrderInput(order_id="order-123", items=["widget-a"]),
id="test-order-123",
task_queue="test-queue",
)
assert result["status"] == "completed"
assert result["transaction_id"] == "txn-001"CI Integration
# .github/workflows/temporal-tests.yml
name: Temporal Workflow Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Run workflow unit tests
run: go test ./workflows/... ./activities/... -v -timeout 60s
replay-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: Run history replay tests
run: go test ./tests/replay/... -v
integration-tests:
runs-on: ubuntu-latest
services:
temporal:
image: temporalio/auto-setup:latest
ports:
- 7233:7233
steps:
- uses: actions/checkout@v4
- run: go test ./tests/integration/... -v -timeout 300s
env:
TEMPORAL_HOST: localhost:7233Production Monitoring
Temporal's visibility API provides workflow status, but it doesn't alert you when key business workflows start failing at higher rates than expected. HelpMeTest can run scheduled tests that trigger real workflow executions and assert on completion — catching infrastructure regressions before they become customer-facing incidents.
Conclusion
Temporal testing has three layers: TestWorkflowEnvironment for fast, deterministic workflow logic tests; activity unit tests for your external integrations; and history replay tests to catch non-determinism regressions after refactors. The time-skipping feature of TestWorkflowEnvironment means timer-heavy workflows that would take hours in production run in milliseconds in tests. Capture golden histories from production and replay them in CI — that's your strongest safety net against breaking existing workflow clients.