---
title: "Testing Temporal Workflows: Unit Tests, Integration Tests, and the Test Environment"
description: "Comprehensive guide to testing Temporal workflows in Go, covering the built-in test suite, activity mocking, compensation path testing, dependency injection testing, and integration tests with the DevServer."
url: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-workflow-testing/
section: knowledge
date: 2026-02-22
categories: ["workflow-orchestration"]
tags: ["temporal","testing","go","unit-tests","integration-tests","mocking","test-suite"]
skills: ["temporal-workflow-testing","mock-activity-design","test-environment-setup"]
tools: ["temporal","go","testify"]
levels: ["beginner"]
word_count: 1335
formats:
  json: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-workflow-testing/index.json
  html: https://agent-zone.ai/knowledge/workflow-orchestration/temporal-workflow-testing/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Testing+Temporal+Workflows%3A+Unit+Tests%2C+Integration+Tests%2C+and+the+Test+Environment
---


# Testing Temporal Workflows

Temporal workflows have a property that most distributed systems lack: determinism. A workflow function, given the same inputs and the same sequence of activity results, will always produce the same output. This makes workflows far more testable than you might expect for code that orchestrates long-running, multi-step processes.

Activities are the opposite. They talk to databases, call APIs, read files, and produce side effects. You do not want your unit tests doing any of that. The testing strategy follows directly: test workflows by mocking their activities, and test activities by injecting mock dependencies.

## Testing Philosophy

Three principles guide Temporal testing:

1. **Workflows are deterministic.** They contain no I/O, no randomness, no system calls. Everything external happens through activities. This means you can replay a workflow with controlled activity results and verify every decision it makes.

2. **Activities have side effects.** They call Docker, query databases, send emails. Mock them in workflow tests. Test them separately with injected dependencies.

3. **Test the contract.** A workflow test should verify that given specific activity results, the workflow produces the correct output and calls the right activities in the right order. It should not care about Temporal internals.

## WorkflowTestSuite

Temporal provides a built-in test framework in the `go.temporal.io/sdk/testsuite` package. It gives you a simulated workflow environment that runs workflows synchronously, without a Temporal server.

```go
package workflows

import (
    "testing"

    "github.com/stretchr/testify/suite"
    "go.temporal.io/sdk/testsuite"
)

type HelloTestSuite struct {
    suite.Suite
    testsuite.WorkflowTestSuite
    env *testsuite.TestWorkflowEnvironment
}

func (s *HelloTestSuite) SetupTest() {
    s.env = s.NewTestWorkflowEnvironment()
}

func (s *HelloTestSuite) AfterTest(suiteName, testName string) {
    s.env.AssertExpectations(s.T())
}

func TestHelloSuite(t *testing.T) {
    suite.Run(t, new(HelloTestSuite))
}
```

`SetupTest` runs before each test method, giving you a fresh environment. `AfterTest` verifies that all expected activity calls actually happened. The environment does not need a running Temporal server -- it simulates the entire workflow execution in-process.

You do not need to register workflows or activities with the test environment explicitly. The environment discovers them when you call `ExecuteWorkflow` and when you set up activity mocks with `OnActivity`.

## Mocking Activities

The test environment intercepts every `ExecuteActivity` call your workflow makes. You tell it what to return for each activity using `OnActivity`:

```go
s.env.OnActivity(GreetActivity, mock.Anything, "World").Return("Hello, World!", nil)
```

The first argument is the activity function. The remaining arguments are matchers for the activity's parameters. `mock.Anything` matches any value -- useful for the `context.Context` parameter that every activity receives.

You can set up different return values for different inputs:

```go
s.env.OnActivity(GreetActivity, mock.Anything, "World").Return("Hello, World!", nil)
s.env.OnActivity(GreetActivity, mock.Anything, "Error").Return("", errors.New("greeting failed"))
```

You can also verify call counts:

```go
s.env.OnActivity(GreetActivity, mock.Anything, mock.Anything).Return("Hi", nil).Times(3)
```

If your workflow calls an activity that has no mock registered, the test fails with an unregistered activity error. This is intentional -- it forces you to account for every external interaction.

## Testing the Hello Workflow

