DEV Community

Mark Gray
Mark Gray

Posted on

The definitive guide to testing Slack bots (and why it's been so hard)

Before we get into solutions, here's a quote from a developer trying to write tests for their Slack bot:

"I had to patch urllib.request.urlopen calls, since the Slack SDK uses that under the hood. It's a little nasty."

If you've tried to write tests for a Slack bot, you felt that immediately. If you haven't tried yet, you will.

This guide covers everything: why testing Slack bots has been so painful, what the options are, and how to actually do it properly in 2026.


*Why testing Slack bots is uniquely painful
*

Most APIs fail loudly. Send a bad request, get an error back. Slack does something worse.

Slack's API returns 200 OK even when your Block Kit blocks are completely invalid. The metadata gets silently dropped. Your message appears as plain text with no error, no warning, nothing. The only way to discover this is to deploy your bot and check a real Slack channel.

Every iteration requires a live API call. Every bug requires a real workspace. Every developer on your team needs their own test bot. It's slow, it's fragile, and it doesn't scale.
The official Bolt SDK has had an open GitHub issue (#638) requesting testing support since September 2020. A Slack maintainer proposed a solution in 2024. As of April 2026 it still hasn't shipped.

Slack built their own testing tool called Steno in 2017, archived it in June 2022 with a "stay tuned for updated testing tools" promise, and shipped nothing for four years.

This is not a niche problem. Stack Overflow has a question titled "How can I monitor, test and view the DMs my Slack bot is sending?" with 72,000 views and no satisfactory answer.


*What developers have been doing instead
*

Before we get to the right solution, here's what the community has been cobbling together:

Duplicate test bots with ngrok
Create a second bot with the same manifest, run ngrok to expose a local endpoint, have every developer on the team replace their endpoint URL. Fragile, annoying, and requires a live connection for every test run.

Patching low-level HTTP internals

# The nasty workaround
import unittest.mock
with unittest.mock.patch('urllib.request.urlopen') as mock_urlopen:
    mock_urlopen.return_value = ...
    # now you can test
Enter fullscreen mode Exit fullscreen mode

This works but it's brittle, tightly coupled to SDK internals, and breaks when the SDK updates.

Using the real API in tests
Some teams just accept the slowness and make real API calls in their test suite. This means tests require network access, a real Slack workspace, valid tokens in CI, and they're slow. A test suite that takes 30 seconds locally takes 3 minutes in CI.

Driving the Slack web client with Cypress
One team documented using Cypress to drive the actual Slack web UI in a browser as a testing strategy. It works but it's so fragile it barely counts.


*The right approach: two layers of testing
*

A proper Slack bot testing strategy has two layers.

Layer 1 — Unit tests (no Slack API)
Test your handler logic in complete isolation. No network calls, no real workspace, instant feedback. This is where you catch the majority of bugs.

Layer 2 — E2E tests (real Slack API)
Test that your deployed bot actually behaves correctly end-to-end. Slower, requires real credentials, but catches integration issues that unit tests miss.
Most teams only need Layer 1 for day-to-day development. Layer 2 is for CI on your main branch.

This guide focuses on Layer 1 - the layer that's been missing.


*Unit testing Slack bots with botlint-slack
*

botlint-slack is an npm package that provides two things:

Offline Block Kit validation — catch invalid blocks before they reach Slack
Mock Slack client — test your handlers without real API calls

npm install botlint-slack --save-dev

Block Kit validation

const { validate } = require('botlint-slack');

const blocks = [
  {
    type: 'header',
    text: {
      type: 'plain_text',
      text: 'This header is way too long and will be silently dropped by Slack because it exceeds the 150 character limit',
    },
  },
];

const result = validate(blocks);
console.log(result);
// {
//   valid: false,
//   errors: [
//     'block[0].text exceeds 150 character limit (current: 111 chars)'
//   ]
// }
Enter fullscreen mode Exit fullscreen mode

This catches the silent failure problem entirely. Run validation in your tests and you'll know immediately when a block is invalid — with a specific error message telling you exactly what's wrong and where.

It works with arrays of blocks, single blocks, modal views, and home tab views:

// Single block
validate({ type: 'section', text: { type: 'mrkdwn', text: 'Hello' } });
// { valid: true, errors: [] }

// Modal view
validate({
  type: 'modal',
  title: { type: 'plain_text', text: 'My Modal' },
  callback_id: 'my_modal',
  blocks: [...]
});
Enter fullscreen mode Exit fullscreen mode

Mock Slack client
The mock client is a drop-in replacement for app.client in your Bolt handlers. Pass it into your handler in tests, then assert on what it was called with.

const { createMockClient } = require('botlint-slack');

// The handler you want to test
async function handleApprovalRequest(client, channelId, requestId) {
  await client.chat.postMessage({
    channel: channelId,
    text: `Request ${requestId} has been approved`,
    blocks: [
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `*Request ${requestId} approved* ✓` }
      }
    ]
  });
}

