Sending Test Pass/Fail Results to Slack with Webhooks
Test results that live only in CI logs are easy to miss. Routing pass/fail summaries to Slack means the right people see failures the moment they happen — not hours later when someone thinks to check the pipeline. Here's how to set it up with webhooks.
Creating a Slack Webhook
- Go to api.slack.com/apps and create a new app (or use an existing one)
- In the app settings, go to Incoming Webhooks and enable it
- Click Add New Webhook to Workspace and choose your channel
- Copy the webhook URL — it looks like
https://hooks.slack.com/services/T.../B.../...
Store this URL as a secret in your CI system, not in code.
Basic Webhook Payload
Slack webhooks accept JSON POSTs:
curl -X POST "$SLACK_WEBHOOK_URL" \
-H <span class="hljs-string">'Content-type: application/json' \
--data <span class="hljs-string">'{
"text": "✅ Tests passed: 47/47 in 2m 34s"
}'For richer messages, use Slack's Block Kit:
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "✅ Test Suite Passed"
}
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Tests:* 47 passed, 0 failed" },
{ "type": "mrkdwn", "text": "*Duration:* 2m 34s" },
{ "type": "mrkdwn", "text": "*Branch:* main" },
{ "type": "mrkdwn", "text": "*Commit:* `a3f91bc`" }
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "View Report" },
"url": "https://github.com/org/repo/actions/runs/12345"
}
]
}
]
}GitHub Actions Integration
The cleanest approach: post to Slack in the always() post-step using the test outcome.
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install and test
id: test
run: |
npm ci
npm test
continue-on-error: true
- name: Notify Slack
if: always()
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
STATUS="${{ steps.test.outcome }}"
if [ "$STATUS" = "success" ]; then
EMOJI="✅"
COLOR="good"
TEXT="Tests passed"
else
EMOJI="❌"
COLOR="danger"
TEXT="Tests failed"
fi
PAYLOAD=$(cat <<EOF
{
"attachments": [{
"color": "$COLOR",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "$EMOJI *$TEXT* on <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|\`${{ github.sha }}\`> (${{ github.ref_name }})"
}
},
{
"type": "actions",
"elements": [{
"type": "button",
"text": { "type": "plain_text", "text": "View Run" },
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}]
}
]
}]
}
EOF
)
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-type: application/json' \
-d "$PAYLOAD"
# Now fail the job if tests failed
- name: Check test outcome
if: steps.test.outcome == 'failure'
run: exit 1The trick: continue-on-error: true lets the Slack step run even on failure, and then the final step re-raises the failure so the job still shows as failed in GitHub.
Rich Failure Messages with Test Counts
Parse JUnit XML to include failure counts:
- name: Parse test results
if: always()
id: parse
run: |
if [ -f "test-results/junit.xml" ]; then
TOTAL=$(python3 -c "import xml.etree.ElementTree as ET; t=ET.parse('test-results/junit.xml').getroot(); print(t.get('tests', 0))")
FAILURES=$(python3 -c "import xml.etree.ElementTree as ET; t=ET.parse('test-results/junit.xml').getroot(); print(t.get('failures', 0))")
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "failures=$FAILURES" >> $GITHUB_OUTPUT
else
echo "total=unknown" >> $GITHUB_OUTPUT
echo "failures=unknown" >> $GITHUB_OUTPUT
fi
- name: Notify Slack with counts
if: always()
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
run: |
curl -X POST "$SLACK_WEBHOOK" -H 'Content-type: application/json' -d '{
"text": "${{ steps.test.outcome == 'success' && '✅' || '❌' }} Tests: ${{ steps.parse.outputs.total }} total, ${{ steps.parse.outputs.failures }} failed — ${{ github.repository }} (${{ github.ref_name }})"
}'Jest Custom Reporter
For more control, write a Jest reporter that posts to Slack directly:
// reporters/slack-reporter.js
const https = require('https');
const url = require('url');
class SlackReporter {
constructor(globalConfig, options) {
this._webhookUrl = process.env.SLACK_WEBHOOK_URL;
this._channel = options?.channel;
}
onRunComplete(contexts, results) {
if (!this._webhookUrl) return;
const { numPassedTests, numFailedTests, numTotalTests, testResults } = results;
const status = numFailedTests === 0 ? 'passed' : 'failed';
const emoji = numFailedTests === 0 ? '✅' : '❌';
const failedTests = testResults
.flatMap(suite => suite.testResults.filter(t => t.status === 'failed'))
.slice(0, 5)
.map(t => `• ${t.ancestorTitles.join(' > ')} > ${t.title}`)
.join('\n');
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *Jest suite ${status}*\n${numPassedTests}/${numTotalTests} tests passed`,
},
},
];
if (failedTests) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Failed tests:*\n${failedTests}`,
},
});
}
this._post({ blocks });
}
_post(payload) {
const data = JSON.stringify(payload);
const parsed = url.parse(this._webhookUrl);
const req = https.request({
hostname: parsed.hostname,
path: parsed.path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length,
},
});
req.write(data);
req.end();
}
}
module.exports = SlackReporter;{
"jest": {
"reporters": [
"default",
["./reporters/slack-reporter.js", { "channel": "#test-alerts" }]
]
}
}Pytest Integration
# conftest.py
import pytest
import requests
import os
def pytest_terminal_summary(terminalreporter, exitstatus, config):
webhook_url = os.environ.get('SLACK_WEBHOOK_URL')
if not webhook_url:
return
stats = terminalreporter.stats
passed = len(stats.get('passed', []))
failed = len(stats.get('failed', []))
total = passed + failed
if failed == 0:
emoji = '✅'
text = f'{emoji} pytest passed: {passed}/{total}'
else:
emoji = '❌'
failed_names = [f'• {r.nodeid}' for r in stats.get('failed', [])[:5]]
text = f'{emoji} pytest failed: {failed}/{total}\n' + '\n'.join(failed_names)
requests.post(webhook_url, json={'text': text}, timeout=5)Reducing Noise: Only Notify on Failure
For large teams, constant "all green" messages add noise. Notify only on failure and on recovery:
- name: Notify on failure
if: steps.test.outcome == 'failure'
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
-d '{"text":"❌ Tests failed on ${{ github.ref_name }} — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View>"}'
- name: Notify on recovery
if: steps.test.outcome == 'success' && github.event_name == 'push'
# Only useful if you track "was previously failing" — requires external state
run: echo "Consider adding recovery notification here"For recovery notifications (tests went from red to green), you'd need to store the previous build status somewhere — a simple approach is checking the previous commit's workflow run status via the GitHub API.
Beyond Notifications: Continuous Monitoring
Slack notifications from CI catch regressions in deployed code only when you push. For 24/7 monitoring — detecting when something breaks in production between deployments — HelpMeTest runs your tests on a schedule and sends Slack alerts when they fail. It's the complement to CI notifications: CI catches regressions at merge time, HelpMeTest catches production drift between merges.
Summary
The minimal setup: create a Slack webhook, store it as a CI secret, and POST JSON to it in an always() step after your tests run. Use continue-on-error: true to ensure the notification step runs even when tests fail. For richer messages, parse JUnit XML for counts and attach Block Kit formatting. For custom reporters, implement the runner's reporter interface and call the webhook in the completion callback.