Functional Tests with Symfony and Webhook component
Webhooks are a great way to make two systems communicate with each other and Symfony 6.3 (PHP 8.1 or above) introduced two components: Webhook and RemoteEvent to handle them in our applications.
This article intends to show how we can easily simulate them in our functional tests, which is essential especially if incoming Webhooks modify the state of your application.
No time to read? The complete example test class is available here.
The concept: Webhooks by the books
Webhooks are a way for an application to send data to one or more other applications when an event occurs. They are an implementation of the Observer pattern where one or multiple Observers (or clients) subscribe to a Subject (or server) to get notified of any changes in its state. In the context of our Symfony application we will assume that we are clients that want to get notified of changes from a remote service (ex: a confirmation from a payment platform, an email verification from an external service, an update from a shop inventory…) using HTTP requests.
How is this different from our beloved API calls? The direction of the relation is the key, in API polling the client will ask the same request several times to a server and will have some logic to process the responses and determine if anything has changed. Webhooks work the other way around: when an event occurs the server will send a request to the client with the relevant data.
The implementation: Symfony’s RemoteEvent and Webhooks components
So from our Symfony application standpoint (remember we are the client here) a Webhook is no more than a HTTP request (usually a POST) that will hit one of the endpoints listed in our Webhook component configuration.
Let’s say for instance that our application needs to receive updates via Webhooks from developer platforms such as GitHub and GitLab, our Webhook component configuration would look like this:
framework:
webhook:
routing:
gitlab:
service: 'App\WebHook\GitLabRequestParser'
secret: '%env(GITLAB_WEBHOOK_SECRET)%'
github:
service: 'App\WebHook\GitHubRequestParser'
secret: '%env(GITHUB_WEBHOOK_SECRET)%'
The configuration declares the two routes, /webhook/gitlab
and /webhook/github
, that will be receiving the Webhooks requests. When a request hits the endpoint it will be passed to the mentioned service where we can perform validation operations before further processing. We also pass them a secret variable that will be used to authenticate the request's origin. These two services must implement the RequestParserInterface
bellow (or extend Webhook component's AbstractRequestParser
which already implements the interface).
interface RequestParserInterface
{
/**
* Parses an HTTP Request and converts it into a RemoteEvent.
*
* @return ?RemoteEvent Returns null if the webhook must be ignored
*
* @throws RejectWebhookException When the payload is rejected (signature issue, parse issue, ...)
*/
public function parse(Request $request, #[\SensitiveParameter] string $secret): ?RemoteEvent;
public function createSuccessfulResponse(): Response;
public function createRejectedResponse(string $reason): Response;
}
So for any validated Webhook we are going to instantiate and return a RemoteEvent
(from the component of the same name), a simple class with 3 properties: a string $id
, a string $name
and an array $payload
. Finally the$name
property is going to be used in a RemoteEventConsumer
configuration to let it know which RemoteEvent
it must consume. For instance the RemoteEventConsumer bellow handles the RemoteEvents with $name
property set to 'gitlab':
#[AsRemoteEventConsumer('gitlab')]
final readonly class GitLabRemoteEventConsumer implements ConsumerInterface
{
public function consume(RemoteEvent $event): void
{
// any event processing logic (eg: creating entites, persisting in a database, routing to a message bus, etc..)
}
}
The ConsumerInterface
only has the above public consume(RemoteEvent $event): void
method.
The testing: Functional tests
Our functional test strategy will be pretty straightforward: we will prepare json payloads based on actual Webhooks from these platforms (their documentation often provides payload examples that can we use right away, see for instance the GitLab documentation for an Issue related Webhook) and then send these via POST request to our Webhook routes, from there we can perform assertions to determine how it has modified our system.
To minimize the overhead in our test classes we create a WebhookTestCase
that extends Symfony's framework WebTestCase
which will host our helpers to fetch and process the payload fixtures and perform the requests. Bellow is the class skeleton, we will detail its content following:
namespace App\Tests\Functional\Webhooks;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
abstract class WebhookTestCase extends WebTestCase
{
protected const FIXTURES_DIR = "path/to/fixtures/directory/";
protected KernelBrowser $client;
abstract protected function getProvider(): string;
public function setUp(): void {}
protected function receiveWebhook(string $payloadFixtureFileName): array {}
private function parseFixtureFile(string $fixtureFilePath): void {}
private function makeWebhookRequest(string $content): void {}
private function addProviderHeaders(array &$headers, string $content): void {}
}
Fetch and decode the fixture files
Right, this is our first step, we need to go read the content of our prepared json fixture file and decode it. For readibility, we do not want to write the absolute path to our fixtures files in our tests, we would rather have these in a dedicated folder and go fetch the files from that folder, hence the protected FIXTURES_DIR = "path/to/fixtures/directory/"
constant:
protected function receiveWebhook(string $payloadFixture): array
{
$fixtureFilePath = self::FIXTURES_DIR.'/'.$payloadFixture;
if (false === $stringData = file_get_contents($fixturePath)) {
$this->fail('Could not read fixture file');
}
if (false === $fixture = json_decode($stringData, true)) {
$this->fail('Could not decode fixture file');
}
// now that the payload is decoded we can use it to simulate an incoming Webhook
// …
return $fixture;
}
Note that we conveniently return our $fixture
data in an array, it will be usefull to perform our assertions.
Prepare HTTP headers
Webhooks requests use HTTP headers in order to identify their origin. Each secret that we passed to the request parser services is also known by the Webhook provider and added to a header. The way it is used is specific to each provider, that is why we have a dedicated parser for each of them.
For instance GitLab simply uses the secret as the value of a X-GITLAB-TOKEN
header (our parser compares this header value with the secret variable).
So with each provider having its specificities we need to know at this point for which provider we are preparing a request, we could pass the provider name to the method call, that would be totally fine but kind of redundant, we would rather have this information somewhere in our class, or even better, we could defer this responsibility to the test class that will be extending our WebhookTestCase. That is why we declare an abstract protected function getProvider(): string
method, to make sure our children test classes will always specify a provider.
Then let’s add a method to our WebhookTestCase
that would set the required headers for each of these providers:
private function addProviderHeaders(array &$headers): void
{
match ($this->getProvider()) {
'gitlab' => $headers['HTTP_X-GITLAB-TOKEN'] = $_ENV['GITLAB_WEBHOOK_SECRET'],
// Any other provider-specific headers handling
// …
};
}
Making the request using the client
We now have everything we need to make our Webhook request:
private function makeWebhookRequest(string $content): void
{
$headers = [
'HTTP_CONTENT_TYPE' => 'application/json',
];
$uri = match ($this->getProvider()) {
'gitlab' => '/webhook/gitlab',
'github' => '/webhook/github',
};
$this->addProviderHeaders($headers);
$this->client->request(method: 'POST', uri: $uri, server: $headers, content: $content);
}
It’s a wrap!
Almost done! Let’s wrap up all of this to complete our protected function receiveWebhook(string $payloadFixtureFileName): array {}
method:
protected function receiveWebhook(string $payloadFixture): array
{
$fixtureFilePath = self::FIXTURES_DIR.$this->getProvider().'/'.$payloadFixture;
if (false === $stringData = file_get_contents($fixturePath)) {
$this->fail('Could not read fixture file');
}
if (false === $fixture = json_decode($stringData, true)) {
$this->fail('Could not decode fixture file');
}
$this->makeWebhookRequest($stringData);
return $fixture;
}
Usage
Great, we are now ready to use it in our test (in our example we will create an entity out of the information we got from the payload):
class SomeTest extends WebhookTestCase
{
protected function getProvider(): string
{
return 'gitlab';
}
public function testWebhookEvent(): void
{
$payload = $this->receiveWebhook(‘my_gitlab_fixture_name.json’);
$entity = // some logic to retrieve the created entity
$this->assertEquals($payload[‘name’], $entity->getName());
$this->assertEquals($payload[‘type’], $entity->getType());
// Any other relevant assertion
// …
}
}
class SomeOtherTest extends WebhookTestCase
{
protected function getProvider(): string
{
return 'github';
}
public function testWebhookEvent(): void
{
$payload = $this->receiveWebhook(‘my_github_fixture_name.json’);
$entity = // some logic to retrieve the created entity
$this->assertEquals($payload[‘name’], $entity->getName());
$this->assertEquals($payload[‘type’], $entity->getType());
// Any other relevant assertion
// …
}
}
That’s it!
We have deterministic tests that are isolated from the external services, a simple method to simulate our incoming Webhooks and a centralized way to prepare our requests where we could easily add support for any other provider. Again the complete example test class is available here, feel free to use and adapt it to your needs.
Happy testing!
Resources
🔗 Symfony’s Webhook component documentation