Sending Test Pass/Fail Results to Slack with Webhooks

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

  1. Go to api.slack.com/apps and create a new app (or use an existing one)
  2. In the app settings, go to Incoming Webhooks and enable it
  3. Click Add New Webhook to Workspace and choose your channel
  4. 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 1

The 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.

Read more