Testing Slack Bot Integrations: Event API, Slash Commands, and Mocks

Testing Slack Bot Integrations: Event API, Slash Commands, and Mocks

Slack bot testing requires handling signed webhooks, validating Block Kit UI components, testing slash command responses, and simulating event payloads. This guide covers unit testing with mock payloads, integration testing with the Slack SDK's built-in test utilities, and end-to-end testing with Socket Mode.

Slack bots have more failure modes than most APIs: request signatures must be verified, events may arrive out of order, slash commands require sub-second responses, and interactive components (buttons, modals) have their own payload formats. Testing all of these correctly is what separates bots that work in demos from bots that work reliably in production.

Slack Request Signature Verification

Every incoming Slack request must be verified using a signature computed from your app's Signing Secret. Test this first:

# app/slack/verification.py
import hashlib
import hmac
import time
import os

def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
    """Verify a Slack request signature."""
    slack_signing_secret = os.environ["SLACK_SIGNING_SECRET"]
    
    # Reject requests older than 5 minutes (replay attack protection)
    if abs(time.time() - float(timestamp)) > 300:
        return False
    
    sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
    computed = "v0=" + hmac.new(
        slack_signing_secret.encode("utf-8"),
        sig_basestring.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(computed, signature)

Test the verification function:

import time
import hmac
import hashlib
import os
from app.slack.verification import verify_slack_signature

def create_slack_signature(body: bytes, secret: str, timestamp: int = None) -> tuple[str, str]:
    timestamp = timestamp or int(time.time())
    sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
    signature = "v0=" + hmac.new(
        secret.encode("utf-8"),
        sig_basestring.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()
    return str(timestamp), signature

SLACK_SIGNING_SECRET = "test_signing_secret_12345"

def test_valid_signature_passes():
    body = b"token=test&channel_id=C1234&user_id=U5678"
    timestamp, signature = create_slack_signature(body, SLACK_SIGNING_SECRET)
    
    with patch.dict(os.environ, {"SLACK_SIGNING_SECRET": SLACK_SIGNING_SECRET}):
        assert verify_slack_signature(body, timestamp, signature) is True

def test_invalid_signature_rejected():
    body = b"token=test&channel_id=C1234"
    timestamp, _ = create_slack_signature(body, SLACK_SIGNING_SECRET)
    
    with patch.dict(os.environ, {"SLACK_SIGNING_SECRET": SLACK_SIGNING_SECRET}):
        assert verify_slack_signature(body, timestamp, "v0=invalid_signature") is False

def test_old_timestamp_rejected():
    body = b"token=test"
    old_timestamp = int(time.time()) - 400  # 6+ minutes ago
    _, signature = create_slack_signature(body, SLACK_SIGNING_SECRET, old_timestamp)
    
    with patch.dict(os.environ, {"SLACK_SIGNING_SECRET": SLACK_SIGNING_SECRET}):
        assert verify_slack_signature(body, str(old_timestamp), signature) is False

Testing Event API Handlers

Slack sends events as signed POST requests. Test your handlers with realistic payloads:

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def make_event_payload(event_type: str, event_data: dict) -> dict:
    """Create a Slack Event API payload."""
    return {
        "token": "test_token",
        "team_id": "T1234567",
        "api_app_id": "A1234567",
        "event": {
            "type": event_type,
            **event_data
        },
        "type": "event_callback",
        "event_id": "Ev1234567",
        "event_time": int(time.time())
    }

def post_slack_event(event_data: dict) -> any:
    """Post a signed Slack event to the webhook endpoint."""
    payload = json.dumps(event_data).encode("utf-8")
    timestamp, signature = create_slack_signature(payload, SLACK_SIGNING_SECRET)
    
    return client.post(
        "/slack/events",
        content=payload,
        headers={
            "Content-Type": "application/json",
            "X-Slack-Request-Timestamp": timestamp,
            "X-Slack-Signature": signature
        }
    )

def test_app_mention_triggers_response():
    """Bot must respond when mentioned in a channel."""
    event = make_event_payload("app_mention", {
        "user": "U1234567",
        "text": "<@U_BOT_ID> help me with deployments",
        "channel": "C1234567",
        "ts": "1234567890.123456"
    })
    
    response = post_slack_event(event)
    assert response.status_code == 200
    
    # Verify bot sent a response message
    from app.slack.tracker import get_sent_messages
    messages = get_sent_messages(channel="C1234567")
    assert len(messages) >= 1
    assert "help" in messages[0]["text"].lower() or "deploy" in messages[0]["text"].lower()

def test_message_event_is_ignored_when_from_bot():
    """Bot must not respond to its own messages (avoid infinite loops)."""
    event = make_event_payload("message", {
        "user": "U_BOT_ID",
        "bot_id": "B1234567",  # This is a bot message
        "text": "I am the bot",
        "channel": "C1234567",
        "ts": "1234567890.123456",
        "subtype": "bot_message"
    })
    
    with patch("app.slack.bot.post_message") as mock_post:
        response = post_slack_event(event)
    
    assert response.status_code == 200
    mock_post.assert_not_called(), "Bot must not respond to its own messages"

def test_reaction_added_event_triggers_ticket_creation():
    """Adding a 🎫 reaction must create a support ticket."""
    event = make_event_payload("reaction_added", {
        "user": "U1234567",
        "reaction": "ticket",
        "item": {
            "type": "message",
            "channel": "C1234567",
            "ts": "1234567890.123456"
        }
    })
    
    response = post_slack_event(event)
    assert response.status_code == 200
    
    from app.tickets import get_tickets
    tickets = get_tickets(source_channel="C1234567", source_ts="1234567890.123456")
    assert len(tickets) == 1

Testing Slash Commands

Slash command handlers must respond within 3 seconds (Slack timeout). Test response time and content:

def create_slash_command_payload(command: str, text: str, user_id: str = "U1234567") -> bytes:
    """Create a Slack slash command payload (URL-encoded form data)."""
    from urllib.parse import urlencode
    data = {
        "token": "test_token",
        "team_id": "T1234567",
        "channel_id": "C1234567",
        "user_id": user_id,
        "command": command,
        "text": text,
        "response_url": "https://hooks.slack.com/commands/test",
        "trigger_id": "trigger.test.12345"
    }
    return urlencode(data).encode("utf-8")

def post_slash_command(command: str, text: str):
    payload = create_slash_command_payload(command, text)
    timestamp, signature = create_slack_signature(payload, SLACK_SIGNING_SECRET)
    
    return client.post(
        "/slack/commands",
        content=payload,
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "X-Slack-Request-Timestamp": timestamp,
            "X-Slack-Signature": signature
        }
    )

def test_deploy_command_returns_immediately():
    """Slash command must respond within 3 seconds even for long-running operations."""
    import time
    
    start = time.time()
    response = post_slash_command("/deploy", "production release-v2.1.0")
    elapsed = time.time() - start
    
    assert response.status_code == 200
    assert elapsed < 3.0, f"Slash command took {elapsed:.1f}s — Slack timeout is 3s"
    
    body = response.json()
    # Immediate response should acknowledge the command
    assert "text" in body or "blocks" in body
    assert "accepted" in body.get("text", "").lower() or \
           "deploying" in body.get("text", "").lower()

def test_deploy_command_validates_environment():
    """Deploy command must reject invalid environment names."""
    response = post_slash_command("/deploy", "invalid-env release-v1.0")
    assert response.status_code == 200
    
    body = response.json()
    # Must return an error, but visible only to the user
    assert body.get("response_type") == "ephemeral", \
        "Error messages should be ephemeral (only visible to user)"
    assert "invalid" in body.get("text", "").lower() or "unknown" in body.get("text", "").lower()

def test_slash_command_requires_permission():
    """Deploy command must check user permissions."""
    response = post_slash_command("/deploy", "production", user_id="U_UNPRIVILEGED")
    body = response.json()
    
    assert body.get("response_type") == "ephemeral"
    assert "permission" in body.get("text", "").lower() or \
           "not allowed" in body.get("text", "").lower()

Testing Block Kit Messages

Block Kit is Slack's UI framework. Test that your blocks render valid JSON:

from app.slack.blocks import create_approval_blocks, create_status_message_blocks

def test_approval_blocks_structure():
    """Approval message blocks must follow Block Kit schema."""
    blocks = create_approval_blocks(
        requester="Alice",
        action="Deploy v2.1.0 to production",
        details={"service": "api", "version": "v2.1.0"}
    )
    
    assert isinstance(blocks, list), "Blocks must be a list"
    assert len(blocks) > 0
    
    # Must have a header section
    header = blocks[0]
    assert header["type"] in ("header", "section")
    
    # Must have action buttons (approve/deny)
    action_blocks = [b for b in blocks if b["type"] == "actions"]
    assert len(action_blocks) >= 1, "Approval must have an actions block"
    
    action_elements = action_blocks[0]["elements"]
    button_values = [e.get("value") for e in action_elements if e.get("type") == "button"]
    assert "approve" in button_values
    assert "deny" in button_values

def test_blocks_fit_within_slack_limits():
    """Slack limits messages to 50 blocks and text to 3000 chars."""
    blocks = create_status_message_blocks(
        tests=[{"name": f"Test {i}", "status": "pass"} for i in range(30)]
    )
    
    assert len(blocks) <= 50, f"Message has {len(blocks)} blocks — Slack limit is 50"
    
    for block in blocks:
        if "text" in block:
            text = block["text"]
            if isinstance(text, dict):
                text = text.get("text", "")
            assert len(text) <= 3000, f"Block text exceeds 3000 chars: {text[:50]}..."

Testing Interactive Components (Buttons, Modals)

When users click buttons or submit modals, Slack sends a payload to your interactivity endpoint:

def create_block_action_payload(action_id: str, value: str, block_id: str = "block_1") -> bytes:
    """Create a Slack block action interaction payload."""
    payload = {
        "type": "block_actions",
        "user": {"id": "U1234567", "name": "alice"},
        "api_app_id": "A1234567",
        "token": "test_token",
        "container": {"type": "message", "message_ts": "1234567890.123456"},
        "team": {"id": "T1234567", "domain": "yourteam"},
        "channel": {"id": "C1234567"},
        "message": {"ts": "1234567890.123456", "blocks": []},
        "actions": [{
            "type": "button",
            "block_id": block_id,
            "action_id": action_id,
            "value": value,
            "action_ts": str(time.time())
        }]
    }
    
    return json.dumps({"payload": json.dumps(payload)}).encode("utf-8")

def test_approve_button_triggers_deployment():
    """Clicking Approve must start the deployment."""
    payload = create_block_action_payload(
        action_id="approve_deploy",
        value=json.dumps({"deployment_id": "dep_123", "service": "api"})
    )
    timestamp, signature = create_slack_signature(payload, SLACK_SIGNING_SECRET)
    
    response = client.post(
        "/slack/interactions",
        content=payload,
        headers={
            "Content-Type": "application/json",
            "X-Slack-Request-Timestamp": timestamp,
            "X-Slack-Signature": signature
        }
    )
    
    assert response.status_code == 200
    
    from app.deployments import get_deployment
    deployment = get_deployment("dep_123")
    assert deployment.status in ("deploying", "queued")

def test_modal_submission_creates_ticket():
    """Submitting the ticket creation modal must create a ticket."""
    modal_payload = {
        "type": "view_submission",
        "team": {"id": "T1234567"},
        "user": {"id": "U1234567"},
        "view": {
            "id": "V1234567",
            "callback_id": "create_ticket_modal",
            "state": {
                "values": {
                    "title_block": {
                        "title_input": {"type": "plain_text_input", "value": "Login button not working"}
                    },
                    "priority_block": {
                        "priority_select": {
                            "type": "static_select",
                            "selected_option": {"value": "high"}
                        }
                    }
                }
            }
        }
    }
    
    payload = json.dumps({"payload": json.dumps(modal_payload)}).encode("utf-8")
    timestamp, signature = create_slack_signature(payload, SLACK_SIGNING_SECRET)
    
    response = client.post(
        "/slack/interactions",
        content=payload,
        headers={
            "Content-Type": "application/json",
            "X-Slack-Request-Timestamp": timestamp,
            "X-Slack-Signature": signature
        }
    )
    
    assert response.status_code == 200
    
    from app.tickets import get_recent_tickets
    tickets = get_recent_tickets(created_by="U1234567")
    assert any(t.title == "Login button not working" for t in tickets)
    assert any(t.priority == "high" for t in tickets)

Testing with Socket Mode

Socket Mode lets your bot receive events without exposing a public HTTP endpoint — ideal for development and testing:

# tests/test_socket_mode.py
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

def test_bot_responds_to_mention_in_socket_mode():
    """Test bot handler in Socket Mode without running a server."""
    from slack_bolt.testing import TestingApp  # Bolt's built-in testing utility
    
    app = App(token=os.environ["SLACK_BOT_TOKEN"], signing_secret=SLACK_SIGNING_SECRET)
    
    # Register your handlers
    @app.event("app_mention")
    def handle_mention(event, say):
        say(f"Hello <@{event['user']}>! How can I help?")
    
    testing_app = TestingApp(app)
    
    # Simulate an app_mention event
    result = testing_app.call_event_handler(
        "app_mention",
        event_body={
            "event": {
                "type": "app_mention",
                "user": "U1234567",
                "text": "<@U_BOT_ID> hello",
                "channel": "C1234567",
                "ts": "1234567890.123456"
            }
        }
    )
    
    assert result is not None

CI/CD Integration

# .github/workflows/slack-bot-tests.yml
name: Slack Bot Tests
on:
  push:
    paths:
      - 'app/slack/**'

jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install slack-bolt fastapi httpx pytest python-dotenv
      - run: pytest tests/slack/ -v
        env:
          SLACK_SIGNING_SECRET: ${{ secrets.SLACK_TEST_SIGNING_SECRET }}
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_TEST_BOT_TOKEN }}

Conclusion

Testing Slack bot integrations requires covering signature verification, event handler logic, slash command responses (including timing), Block Kit structure, and interactive component handling. The most important investment is in unit tests with mock payloads — they're fast, don't require a Slack workspace, and catch 90% of bugs. Add integration tests with a test Slack workspace for the remaining edge cases, and you'll have a Slack bot you can iterate on with confidence.

Read more