// The test
test('posts approval message to correct channel', async () => {
  const mock = createMockClient();

  await handleApprovalRequest(mock.client, 'C123ABC', 'REQ-456');

  const post = mock.lastCall('chat.postMessage');
  expect(post.channel).toBe('C123ABC');
  expect(post.text).toContain('REQ-456');
  expect(post.blocks).toHaveLength(1);
});
Enter fullscreen mode Exit fullscreen mode

No network calls. No real workspace. No tokens. Runs in milliseconds.

Jest matchers
If you're using Jest, botlint-slack includes custom matchers that make assertions cleaner:
jest.setup.js

const setupMatchers = require('botlint-slack/jest');
setupMatchers();
Enter fullscreen mode Exit fullscreen mode

jest.config.js

module.exports = {
  setupFilesAfterEnv: ['./jest.setup.js']
};
Enter fullscreen mode Exit fullscreen mode

Then in your tests:

// Assert blocks are valid
expect(blocks).toBeValidSlackBlocks();

// Assert modal is valid
expect(modal).toBeValidSlackModal();

// Assert on mock calls
expect(mock.lastCall('chat.postMessage')).toHavePostedTo('C123');
expect(mock.lastCall('chat.postMessage')).toHaveText('approved');
expect(mock.lastCall('chat.postMessage')).toHaveBlocks();
Enter fullscreen mode Exit fullscreen mode

**What gets validated
**botlint-slack validates all core Block Kit block types against Slack's actual constraints:

Block-----Key Limits
section - text ≤ 3000 chars, fields ≤ 10 items
actions - 1–5 elements, button text ≤ 75 chars
context - 1–10 elements, text ≤ 2000 chars
header - text ≤ 150 chars, plain_text only
image - image_url and alt_text required
input - label and element required
modal - title ≤ 24 chars, callback_id required
video. - title, video_url, thumbnail_url required

All block types also validate block_id uniqueness across the blocks array.

*Why not just use TypeScript types?
*

TypeScript types validate structure at compile time. They don't validate runtime data, string length limits, or conditional constraints.
A header block with a 200-character title satisfies the TypeScript type { type: 'header', text: { type: 'plain_text', text: string } }perfectly. It will still be silently dropped by Slack. If that title comes from a database or user input, no type checker will catch it.
botlint-slack catches what TypeScript can't.


A complete example

Here's a realistic Bolt handler with a full test suite:

// handler.js
async function handleStatusCommand({ client, body, ack }) {
  await ack();

  const userId = body.user_id;
  const channelId = body.channel_id;

  const userInfo = await client.users.info({ user: userId });

  await client.chat.postMessage({
    channel: channelId,
    text: `Status update for ${userInfo.user.name}`,
    blocks: [
      {
        type: 'header',
        text: { type: 'plain_text', text: 'Current Status' }
      },
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `*User:* ${userInfo.user.name}\n*Status:* Active` }
      },
      {
        type: 'actions',
        elements: [
          {
            type: 'button',
            text: { type: 'plain_text', text: 'Update Status' },
            action_id: 'update_status',
            style: 'primary'
          }
        ]
      }
    ]
  });
}

module.exports = { handleStatusCommand };
Enter fullscreen mode Exit fullscreen mode
// handler.test.js
const { createMockClient, validate } = require('botlint-slack');
const { handleStatusCommand } = require('./handler');

describe('handleStatusCommand', () => {
  let mock;

  beforeEach(() => {
    mock = createMockClient();
  });

  test('posts status message to correct channel', async () => {
    const ack = jest.fn();

    await handleStatusCommand({
      client: mock.client,
      body: { user_id: 'U123', channel_id: 'C456' },
      ack
    });

    expect(ack).toHaveBeenCalled();

    const post = mock.lastCall('chat.postMessage');
    expect(post.channel).toBe('C456');
    expect(post.text).toContain('testuser');
  });

  test('blocks are valid Slack Block Kit', async () => {
    const ack = jest.fn();

    await handleStatusCommand({
      client: mock.client,
      body: { user_id: 'U123', channel_id: 'C456' },
      ack
    });

    const post = mock.lastCall('chat.postMessage');
    const result = validate(post.blocks);
    expect(result.valid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });

  test('fetches user info before posting', async () => {
    const ack = jest.fn();

    await handleStatusCommand({
      client: mock.client,
      body: { user_id: 'U123', channel_id: 'C456' },
      ack
    });

    expect(mock.calls['users.info']).toHaveLength(1);
    expect(mock.calls['users.info'][0].user).toBe('U123');
  });
});
Enter fullscreen mode Exit fullscreen mode

What's next

botlint-slack is open source and actively maintained. The roadmap:

V2 — E2E testing
Send real messages to a test channel, assert on real responses, simulate button clicks and modal interactions. For teams that need to test their deployed bot end-to-end.

V3 — Cloud runner
Run your E2E tests in CI without managing credentials locally. GitHub Actions integration, test history, badges.

If you're building a Slack bot and find this useful, give it a star on GitHub or drop a comment below. Bug reports and feedback welcome.

Top comments (0)