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 FalseTesting 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) == 1Testing 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 NoneCI/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.