What are you really trying to do?
Testing a payment-timeout workflow that mixes a long timer and several activities. The workflow waits 30 minutes via
Workflow::awaitWithTimeout, then calls an activity to check the gateway, then dispatches one of three completion activities
depending on the result. We want the test to complete in seconds (not 30+ wall-clock minutes) by relying on the time-skipping test
server, while mocking the activity results with Temporal\Testing\ActivityMocker.
Describe the bug
ActivityMocker serves activity results through the RoadRunner KV cache (RoadRunnerActivityInvocationCache) — it short-circuits
the worker's normal activity-execution lifecycle. The test server therefore never sees "an activity is running on a worker" and
keeps fast-forwarding virtual time even though the activity is logically in flight.
withStartToCloseTimeout(N) is denominated in virtual seconds, so the timeout elapses in microseconds of wall-clock — faster
than the mocked response can come back through KV. The workflow fails with:
WorkflowFailedException
└── ActivityFailure
└── TimeoutFailure: timeoutWorkflowType='TIMEOUT_TYPE_START_TO_CLOSE'
This means timer-driven workflows with ActivityMocker are effectively untestable under time-skipping. We confirmed it with the
following matrix:
| Setup |
Result |
| Timer + signal-only workflow (no activities) |
works — workflow exits on signal before timer matters |
Timer + ActivityMocker-served activity, default Time Locking Counter = 1 |
hangs on the timer (no fast-forward — see related |
| issue about lock counter) |
|
Same, with explicit unlockTimeSkipping() around getResult() |
TIMEOUT_TYPE_START_TO_CLOSE on activity |
Same, with withStartToCloseTimeout(300) (10x larger) |
same — virtual time elapses 300s in microseconds, mock still doesn't beat |
| it |
|
Same, with smaller workflow input timeouts (e.g. awaitWithTimeout(1, ...)) |
same — activity timeout still virtual |
The root cause is that ActivityMocker sits outside the worker's "activity is running" signal. The TS / Java / Go SDKs don't have
this problem because their test mocks register as real activity implementations on the worker (e.g.
worker.registerActivitiesImplementations(mockImpl) in Java), so the test server correctly halts the virtual clock while a mock is
responding.
Minimal Reproduction
#[WorkflowInterface]
final class TimerActivityWorkflow {
#[WorkflowMethod(name: 'TimerActivity')]
public function execute(string $id): \Generator {
$activity = Workflow::newActivityStub(
CheckActivity::class,
ActivityOptions::new()->withStartToCloseTimeout(30),
);
yield Workflow::timer(1800); // 30 min — should fast-forward
$result = yield $activity->check($id); // mocked via ActivityMocker
return $result;
}
}
final class TimerActivityWorkflowTest extends WorkflowTestCase {
private ActivityMocker $mocks;
protected function setUp(): void {
parent::setUp();
$this->mocks = new ActivityMocker();
}
protected function tearDown(): void {
$this->mocks->clear();
parent::tearDown();
}
public function testHits(): void {
$this->mocks->expectCompletion('CheckActivity.check', 'paid');
$stub = $this->workflowClient->newWorkflowStub(
TimerActivityWorkflow::class,
WorkflowOptions::new()
->withTaskQueue('default')
->withWorkflowExecutionTimeout('PT2H'),
);
// Fails with TIMEOUT_TYPE_START_TO_CLOSE on the mocked activity.
$result = $this->workflowClient->start($stub, 'p1')->getResult('string');
self::assertSame('paid', $result);
}
}
Environment/Versions
- OS and processor: Linux x86_64 (Fedora 43, kernel 6.19), running inside an Alpine-based Docker container.
- Temporal Version:
temporal/sdk 2.17, temporal-test-server (downloaded by Temporal\Testing\Downloader from official
releases — version whichever SystemInfo::detect() resolves at the time of running).
- Are you using Docker or Kubernetes or building Temporal from source? Docker (Alpine + RoadRunner 2024.x). Issue is independent
of RR — same shape against a vanilla worker.
- PHP: 8.4
Additional context
Suggested fixes, in order of preference:
-
Make ActivityMocker participate in the test server's "activity is running" signal. Have it open and hold an activity
heartbeat (or equivalent gRPC call) for the duration of the mocked response, so the server halts the virtual clock the same way it
does for real activities. Without this, time-skipping is unusable for any workflow that mixes timers and activities — which is most
non-trivial workflows.
-
Document the limitation prominently. The current testing-suite docs (PHP
Testing) suggest ActivityMocker as the standard way to mock activities in
workflow tests, with no warning that it's incompatible with timer-driven workflows under time-skipping. At minimum, a "Known
limitations" section pointing at this issue would have saved us hours of debugging.
-
Provide a worker-registered mock alternative. TS / Java / Go register mock activity implementations on the worker, which
integrates with the activity-running detection. A PHP equivalent — e.g. a MockActivityRegistry you register on the worker before
WorkerFactory::run() — would let users opt out of ActivityMocker for the cases where it can't work.
Workaround we're using: registering real PHP stub-classes that implement the activity interface on the test worker, then using
PHPUnit mocks/spies inside those stubs to assert/control behavior. Works, but requires writing per-test stub classes — much heavier
than ActivityMocker::expectCompletion.
Related issue: the test server also starts with Time Locking Counter = 1 (skipping locked) and PHP SDK never auto-unlocks around
getResult() / execute(), the way TS/Java/Go SDKs do. That's filed separately — but it compounds with this one: until both are
fixed, timer-driven PHP workflows with mocked activities can't be tested via the standard tools.
What are you really trying to do?
Testing a payment-timeout workflow that mixes a long timer and several activities. The workflow waits 30 minutes via
Workflow::awaitWithTimeout, then calls an activity to check the gateway, then dispatches one of three completion activitiesdepending on the result. We want the test to complete in seconds (not 30+ wall-clock minutes) by relying on the time-skipping test
server, while mocking the activity results with
Temporal\Testing\ActivityMocker.Describe the bug
ActivityMockerserves activity results through the RoadRunner KV cache (RoadRunnerActivityInvocationCache) — it short-circuitsthe worker's normal activity-execution lifecycle. The test server therefore never sees "an activity is running on a worker" and
keeps fast-forwarding virtual time even though the activity is logically in flight.
withStartToCloseTimeout(N)is denominated in virtual seconds, so the timeout elapses in microseconds of wall-clock — fasterthan the mocked response can come back through KV. The workflow fails with:
This means timer-driven workflows with
ActivityMockerare effectively untestable under time-skipping. We confirmed it with thefollowing matrix:
ActivityMocker-served activity, default Time Locking Counter = 1unlockTimeSkipping()aroundgetResult()TIMEOUT_TYPE_START_TO_CLOSEon activitywithStartToCloseTimeout(300)(10x larger)awaitWithTimeout(1, ...))The root cause is that
ActivityMockersits outside the worker's "activity is running" signal. The TS / Java / Go SDKs don't havethis problem because their test mocks register as real activity implementations on the worker (e.g.
worker.registerActivitiesImplementations(mockImpl)in Java), so the test server correctly halts the virtual clock while a mock isresponding.
Minimal Reproduction
Environment/Versions
temporal/sdk2.17,temporal-test-server(downloaded byTemporal\Testing\Downloaderfrom officialreleases — version whichever
SystemInfo::detect()resolves at the time of running).of RR — same shape against a vanilla worker.
Additional context
Suggested fixes, in order of preference:
Make
ActivityMockerparticipate in the test server's "activity is running" signal. Have it open and hold an activityheartbeat (or equivalent gRPC call) for the duration of the mocked response, so the server halts the virtual clock the same way it
does for real activities. Without this, time-skipping is unusable for any workflow that mixes timers and activities — which is most
non-trivial workflows.
Document the limitation prominently. The current testing-suite docs (PHP
Testing) suggest
ActivityMockeras the standard way to mock activities inworkflow tests, with no warning that it's incompatible with timer-driven workflows under time-skipping. At minimum, a "Known
limitations" section pointing at this issue would have saved us hours of debugging.
Provide a worker-registered mock alternative. TS / Java / Go register mock activity implementations on the worker, which
integrates with the activity-running detection. A PHP equivalent — e.g. a
MockActivityRegistryyou register on the worker beforeWorkerFactory::run()— would let users opt out ofActivityMockerfor the cases where it can't work.Workaround we're using: registering real PHP stub-classes that implement the activity interface on the test worker, then using
PHPUnit mocks/spies inside those stubs to assert/control behavior. Works, but requires writing per-test stub classes — much heavier
than
ActivityMocker::expectCompletion.Related issue: the test server also starts with Time Locking Counter = 1 (skipping locked) and PHP SDK never auto-unlocks around
getResult()/execute(), the way TS/Java/Go SDKs do. That's filed separately — but it compounds with this one: until both arefixed, timer-driven PHP workflows with mocked activities can't be tested via the standard tools.