Here is a complete test for the HelloWorkflow from the [companion repo](https://github.com/statherm/temporal-examples). This workflow takes a name, passes it to a `GreetActivity`, and returns the greeting. See [Temporal Go Workflow Basics](../temporal-go-workflow-basics/) for the workflow implementation.

```go
func (s *HelloTestSuite) TestHelloWorkflow() {
    s.env.OnActivity(GreetActivity, mock.Anything, "World").Return("Hello, World!", nil)

    s.env.ExecuteWorkflow(HelloWorkflow, "World")

    s.Require().True(s.env.IsWorkflowCompleted())
    s.Require().NoError(s.env.GetWorkflowError())

    var result string
    s.Require().NoError(s.env.GetWorkflowResult(&result))
    s.Require().Equal("Hello, World!", result)
}
```

`ExecuteWorkflow` runs the workflow synchronously. After it returns, you can check whether the workflow completed, whether it errored, and what it returned. This test verifies the happy path: the activity succeeds and the workflow returns its result.

Test the error path too:

```go
func (s *HelloTestSuite) TestHelloWorkflow_ActivityFails() {
    s.env.OnActivity(GreetActivity, mock.Anything, "World").Return("", errors.New("service unavailable"))

    s.env.ExecuteWorkflow(HelloWorkflow, "World")

    s.Require().True(s.env.IsWorkflowCompleted())
    s.Require().Error(s.env.GetWorkflowError())
}
```

## Testing Activities Directly with Dependency Injection

Workflow tests mock activities entirely. But you also need to test the activities themselves. If your activities use dependency injection -- accepting an interface rather than a concrete client -- you can test them with a mock client.

Consider a `StopContainer` activity that stops a Docker container. It should be idempotent: if the container is already stopped, it should succeed without calling `Stop`.

```go
func TestStopContainer_AlreadyStopped(t *testing.T) {
    mockClient := &MockContainerClient{}
    mockClient.On("Inspect", mock.Anything, "abc123").Return(ContainerInfo{State: "exited"}, nil)

    activities := NewContainerActivities(mockClient)
    err := activities.StopContainer(context.Background(), StopRequest{ContainerID: "abc123"})

    require.NoError(t, err)
    mockClient.AssertNotCalled(t, "Stop", mock.Anything, mock.Anything)
}
```

This test creates a mock container client that reports the container as already exited. The activity should return success without calling `Stop`. `AssertNotCalled` verifies that the `Stop` method was never invoked.

Test the running case too:

```go
func TestStopContainer_Running(t *testing.T) {
    mockClient := &MockContainerClient{}
    mockClient.On("Inspect", mock.Anything, "abc123").Return(ContainerInfo{State: "running"}, nil)
    mockClient.On("Stop", mock.Anything, "abc123").Return(nil)

    activities := NewContainerActivities(mockClient)
    err := activities.StopContainer(context.Background(), StopRequest{ContainerID: "abc123"})

    require.NoError(t, err)
    mockClient.AssertCalled(t, "Stop", mock.Anything, "abc123")
}
```

This pattern -- interface-based DI tested with mocks -- is covered in detail in the [container lifecycle workflow article](../temporal-container-lifecycle-workflow/).

## Testing Compensation Paths

Real workflows need compensation: if step 3 fails, undo steps 1 and 2. Testing compensation means simulating an activity failure and verifying that the correct compensation activities run.

```go
func (s *CompensationTestSuite) TestStepTwoFailure_TriggersCompensation() {
    // Step 1 succeeds
    s.env.OnActivity(StepOne, mock.Anything, mock.Anything).Return(StepOneResult{ID: "abc"}, nil)

    // Step 2 fails
    s.env.OnActivity(StepTwo, mock.Anything, mock.Anything).Return(StepTwoResult{}, errors.New("step two failed"))

    // Compensation for step 1 should be called
    s.env.OnActivity(CompensateStepOne, mock.Anything, CompensateRequest{ID: "abc"}).Return(nil)

    s.env.ExecuteWorkflow(MultiStageWorkflow, WorkflowRequest{Name: "test"})

    s.Require().True(s.env.IsWorkflowCompleted())
    s.Require().Error(s.env.GetWorkflowError())
}
```

The key verification here is implicit: `AfterTest` calls `s.env.AssertExpectations`, which confirms that `CompensateStepOne` was actually called. If the workflow fails to compensate, the test fails because the registered mock was never invoked. To make this explicit, use `.Times(1)` on the compensation mock.

For more on designing compensation, see [Multi-Stage Temporal Workflows](../temporal-multi-stage-workflows/).

## Integration Testing with DevServer

Unit tests with the test suite are fast and cover most logic. But they do not exercise the real Temporal server -- task queues, retries, timeouts, and worker polling are all simulated. For end-to-end confidence, use the Temporal DevServer.

The DevServer is an in-memory Temporal server designed for testing. Start it programmatically:

```go
//go:build e2e

package integration

import (
    "context"
    "testing"

    "go.temporal.io/sdk/client"
    "go.temporal.io/sdk/testsuite"
    "go.temporal.io/sdk/worker"
)

func TestHelloWorkflow_Integration(t *testing.T) {
    // Start DevServer
    server, err := testsuite.StartDevServer(context.Background(), testsuite.DevServerOptions{})
    if err != nil {
        t.Fatal(err)
    }
    defer server.Stop()

    // Create client connected to DevServer
    c := server.Client()
    defer c.Close()

    // Start a worker
    taskQueue := "test-hello-queue"
    w := worker.New(c, taskQueue, worker.Options{})
    w.RegisterWorkflow(HelloWorkflow)
    w.RegisterActivity(GreetActivity)
    if err := w.Start(); err != nil {
        t.Fatal(err)
    }
    defer w.Stop()

    // Execute workflow
    run, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
        TaskQueue: taskQueue,
    }, HelloWorkflow, "Integration")
    if err != nil {
        t.Fatal(err)
    }

    var result string
    if err := run.Get(context.Background(), &result); err != nil {
        t.Fatal(err)
    }

    if result != "Hello, Integration!" {
        t.Errorf("expected 'Hello, Integration!', got '%s'", result)
    }
}
```

This test starts a real Temporal server, registers a worker, executes a workflow, and waits for the result. The activities run for real -- no mocking. This catches issues that unit tests miss: serialization problems, task queue misconfiguration, and timeout behavior.

**When to use which:**

| Scenario | Approach |
|---|---|
| Testing workflow logic and branching | Test suite (unit) |
| Testing activity implementation | Direct call with mock deps |
| Testing serialization and task queues | DevServer (integration) |
| Testing timeouts and retries | DevServer (integration) |
| CI pipeline, fast feedback | Test suite (unit) |
| Pre-deploy validation | DevServer (integration) |

## Test Organization

Structure your test files to separate unit and integration tests:

```
workflows/
  hello_workflow.go
  hello_workflow_test.go          # unit tests (test suite)
  container_workflow.go
  container_workflow_test.go      # unit tests (test suite)
activities/
  container_activities.go
  container_activities_test.go    # unit tests (mock DI)
integration/
  hello_integration_test.go       # e2e (build tag: e2e)
  container_integration_test.go   # e2e (build tag: e2e)
```

Use build tags to separate test levels:

```go
//go:build e2e

package integration
```

Run them independently:

```bash
# Unit tests only (fast, no server needed)
go test ./workflows/... ./activities/...

# Integration tests (needs DevServer, slower)
go test -tags=e2e ./integration/...

# Everything
go test -tags=e2e ./...
```

Name test functions descriptively. Prefix with the function under test, then describe the scenario:

```go
func (s *Suite) TestContainerLifecycle_HappyPath()
func (s *Suite) TestContainerLifecycle_AlreadyStopped()
func (s *Suite) TestContainerLifecycle_CommitFails_CompensatesWithRestart()
```

This naming convention makes `go test -run` filtering intuitive:

```bash
# Run all container lifecycle tests
go test -run TestContainerLifecycle ./workflows/...

# Run only compensation tests
go test -run Compensat ./workflows/...
```

## Summary

Temporal's deterministic workflow model makes testing straightforward once you understand the split: mock activities in workflow tests, inject mock dependencies in activity tests, and use the DevServer for end-to-end validation. Start with unit tests using the test suite -- they are fast and catch the majority of bugs. Add integration tests for serialization and timeout behavior. The companion repo at [github.com/statherm/temporal-examples](https://github.com/statherm/temporal-examples) has runnable versions of every test shown here.

