Testing Node Processors¶
FlowDrop node processors are standard Drupal plugins and can be unit tested with PHPUnit. Because processors implement a simple process(ParameterBagInterface $params): array method, they are straightforward to test without a Drupal bootstrap.
Test Environment Setup¶
FlowDrop ships with a DDEV-based development environment. Run tests with:
# Run all FlowDrop unit tests
ddev phpunit modules/
# Run tests for a specific module
ddev phpunit modules/flowdrop_node_processor/tests/
# Run a specific test class
ddev phpunit modules/flowdrop_node_processor/tests/src/Unit/Plugin/FlowDropNodeProcessor/HttpRequestTest.php
Tests use Drupal's UnitTestCase base class, which provides mocking utilities without requiring a full Drupal install.
Basic Unit Test Structure¶
Place tests in tests/src/Unit/Plugin/FlowDropNodeProcessor/ within your module. The test class should extend Drupal\Tests\UnitTestCase.
<?php
declare(strict_types=1);
namespace Drupal\Tests\my_module\Unit\Plugin\FlowDropNodeProcessor;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\my_module\Plugin\FlowDropNodeProcessor\MyProcessor;
use Drupal\Tests\UnitTestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* Tests the MyProcessor node processor.
*
* @coversDefaultClass \Drupal\my_module\Plugin\FlowDropNodeProcessor\MyProcessor
* @group my_module
*/
class MyProcessorTest extends UnitTestCase {
use ProphecyTrait;
/**
* Creates a processor instance for testing.
*/
private function createProcessor(): MyProcessor {
return new MyProcessor(
[], // $configuration
'my_processor', // $plugin_id
['id' => 'my_processor'], // $plugin_definition
);
}
/**
* @covers ::process
*/
public function testProcessReturnsExpectedOutput(): void {
$processor = $this->createProcessor();
// Mock the ParameterBag.
$params = $this->prophesize(ParameterBagInterface::class);
$params->get('input_text')->willReturn('hello world');
$params->getString('mode', 'upper')->willReturn('upper');
$result = $processor->process($params->reveal());
$this->assertArrayHasKey('output', $result);
$this->assertSame('HELLO WORLD', $result['output']);
}
}
Mocking ParameterBagInterface¶
Use Prophecy (included with Drupal's testing infrastructure) to mock the parameter bag:
use Prophecy\Argument;
// Stub a specific parameter value:
$params->get('url')->willReturn('https://example.com');
// Stub typed accessors:
$params->getString('mode', 'default')->willReturn('custom');
$params->getInt('count', 0)->willReturn(5);
$params->getBool('enabled', FALSE)->willReturn(TRUE);
$params->getArray('items', [])->willReturn(['a', 'b', 'c']);
// Stub with a wildcard (match any default):
$params->get('optional_key', Argument::any())->willReturn(NULL);
Testing with Service Dependencies¶
If your processor injects services via create(), mock them in the test:
use Drupal\Core\Logger\LoggerChannelInterface;
private function createProcessor(): MyProcessor {
$logger = $this->prophesize(LoggerChannelInterface::class);
// Expect a specific log call:
$logger->info(Argument::containingString('processed'))->shouldBeCalled();
return new MyProcessor(
[],
'my_processor',
['id' => 'my_processor'],
$logger->reveal(),
);
}
Testing Exception Cases¶
Processors should throw InvalidNodeConfigurationException for bad configuration and NodeProcessingException for runtime failures:
use Drupal\flowdrop\Exception\InvalidNodeConfigurationException;
use Drupal\flowdrop\Exception\NodeProcessingException;
public function testEmptyUrlThrowsConfigurationException(): void {
$processor = $this->createProcessor();
$params = $this->prophesize(ParameterBagInterface::class);
$params->get('url')->willReturn('');
$this->expectException(InvalidNodeConfigurationException::class);
$this->expectExceptionMessageMatches('/url.*required/i');
$processor->process($params->reveal());
}
public function testExternalServiceFailureThrowsProcessingException(): void {
// ... test that a failed API call throws NodeProcessingException
}
Testing with an HTTP Client¶
For processors that make HTTP requests (like a custom integration node), mock the Guzzle client:
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use Drupal\Core\Http\ClientFactory;
private function createProcessor(int $statusCode = 200, string $body = '{}'): MyApiProcessor {
$response = new Response($statusCode, [], $body);
$client = $this->prophesize(Client::class);
$client->request(Argument::cetera())->willReturn($response);
$factory = $this->prophesize(ClientFactory::class);
$factory->fromOptions(Argument::any())->willReturn($client->reveal());
return new MyApiProcessor(
[], 'my_api_processor', ['id' => 'my_api_processor'],
$factory->reveal(),
);
}
public function testSuccessfulApiCallReturnsData(): void {
$processor = $this->createProcessor(200, '{"name": "test"}');
$params = $this->prophesize(ParameterBagInterface::class);
$params->get('endpoint')->willReturn('https://api.example.com/resource');
$result = $processor->process($params->reveal());
$this->assertSame('test', $result['name']);
}
public function testApiErrorReturnsFailureStatus(): void {
$processor = $this->createProcessor(500, '{"error": "Internal Server Error"}');
// ...
}
Subclassing to Stub External Calls¶
When a processor makes calls that are hard to mock (DNS lookups, file system access), create a testable subclass that overrides the specific method:
// In your test file:
class TestableDnsProcessor extends MyDnsProcessor {
public ?string $mockedIp = '1.2.3.4';
protected function resolveHostname(string $hostname): ?string {
return $this->mockedIp;
}
}
This pattern is used in HttpRequestTest.php to stub DNS resolution without real network calls.
Test File Location Convention¶
modules/my_module/
tests/
src/
Unit/
Plugin/
FlowDropNodeProcessor/
MyProcessorTest.php
Declare the test group with @group my_module to run your module's tests in isolation:
ddev phpunit --group my_module
Integration Testing with WorkflowExecutionTestBase¶
Unit tests cannot observe orchestration semantics — job scheduling, branch
gating, tool wiring, state propagation. To pin how your node behaves inside a
running workflow, extend the public kernel base
Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase. It ships the full
module closure and a small fixture/assertion helper set for the
load-fixture → launch → assert-job-trail loop:
use Drupal\Tests\flowdrop\Kernel\WorkflowExecutionTestBase;
class MyProcessorIntegrationTest extends WorkflowExecutionTestBase {
// Merged with the base module list — declare only your additions.
protected static $modules = ['my_module'];
protected function setUp(): void {
parent::setUp();
$this->installConfig(['my_module']);
}
public function testMyNodeRunsInAWorkflow(): void {
$workflow = $this->createStateGraphWorkflow('my_fixture', [
$this->node('start', 'my_node_type', ['option' => 'value']),
$this->loggerNode('sink', 'done'),
], [
$this->edge('start', 'result', 'sink', 'message'),
]);
$result = $this->launchAndWait($workflow);
$pipeline = $this->loadPipeline($result->pipelineId);
$this->assertSame('completed', $result->status, $this->jobTrail($pipeline));
$this->assertSame('completed', $this->jobStatusForNode($pipeline, 'start'));
$this->assertSame('value', $this->jobOutputForNode($pipeline, 'start')['echoed'] ?? NULL);
}
}
Helpers: createWorkflow() / createStateGraphWorkflow() (fixtures),
node() / edge() plus convenience builders (loggerNode(),
gatewayNode(), repeatNode(), forEachNode(), confirmationNode()),
launchAndWait(), and the assertions loadPipeline(),
jobStatusForNode(), jobOutputForNode(), jobTrail().
The in-tree orchestration suites (StateGraphBranchSemanticsTest,
StateGraphExecutorParityTest, …) are built on the same base and double as
usage examples.
Running PHPStan on Your Tests¶
FlowDrop enforces PHPStan level 6. After writing tests, verify they pass static analysis:
ddev phpstan analyse modules/my_module/tests/
Next Steps¶
- Create a Node Processor — Build the processor you want to test
- Node Processor Plugin System — Deep dive into processor internals