Every CI pipeline eventually grows a Slack notification. Usually it starts as a one-liner: curl -X POST ... -d '{"text":"Tests failed"}'. Then someone wants the test count. Then the branch name. Then a link to the report. Then a list of failing tests. Before long you have three hundred lines of shell script and a bunch of JSON you're scared to touch.
@playwright-labs/reporter-slack is a Playwright reporter that handles all of that for you.
Setup in 30 seconds
pnpm add -D @playwright-labs/reporter-slack
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { WithOptionsTemplate } from "@playwright-labs/reporter-slack/templates";
export default defineConfig({
reporter: [
["@playwright-labs/reporter-slack", {
send: { webhook: process.env.SLACK_WEBHOOK_URL },
template: WithOptionsTemplate,
}],
],
});
That's it. When your test run finishes, Slack gets a message.
What the message looks like
The default WithOptionsTemplate groups tests by status and adds an interactive dropdown filter:
✅ My App — All tests passed
Total: 42 Duration: 18.4s Started: Mon, 28 Apr 2025 ...
[Filter by status ▾] [View Full Report →]
──────────────
✅ Passed — 40 tests
• `Auth › login works`
• `Auth › logout works`
• `Shop › add to cart`
…
⏩ Skipped — 2 tests
If the run fails:
❌ My App — Tests failed
Total: 42 Duration: 22.1s
[Filter by status ▾] [View Full Report →]
──────────────
❌ Failed — 3 tests
• `Auth › forgot password`
_Expected button to be enabled_
• `Shop › checkout flow`
_TimeoutError: locator('[data-testid=checkout]') timed out_
• `Shop › apply coupon`
_AssertionError: expected 200, got 404_
The first error line from each failed test appears directly under the test name — no clicking through to logs for the most common failures.
Three built-in templates
BaseTemplate — simple pass/fail
Good for small projects or when you just need signal in your channel. Header + test count + duration + list of failed tests + optional report button. Nothing else.
import { BaseTemplate } from "@playwright-labs/reporter-slack/templates";
reporter: [["@playwright-labs/reporter-slack", {
send: { webhook: process.env.SLACK_WEBHOOK_URL },
template: (result, testCases) =>
BaseTemplate(result, testCases, {
projectName: "My App",
reportUrl: process.env.REPORT_URL,
}),
}]]
WithOptionsTemplate — status groups + interactive filter
Groups tests into status sections (failed → timedOut → interrupted → skipped → passed). Includes a static_select dropdown so anyone in the channel can filter by status without leaving Slack. Overflowing tests show an "…and N more" notice.
import { WithOptionsTemplate } from "@playwright-labs/reporter-slack/templates";
template: (result, testCases) =>
WithOptionsTemplate(result, testCases, {
projectName: "My App",
reportUrl: process.env.REPORT_URL,
maxPerStatus: 5, // cap test names per group
showTestNames: true, // default true
show: {
passed: false, // hide passed group (reduce noise on green runs)
},
}),
WithTableTemplate — env context as a table
Same test summary, but adds a GFM table of environment variables below it. Useful when you run tests across multiple environments and need to know where the run happened.
import { WithTableTemplate } from "@playwright-labs/reporter-slack/templates";
template: (result, testCases) =>
WithTableTemplate(result, testCases, {
projectName: "My App",
reportUrl: process.env.REPORT_URL,
tableTitle: "Build Environment",
env: {
CI: process.env.CI,
BRANCH: process.env.BRANCH,
BUILD_ID: process.env.BUILD_ID,
DEPLOY_ENV: process.env.DEPLOY_ENV,
DB_HOST: process.env.DB_HOST,
DB_PASSWORD: process.env.DB_PASSWORD, // 👈 auto-masked
API_KEY: process.env.API_KEY, // 👈 auto-masked
},
}),
The rendered table looks like:
| Variable | Value |
| --- | --- |
| `CI` | true |
| `BRANCH` | main |
| `BUILD_ID` | run-42 |
| `DB_HOST` | db.internal|
| `DB_PASSWORD` | •••••••• |
| `API_KEY` | •••••••• |
Auto-masking sensitive variables
Any key whose name matches TOKEN, SECRET, PASSWORD, PASS, KEY, AUTH, or CREDENTIAL is automatically replaced with •••••••• before anything reaches the Slack API.
You can also be explicit:
mask: ["CUSTOM_SECRET", "INTERNAL_TOKEN"] // mask only these keys
mask: false // disable all masking
Two transport options
Incoming Webhook — the simplest path. Create a webhook app in Slack, pass the URL:
send: { webhook: process.env.SLACK_WEBHOOK_URL }
Web API — requires a Slack app with chat:write scope. More control: you can choose the channel, post as a bot, thread replies:
send: {
token: process.env.SLACK_BOT_TOKEN,
channel: "#ci-reports",
}
Writing your own template
All three templates are regular functions — (result: FullResult, testCases: SlackTestCases) => SlackBlock[]. You can write your own from scratch using the JSX components from @playwright-labs/slack-buildkit/react, or compose on top of an existing template.
/** @jsxImportSource @playwright-labs/slack-buildkit/react */
import { Blocks, Header, Section, Divider, Table, Tr, Th, Td, Context } from "@playwright-labs/slack-buildkit/react";
import type { FullResult } from "@playwright/test/reporter";
import type { SlackTestCases } from "@playwright-labs/reporter-slack";
export function MyTemplate(result: FullResult, testCases: SlackTestCases) {
const failed = testCases.filter(([, r]) => r.status === "failed");
const emoji = result.status === "passed" ? ":white_check_mark:" : ":x:";
return (
<Blocks>
<Header>{`${emoji} My App — ${result.status}`}</Header>
{failed.length > 0 && (
<>
<Divider />
<Table>
<Tr><Th>Test</Th><Th>Error</Th></Tr>
{failed.map(([test, r]) => (
<Tr>
<Td>{test.title}</Td>
<Td>{r.errors[0]?.message?.split("\n")[0] ?? "—"}</Td>
</Tr>
))}
</Table>
</>
)}
<Divider />
<Context>{`Ran at ${new Date().toUTCString()}`}</Context>
</Blocks>
);
}
The JSX here is not React — it's a custom runtime that outputs Slack Block Kit JSON directly. No DOM, no reconciler. The <Table> component converts its children to a GFM markdown block. Everything is synchronous and testable.
Testing your templates
Because templates return plain JSON arrays, assertions are trivial:
import { test, expect } from "@playwright/test";
import { WithTableTemplate } from "@playwright-labs/reporter-slack/templates";
test("masks DB_PASSWORD in table output", () => {
const blocks = WithTableTemplate(failedRun, [], {
env: { DB_PASSWORD: "supersecret", BRANCH: "main" },
});
const tableText = blocks
.filter((b) => b.type === "markdown")
.map((b) => (b as any).text)
.join("\n");
expect(tableText).not.toContain("supersecret");
expect(tableText).toContain("••••••••");
expect(tableText).toContain("main");
});
Installation
pnpm add -D @playwright-labs/reporter-slack @playwright-labs/slack-buildkit
If you've been hand-rolling Slack notifications from CI, give it a try. Feedback and issues welcome.
Top comments (0)