How to e2e test webhooks π¬
Letβs paint the picture π¨.
Webhooks are a strong mechanism and are used by many to send real-time updates about any kind of event.
When
something happens
on service X, it sends anHTTP-request
to service Yβsendpoint
, notifying it of achange
.
Examples:
- π οΈ GitHub / GitLab β sends a webhook to your CI pipeline whenever you push code
- π Amazon / Uber β sends a webhook to update delivery or ride status in real time
- π Moralis / CryptoAPIs β sends a webhook for onchain transactions involving your wallet
- etc π
Our playground case scenario π.
System overview π
Letβs say we have a delivery service
:
- π We deliver orders to addresses β the final destination of each package.
- ποΈ Every delivery has its own metadata β details that can change while the order is in progress.
- π If anything changes about an ongoing delivery, we need to notify everyone involved:
client
,delivery man
,integrated services
,apps
, and more. - π‘ We handle all these updates via webhooks.
Hereβs an example
metadata
for our delivery service:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { "orderId": "4b2192f5-6cd4-4a3d-9b72-1674eb98bca4", "status": "delivering", "paid": true, "leaveAtTheDoor": true, "deliveryAddress": { "city": "Sydney", "street": "Wallaby Way", "house": "42", "flat": "3", "entrance": "2", "intercom": "2", "comment": "Please ring and leave at the door if no answer" }, "clientInfo": { "name": "Peter Gaevoy", "phone": "+61 999 322 633" } }
Webhook delivery client setup π
Now we create simple client that will send webhooks for our delivery.
π§Ύ Declare webhook data types
β Define the structure of a webhook payload to keep things type-safe and tidy.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export interface DeliveryAddress {
city: string;
street: string;
house: string;
flat: string;
entrance: string;
intercom: string;
comment: string;
}
export interface ClientInfo {
name: string;
phone: string;
}
export interface DeliveryWebhook {
orderId: string;
status: string;
paid: boolean;
leaveAtTheDoor: boolean;
deliveryAddress: DeliveryAddress;
clientInfo: ClientInfo;
}
ποΈ Create a builder to generate and modify delivery data
β This makes it easy to construct valid payloads dynamically.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { DeliveryWebhook } from "../../types/delivery/webhook-types";
import { v4 as uuidv4 } from 'uuid';
export const buildDeliveryWebhook = (overrides: Partial<DeliveryWebhook> = {}): DeliveryWebhook => {
return {
orderId: uuidv4(),
status: "delivering",
paid: true,
leaveAtTheDoor: true,
deliveryAddress:
{
city: "Sydney",
street: "Wallaby Way",
house: "42",
flat: "3",
entrance: "2",
intercom: "2",
comment: "Please ring and leave at the door if no answer"
}
,
clientInfo: {
name: "Peter Gaevoy",
phone: "+61 999 322 633"
},
...overrides,
}
};
π It returns a complete
DeliveryWebhook
object with default values filled in.
If you want to change any of the fields, simply pass an object with your custom values using theoverrides
parameter β those values will replace the defaults.
π€ Write a function that sends a webhook
β A simple function to POST crafted payloads.
1
2
3
4
5
6
7
8
9
10
11
12
async function sendWebhookWithCustomStatus(webhookUrl: string, statuses: string[]) {
for (const status of statuses) {
const requestBody = buildDeliveryWebhook({ status });
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
expect(response.status).toBe(200);
}
}
π Sends webhook requests sequentially with specified statuses to the given webhook URL.
β Asserts that each request returns HTTP 200 status.
- DONE
- Now we can simulate webhooks being sent by our delivery service.
Webhook receiver and validator π
π Now begins the FUN part! π€©
To validate that our system actually sends webhooks, we need to create an endpoint to receive and verify them.
π οΈ Create an endpoint to receive incoming webhooks
β This endpoint will act as the receiver for incoming webhooks, enabling us to handle, verify, and validate the webhook data during tests.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import express from "express";
const webhooks: any[] = [];
export async function startServer() {
const app = express();
const port = 3001;
app.use(express.json());
app.post("/webhook", (req, res) => {
const data = req.body;
webhooks.push(data);
res.status(200).send("OK");
});
const server = app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
return {
server,
getWebhooks: () => [...webhooks],
clearWebhooks: () => webhooks.splice(0)
};
}
π Starts a local Express server on port 3001 that listens for incoming POST requests to the
/webhook
endpoint.
ποΈ Stores received webhook payloads in memory and provides handy utility functions to access or clear them.
π Expose your local server using ngrok
β Public URL lets external services reach local endpoint.
π‘ Note:
Example in step 1 will work justfine
as long as webhooks are coming from the local machine.
If you want to work with external systems β you need to open your endpoint to the internet.
Thatβs wherengrok
gets you covered π» β π€ β π
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import ngrok from "ngrok";
. . .
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
let url = await ngrok.connect(port);
console.log(`ngrok tunnel opened at: ${url}`);
return {
server,
url,
getWebhooks: () => [...webhooks],
. . .
π§Ύ Write a smart parser
β Extract the status
or any required data from incoming webhook
β Store it in a variable
for your test to check later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
interface WebhookData {
data?: {
status?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
const webhooks: WebhookData[] = [];
export async function waitForWebhookStatuses(
expectedStatuses: string[],
timeout = 5,
): Promise<WebhookData[]> {
const start = Date.now();
const loggedStatuses = new Set<string>();
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
const statuses = webhooks.map((w) => w.status).filter((s): s is string => Boolean(s));
for (const status of statuses) {
if (!loggedStatuses.has(status)) {
console.log(`Webhook received with status: "${status}"`);
loggedStatuses.add(status);
}
}
const allReceived = expectedStatuses.every((s) => statuses.includes(s));
if (allReceived) {
clearInterval(interval);
resolve([...webhooks]);
}
const timeoutMs = timeout * 1000 * 60;
if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error(`Timeout: not all statuses received. Got: ${statuses}`));
}
}, 500);
});
}
β³ Waits for webhook events with the specified statuses to arrive within the given time limit.
- NOTE
- Donβt forget to add it to return in startServer()
1
2
3
4
5
6
7
8
9
. . .
return {
server,
url,
getWebhooks: () => [...webhooks],
clearWebhooks: () => webhooks.splice(0),
waitForWebhookStatuses,
};
. . .
π§Ή Clean up after test
β Add a function to stop both the local server
and the ngrok
tunnel
β Prevent leftover processes messing up next runs.
1
2
3
4
5
export async function stopServer(server: Server) {
server.close();
await ngrok.disconnect();
await ngrok.kill();
}
Now put it to the test π§ͺ
Example spec that receives and validates webhooks π₯³
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import test, { expect } from '@playwright/test';
import { startServer, stopServer } from '../../utils/webhook/ngrok-client';
import { buildDeliveryWebhook } from '../../utils/webhook/webhook-builder';
let serverInstance: Awaited<ReturnType<typeof startServer>>;
let webhookUrl: string;
// Start the webhook server before all tests
test.beforeAll(async () => {
serverInstance = await startServer();
webhookUrl = serverInstance.url + "/webhook";
});
// Stop the webhook server after all tests
test.afterAll(async () => {
await stopServer(serverInstance.server);
});
test.describe("Webhook status flow", () => {
test("should receive all expected webhook statuses", async () => {
test.setTimeout(300_000);
const expectedStatuses = ["created", "cooking", "delivering", "delivered"];
// Start waiting for all expected webhook statuses in background
const hooksPromise = serverInstance.waitForWebhookStatuses(expectedStatuses, 5);
// Send webhooks with expected statuses sequentially
await sendWebhookWithCustomStatus(webhookUrl, expectedStatuses);
// Await until all expected statuses are received or timeout occurs
const hooks = await hooksPromise;
// Extract received statuses from webhooks
const receivedStatuses = hooks.map(hook => hook.status);
// Assert that all expected statuses were received
expect(receivedStatuses).toEqual(expect.arrayContaining(expectedStatuses));
});
});
Wrapping it all up π€
Now you know how to handle webhooks
in e2e
specs! π§ͺπ
You can go further from here and Pimp My Ride π for your project needs:
- β Add validators for more fields
- β° Set time deadlines for target statuses
- π’ Check correct order of statuses
- π« Look out for doubles
- ποΈ Compare data in DB with webhook
- β¨ Etc
- οΏΌ
- All of code above is available in my:
- οΏΌ
- Playwright example project
- β¨ Etc