Bazel rules for running integration tests against an ephemeral
Temporal cluster. Every test target gets its own
temporal server start-dev process on a dynamically allocated port with a
UUID-based namespace — no shared state, no leftover history, full
--jobs parallelism.
- Hermetic — each
temporal_testowns an independent server and namespace; no shared state between test targets. - Parallel-safe — run
bazel test //... --jobs 32without port collisions or namespace pollution. - Zero CDN downloads required — auto-detects the host-installed Temporal CLI
from
PATH; pre-built tarballs are also supported. - Analysis-time validation —
temporal_buildcatches missing task queues, empty type lists, and duplicate names asbazel builderrors, not flaky failures. - Custom search attributes —
temporal_namespace_configregisters search attributes at server startup so they are available before the worker starts. - Workflow history inspection —
temporal_workflow_historycaptures exported history files as Bazel artifacts for replay-based determinism testing. - rules_itest integration —
temporal_serverandtemporal_health_checkslot directly into multi-service integration tests.
- Bazel 6+ (Bzlmod or WORKSPACE)
- Temporal CLI 1.x installed on the host (or downloaded via
temporal.version()) - Python 3.9+ (for the launcher; no pip dependencies)
bazel_dep(name = "rules_temporal", version = "0.2.0")
temporal = use_extension("@rules_temporal//:extensions.bzl", "temporal")
# Use the host-installed temporal CLI (auto-detects from PATH):
temporal.system(versions = ["1.6.2"])
# Or specify the path explicitly:
# temporal.system(versions = ["1.6.2"], bin_dir = "/usr/local/bin")
# Or download pre-built tarballs from GitHub releases:
# temporal.version(versions = ["1.6.2"])
use_repo(temporal,
"temporal_1_6_2_linux_amd64",
"temporal_1_6_2_darwin_arm64",
"temporal_1_6_2_darwin_amd64",
)load("@rules_temporal//:repositories.bzl", "temporal_system_dependencies")
# Auto-detect from PATH; or pass bin_dir for explicit path:
temporal_system_dependencies(versions = ["1.6.2"])# BUILD.bazel
load("@rules_temporal//:defs.bzl", "temporal_build", "temporal_test")
temporal_build(
name = "my_worker",
worker_binary = ":my_worker_bin", # a *_binary target
task_queue = "my-task-queue",
workflow_types = ["MyWorkflow"],
activity_types = ["my_activity"], # optional
)# my_test.sh
set -euo pipefail
require_env() { [[ -n "${!1:-}" ]] || { echo "ERROR: \$$1 not set" >&2; exit 1; }; }
require_env TEMPORAL_ADDRESS
require_env TEMPORAL_NAMESPACE
require_env TEMPORAL_TASK_QUEUE
result=$(temporal workflow execute \
--address "$TEMPORAL_ADDRESS" \
--namespace "$TEMPORAL_NAMESPACE" \
--type MyWorkflow \
--task-queue "$TEMPORAL_TASK_QUEUE" \
--input '"world"' \
-o json)
echo "$result" | python3 -c "
import json, sys
obj = json.load(sys.stdin)
assert obj['result'] == 'Hello, world!', f'unexpected: {obj}'
print('PASS')
"# BUILD.bazel
temporal_test(
name = "my_test",
worker = ":my_worker",
srcs = ["my_test.sh"],
)bazel test //mypackage:my_testload("@rules_temporal//:defs.bzl",
"temporal_build",
"temporal_test",
"temporal_server",
"temporal_health_check",
"temporal_namespace_config",
"temporal_workflow_history",
)Declares a Temporal worker binary and validates its registered types at analysis
time. Produces a TemporalWorkerInfo provider consumed by temporal_test and
temporal_server.
temporal_build(
name = "my_worker",
worker_binary = ":my_worker_bin", # required: a *_binary target
task_queue = "my-task-queue", # required: non-empty string
workflow_types = ["MyWorkflow"], # required: at least one
activity_types = ["my_activity"], # optional
)Validated at analysis time:
task_queuemust be non-empty.workflow_typesmust be non-empty and contain no duplicates or empty strings.activity_typesmust contain no duplicates or empty strings.
Runs an isolated test against an ephemeral Temporal cluster. Wraps any Bazel
test rule (sh_test, go_test, py_test, …).
temporal_test(
name = "my_test",
worker = ":my_worker", # required: temporal_build target
srcs = ["my_test.sh"], # forwarded to test_rule
deps = [...], # forwarded to test_rule
size = "medium", # optional, default "medium"
timeout = None, # optional
tags = [...], # optional
test_rule = go_test, # optional; default native.sh_test
namespace_config = ":my_ns_config", # optional: temporal_namespace_config
history = ":my_histories", # optional: temporal_workflow_history
**kwargs, # forwarded to test_rule
)Environment variables injected into the test binary:
| Variable | Example value | Description |
|---|---|---|
TEMPORAL_ADDRESS |
127.0.0.1:54321 |
gRPC address of the ephemeral server |
TEMPORAL_NAMESPACE |
temporal-test-abc123def456 |
Isolated per-test namespace |
TEMPORAL_TASK_QUEUE |
my-task-queue |
Task queue from temporal_build |
Registers custom search attributes at server startup. In Temporal 1.24+, custom
search attributes are namespace-scoped and must be declared via
--search-attribute flags to temporal server start-dev.
temporal_namespace_config(
name = "my_ns_config",
worker = ":my_worker",
search_attributes = {
"CustomerId": "Keyword",
"OrderTotal": "Double",
"IsRetried": "Bool",
},
)
temporal_test(
name = "my_test",
worker = ":my_worker",
srcs = ["my_test.sh"],
namespace_config = ":my_ns_config",
)Valid search attribute types: Bool, Datetime, Double, Int, Keyword,
KeywordList, Text. Types are validated at analysis time.
Captures exported workflow history files as Bazel artifacts. At test time the
launcher inspects each history file and verifies it contains a completed
execution. When temporal workflow replay becomes available in the CLI, the
launcher will also replay each history against the live worker, failing fast if
any replay indicates a determinism break.
temporal_workflow_history(
name = "my_histories",
worker = ":my_worker",
srcs = glob(["testdata/histories/*.json"]),
)
temporal_test(
name = "my_test",
worker = ":my_worker",
srcs = ["my_test.sh"],
history = ":my_histories",
)See Workflow history and determinism testing for a full end-to-end example including how to capture history files.
A long-running server target for multi-service integration tests with
rules_itest. Writes
$TEST_TMPDIR/<name>.env atomically once the server and namespace are ready,
then blocks until SIGTERM.
temporal_server(name = "my_temporal")The env file contains:
TEMPORAL_ADDRESS=127.0.0.1:54321
TEMPORAL_NAMESPACE=temporal-test-abc123def456
A companion health-check binary for temporal_server. Exits 0 if and only if
the server's env file exists.
temporal_health_check(
name = "my_temporal_health",
server = ":my_temporal",
)Temporal workflows must be deterministic: replaying a workflow's event history against the current worker code must produce the same sequence of commands. A non-determinism bug — adding a branch, reordering activities, changing sleep durations — causes a stuck workflow in production and is caught immediately by replaying a captured history file.
temporal_workflow_history bakes history files into the Bazel build so that
every bazel test run verifies determinism before the test binary runs. This
section walks through capturing history files from a live server and wiring them
into a test.
myapp/
├── BUILD.bazel
├── worker/
│ └── main.py # Temporal worker binary
├── testdata/
│ └── histories/
│ ├── order_happy_path.json # captured from a dev server
│ └── order_retry.json # captured after simulated activity failure
└── determinism_test.sh
Start a local Temporal server and your worker, then execute the workflow you
want to capture. The easiest way is a bazel test run with the --test_output
flag so you can see the connection details:
bazel test //myapp:my_test --test_output=streamed
# Look for lines like:
# Server: 127.0.0.1:54321
# Namespace: temporal-test-abc123def456Or start a standalone dev server manually:
temporal server start-dev \
--port 7233 \
--namespace dev \
--headlessStart your worker in a second terminal (point it at the dev server):
TEMPORAL_ADDRESS=127.0.0.1:7233 \
TEMPORAL_NAMESPACE=dev \
TEMPORAL_TASK_QUEUE=order-queue \
bazel run //myapp/workerExecute the workflow you want to capture:
temporal workflow execute \
--address 127.0.0.1:7233 \
--namespace dev \
--type OrderWorkflow \
--task-queue order-queue \
--input '{"order_id": "ord-001", "amount": 49.99}' \
--workflow-id order-happy-path-captureOnce the workflow completes, export its full event history to JSON:
mkdir -p myapp/testdata/histories
temporal workflow show \
--address 127.0.0.1:7233 \
--namespace dev \
--workflow-id order-happy-path-capture \
-o json > myapp/testdata/histories/order_happy_path.jsonVerify the file looks reasonable — it should be a JSON object with an events
array containing at least a WorkflowExecutionStarted event and a
WorkflowExecutionCompleted event:
python3 -c "
import json
with open('myapp/testdata/histories/order_happy_path.json') as f:
d = json.load(f)
events = d.get('events') or d.get('history', {}).get('events', [])
print(f'{len(events)} events')
types = [e.get('eventType', '') for e in events]
print('first:', types[0])
print('last: ', types[-1])
"
# 12 events
# first: EVENT_TYPE_WORKFLOW_EXECUTION_STARTED
# last: EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETEDRepeat for any other execution paths you want to lock in (retry paths, signal handling, compensating transactions, etc.).
History files are small (~10–50 KB for typical workflows) and belong in source control alongside the worker code they describe. Commit them as regular files:
git add myapp/testdata/histories/
git commit -m "test: add captured workflow histories for determinism tests"Keep one history file per distinct execution path. Name files after what they
represent (order_happy_path.json, order_activity_retry.json,
order_cancel_signal.json), not after workflow IDs or timestamps.
# myapp/BUILD.bazel
load("@rules_temporal//:defs.bzl",
"temporal_build",
"temporal_test",
"temporal_workflow_history",
)
temporal_build(
name = "order_worker",
worker_binary = "//myapp/worker",
task_queue = "order-queue",
workflow_types = ["OrderWorkflow"],
activity_types = ["charge_card", "send_confirmation", "refund_card"],
)
temporal_workflow_history(
name = "order_histories",
worker = ":order_worker",
srcs = glob(["testdata/histories/*.json"]),
)
temporal_test(
name = "order_determinism_test",
worker = ":order_worker",
srcs = ["determinism_test.sh"],
history = ":order_histories",
)When bazel test //myapp:order_determinism_test runs, the launcher:
- Starts an ephemeral
temporal server start-dev. - Starts the
order_workerbinary and waits for it to register its task queue. - Reads each
.jsonfile listed inorder_histories. - Verifies each history file contains events and a completed execution — fails immediately and prints the file name if a history is malformed or empty.
- (Future) Replays each history via
temporal workflow replayagainst the live worker. A non-determinism bug in the worker code causes a non-zero exit and the full replay output is printed to identify the offending event. execve'sdeterminism_test.shwithTEMPORAL_*env vars set.
The test script itself can be as simple as a pass-through if the history check is all you need:
#!/usr/bin/env bash
# determinism_test.sh — history validation is done by the launcher.
# Add any additional live assertions here.
set -euo pipefail
echo "--- PASS ---"Or combine history validation with live regression tests in the same target:
#!/usr/bin/env bash
set -euo pipefail
require_env() { [[ -n "${!1:-}" ]] || { echo "ERROR: \$$1 not set" >&2; exit 1; }; }
require_env TEMPORAL_ADDRESS
require_env TEMPORAL_NAMESPACE
require_env TEMPORAL_TASK_QUEUE
# Run a new execution to verify the current code path still works end-to-end.
result=$(temporal workflow execute \
--address "$TEMPORAL_ADDRESS" \
--namespace "$TEMPORAL_NAMESPACE" \
--type OrderWorkflow \
--task-queue "$TEMPORAL_TASK_QUEUE" \
--input '{"order_id": "ord-test", "amount": 9.99}' \
-o json 2>&1)
echo "$result" | python3 -c "
import json, sys
obj = json.loads(sys.stdin.read())
assert obj.get('result', {}).get('status') == 'completed', f'unexpected: {obj}'
print('OK: OrderWorkflow completed')
"
echo "--- PASS ---"Update (re-export) history files whenever you make a backward-incompatible change to a workflow — one that changes the sequence or type of commands recorded in the event history. Common triggers:
- Reordering activity calls.
- Adding or removing an activity from an existing execution path.
- Changing the input or output type of an activity used inside the workflow.
- Adding a signal handler that changes the command sequence for in-progress workflows.
You do not need to update history files for changes that don't affect the recorded command sequence: adding new workflow types, changing activity implementations (not signatures), updating logging, or modifying worker configuration.
Every temporal_test target gets:
- Its own
temporal server start-devprocess on a dynamically allocated free port. - A UUID-based namespace (
temporal-test-<12-hex-chars>) created at runtime and never reused. - A dedicated worker process started and stopped by the launcher.
No shared state between tests — full --jobs parallelism is safe.
temporal_server and temporal_health_check slot directly into
rules_itest:
load("@rules_temporal//:defs.bzl", "temporal_server", "temporal_health_check")
load("@rules_itest//:itest.bzl", "itest_service", "service_test")
temporal_server(name = "temporal")
temporal_health_check(name = "temporal_health", server = ":temporal")
itest_service(
name = "temporal_svc",
exe = ":temporal",
health_check = ":temporal_health",
)
itest_service(
name = "my_worker_svc",
exe = ":my_worker_wrapper", # sh_binary that sources $TEST_TMPDIR/temporal.env
deps = [":temporal_svc"],
)
service_test(
name = "integration_test",
test = ":integration_test_bin",
services = [":temporal_svc", ":my_worker_svc"],
)Worker wrapper pattern (sources the env file before starting the worker):
#!/usr/bin/env bash
set -euo pipefail
source "$TEST_TMPDIR/temporal.env"
export TEMPORAL_TASK_QUEUE="my-task-queue"
exec "$0.runfiles/myapp/my_worker_bin" "$@"| Scenario | Use |
|---|---|
| Unit / integration test for a single workflow or worker | temporal_test |
| Multi-service test (HTTP API + worker + Temporal) | temporal_server + itest_service |
bazel test with per-target isolation |
temporal_test |
| Shared server across multiple services | temporal_server |
All test scripts should:
- Begin with
set -euo pipefail. - Guard every
TEMPORAL_*variable withrequire_envbefore first use. - Pass
--namespace "$TEMPORAL_NAMESPACE"explicitly on every Temporal CLI call (the env var alone is not always picked up in the Bazel sandbox).
| Platform | temporal.system() |
temporal.version() |
|---|---|---|
| Linux x86_64 | Yes | Yes |
| macOS arm64 | Yes | Checksums are placeholder values |
| macOS amd64 | Yes | Checksums are placeholder values |
| Windows | No | No |
temporal workflow replayis not present in Temporal CLI 1.6.2.temporal_workflow_historystores history files as Bazel artifacts and inspects them at test time; full replay will be enabled in a future CLI version.temporal_serversearch attributes: the server mode wrapper does not currently forward--search-attributeflags. Usetemporal_testwithnamespace_configwhen custom search attributes are required.- Target name collisions: two
temporal_servertargets with the same local name in different packages would write to the same$TEST_TMPDIR/<name>.envpath. Use unique target names within a test run. - No time-skipping:
temporal server start-devruns on real wall-clock time. Timer-based workflows (workflow.sleep, cron schedules) are not testable without long test timeouts. - darwin tarball checksums are placeholders:
temporal.version()on macOS requires real SHA-256 values pinned inextensions.bzl. ~1–2 soverhead per test for server startup. For very large test suites, consider a shared-server approach withtemporal_server.
# Run all self-tests
bazel test //tests/...All 5 tests pass on Linux x86_64 with Temporal CLI 1.6.2.