Skip to content

Commit a5ecd91

Browse files
committed
Add slack_bolt.testing for unit testing Bolt apps (#380)
1 parent efc36b1 commit a5ecd91

11 files changed

Lines changed: 1180 additions & 0 deletions

File tree

‎docs/english/_sidebar.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
]
7878
},
7979
"tools/bolt-python/concepts/lazy-listeners",
80+
"tools/bolt-python/concepts/testing",
8081
{
8182
"type": "category",
8283
"label": "Adaptors",

‎docs/english/concepts/testing.md‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Testing your app
2+
3+
`slack_bolt.testing` lets you unit test listeners without connecting to Slack. It has two parts:
4+
5+
* `MockSlackServer`: a small local HTTP server that stands in for the Slack Web API and for `response_url`. It records every API call your app makes and every message posted with `respond()`, and answers with canned responses. `auth.test` is answered with a bot installation by default, and every other method returns `{"ok": true}` unless you set a response.
6+
* `BoltTestClient` (and `AsyncBoltTestClient` for `AsyncApp`): builds signed requests for events, messages, slash commands, block actions, shortcuts and view submissions, sends them through your app, and returns what happened.
7+
8+
Requests go through the app's full middleware chain, including request verification, authorization and listener matching. The test client runs listeners (and lazy listeners) to completion before returning, so you can assert on their side effects right away, whatever `process_before_response` is set to.
9+
10+
```python
11+
import pytest
12+
from slack_bolt import App
13+
from slack_bolt.testing import BoltTestClient, MockSlackServer
14+
15+
16+
@pytest.fixture
17+
def server():
18+
with MockSlackServer() as server:
19+
yield server
20+
21+
22+
def create_app(client) -> App:
23+
app = App(client=client, signing_secret="test-secret")
24+
25+
@app.command("/ticket")
26+
def open_ticket(ack, command, respond, client):
27+
ack(f"Creating a ticket for: {command['text']}")
28+
client.chat_postMessage(channel="C_SUPPORT", text=command["text"])
29+
respond("Your ticket was created")
30+
31+
return app
32+
33+
34+
def test_ticket_command(server):
35+
app = create_app(server.web_client())
36+
result = BoltTestClient(app, server).slash_command("/ticket", "printer is on fire")
37+
38+
assert result.status == 200
39+
assert result.body == "Creating a ticket for: printer is on fire"
40+
assert result.calls_to("chat.postMessage")[0].args["channel"] == "C_SUPPORT"
41+
assert result.responds == [{"text": "Your ticket was created"}]
42+
```
43+
44+
## Canned API responses
45+
46+
```python
47+
server.set_response("users.info", {"ok": True, "user": {"id": "U123", "real_name": "Ada"}})
48+
server.set_response("chat.postMessage", {"ok": False, "error": "channel_not_found"})
49+
```
50+
51+
An `"ok": false` response raises `SlackApiError` in your listener, the same as the real API.
52+
53+
## Other request types
54+
55+
```python
56+
tester = BoltTestClient(app, server)
57+
tester.message("hello", user="U123", channel="C123")
58+
tester.event({"type": "app_mention", "channel": "C123", "ts": "1.1", "text": "<@U0> hi"})
59+
tester.block_action("approve_button", value="request-42")
60+
tester.shortcut("open_form")
61+
tester.message_shortcut("save_message", message={"type": "message", "text": "hi", "ts": "1.2"})
62+
tester.view_submission("form", {"title_block": {"title": {"type": "plain_text_input", "value": "Hi"}}})
63+
```
64+
65+
Each method returns a `DispatchResult` with `status`, `body`, `json` (the body parsed as JSON, for example `response_action` errors from a view submission), `api_calls`, `calls_to(method)` and `responds`. The payload builders are also available on their own in `slack_bolt.testing.payloads` if you want to customize a payload and send it with `tester.dispatch(payload)`.
66+
67+
## Apps created at import time
68+
69+
If your app is created at module level with a token from the environment, you can still test it: `BoltTestClient` points the app's Web API client at the mock server. Disable token verification so creating the app doesn't call Slack:
70+
71+
```python
72+
app = App(token=os.environ["SLACK_BOT_TOKEN"], signing_secret=os.environ["SLACK_SIGNING_SECRET"],
73+
token_verification_enabled=False)
74+
```
75+
76+
## AsyncApp
77+
78+
```python
79+
from slack_sdk.web.async_client import AsyncWebClient
80+
from slack_bolt.async_app import AsyncApp
81+
from slack_bolt.testing.async_client import AsyncBoltTestClient
82+
83+
84+
async def test_async_command(server):
85+
app = AsyncApp(client=AsyncWebClient(token="xoxb-test", base_url=server.base_url), signing_secret="test-secret")
86+
87+
@app.command("/hello")
88+
async def hello(ack, respond):
89+
await ack()
90+
await respond("Hi!")
91+
92+
result = await AsyncBoltTestClient(app, server).slash_command("/hello")
93+
assert result.responds == [{"text": "Hi!"}]
94+
```

‎slack_bolt/testing/__init__.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Utilities for testing Bolt apps without connecting to Slack.
2+
3+
`MockSlackServer` stands in for the Slack Web API and `response_url` endpoints and records
4+
what your app sends to them. `BoltTestClient` sends signed requests (events, messages, slash
5+
commands, actions, shortcuts and view submissions) to your app and returns what it did.
6+
7+
from slack_bolt import App
8+
from slack_bolt.testing import BoltTestClient, MockSlackServer
9+
10+
def test_hello_command():
11+
with MockSlackServer() as server:
12+
app = App(client=server.web_client(), signing_secret="test-secret")
13+
14+
@app.command("/hello")
15+
def hello(ack, command, respond):
16+
ack()
17+
respond(f"Hi <@{command['user_id']}>!")
18+
19+
result = BoltTestClient(app, server).slash_command("/hello")
20+
assert result.status == 200
21+
assert result.responds[0]["text"] == "Hi <@U0000000000>!"
22+
23+
For `AsyncApp`, use `slack_bolt.testing.async_client.AsyncBoltTestClient`.
24+
"""
25+
26+
from . import payloads
27+
from .mock_slack_server import APICall, MockSlackServer
28+
from .client import BoltTestClient, DispatchResult, InlineExecutor
29+
30+
__all__ = [
31+
"APICall",
32+
"BoltTestClient",
33+
"DispatchResult",
34+
"InlineExecutor",
35+
"MockSlackServer",
36+
"payloads",
37+
]

‎slack_bolt/testing/async_client.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import asyncio
2+
from typing import Any, Dict, Optional, Sequence
3+
4+
from slack_bolt.app.async_app import AsyncApp
5+
from slack_bolt.request.async_request import AsyncBoltRequest
6+
7+
from . import payloads
8+
from .mock_slack_server import MockSlackServer
9+
from .client import DispatchResult, _BaseTestClient
10+
11+
12+
class AsyncBoltTestClient(_BaseTestClient):
13+
"""Sends signed requests to a Bolt `AsyncApp` in tests and reports what the app did.
14+
15+
This is the `AsyncApp` counterpart of `BoltTestClient`. After the app acknowledges a request,
16+
the client waits for the listener and lazy listener tasks it started, so all of their side
17+
effects are done when a call returns.
18+
"""
19+
20+
def __init__(self, app: AsyncApp, server: MockSlackServer, *, timeout: float = 10.0):
21+
self.app = app
22+
self.server = server
23+
self.signing_secret = app._signing_secret
24+
self.timeout = timeout
25+
app.client.base_url = server.base_url
26+
27+
async def dispatch(self, payload: Dict[str, Any], *, headers: Optional[Dict[str, str]] = None) -> DispatchResult:
28+
"""Dispatches a payload (e.g. built with `slack_bolt.testing.payloads`) to the app."""
29+
body, request_headers = self._prepare(payload, headers)
30+
calls_before = len(self.server.calls)
31+
responds_before = len(self.server.responds)
32+
existing = asyncio.all_tasks()
33+
response = await self.app.async_dispatch(AsyncBoltRequest(body=body, headers=request_headers))
34+
await self._wait_for_started_tasks(existing)
35+
return DispatchResult(
36+
response=response,
37+
api_calls=self.server.calls[calls_before:],
38+
responds=self.server.responds[responds_before:],
39+
)
40+
41+
async def _wait_for_started_tasks(self, existing) -> None:
42+
loop = asyncio.get_event_loop()
43+
deadline = loop.time() + self.timeout
44+
current = asyncio.current_task()
45+
while True:
46+
pending = [t for t in asyncio.all_tasks() if t not in existing and t is not current and not t.done()]
47+
if not pending:
48+
return
49+
remaining = deadline - loop.time()
50+
if remaining <= 0:
51+
raise asyncio.TimeoutError(f"Listener tasks did not finish within {self.timeout} seconds")
52+
await asyncio.wait(pending, timeout=remaining)
53+
54+
async def event(self, event: Dict[str, Any], **kwargs: Any) -> DispatchResult:
55+
"""Sends an Events API request with the given event, e.g. `{"type": "app_mention", ...}`."""
56+
return await self.dispatch(payloads.event(event, **kwargs))
57+
58+
async def message(self, text: str, **kwargs: Any) -> DispatchResult:
59+
"""Sends a `message` event."""
60+
return await self.dispatch(payloads.message(text, **kwargs))
61+
62+
async def slash_command(self, command: str, text: str = "", **kwargs: Any) -> DispatchResult:
63+
"""Sends a slash command. `respond()` calls are recorded in the result."""
64+
return await self.dispatch(payloads.slash_command(command, text, **self._with_response_url(kwargs)))
65+
66+
async def block_action(self, action_id: str, **kwargs: Any) -> DispatchResult:
67+
"""Sends a `block_actions` request. `respond()` calls are recorded in the result."""
68+
return await self.dispatch(payloads.block_action(action_id, **self._with_response_url(kwargs)))
69+
70+
async def shortcut(self, callback_id: str, **kwargs: Any) -> DispatchResult:
71+
"""Sends a global shortcut."""
72+
return await self.dispatch(payloads.shortcut(callback_id, **kwargs))
73+
74+
async def message_shortcut(self, callback_id: str, **kwargs: Any) -> DispatchResult:
75+
"""Sends a message shortcut. `respond()` calls are recorded in the result."""
76+
return await self.dispatch(payloads.message_shortcut(callback_id, **self._with_response_url(kwargs)))
77+
78+
async def view_submission(
79+
self,
80+
callback_id: str,
81+
values: Optional[Dict[str, Dict[str, Any]]] = None,
82+
**kwargs: Any,
83+
) -> DispatchResult:
84+
"""Sends a `view_submission` request for a modal."""
85+
return await self.dispatch(payloads.view_submission(callback_id, values, **kwargs))
86+
87+
88+
__all__: Sequence[str] = ["AsyncBoltTestClient"]

‎slack_bolt/testing/client.py‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import json
2+
from concurrent.futures import Executor, Future
3+
from typing import Any, Callable, Dict, List, Optional, Sequence
4+
5+
from slack_bolt.app import App
6+
from slack_bolt.request import BoltRequest
7+
from slack_bolt.response import BoltResponse
8+
9+
from . import payloads
10+
from .mock_slack_server import APICall, MockSlackServer
11+
12+
13+
class InlineExecutor(Executor):
14+
"""An executor that runs each submitted function immediately in the calling thread."""
15+
16+
def submit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Future: # type: ignore[override]
17+
future: Future = Future()
18+
try:
19+
future.set_result(fn(*args, **kwargs))
20+
except BaseException as e:
21+
future.set_exception(e)
22+
return future
23+
24+
25+
class DispatchResult:
26+
"""The outcome of dispatching one request to a Bolt app in a test."""
27+
28+
response: Optional[BoltResponse]
29+
"""The HTTP response the app returned to Slack, or None if no listener acknowledged the request."""
30+
api_calls: List[APICall]
31+
"""The Web API calls the app made while handling this request."""
32+
responds: List[Dict[str, Any]]
33+
"""The messages the app posted to the request's `response_url` (e.g. with `respond()`)."""
34+
35+
def __init__(
36+
self,
37+
*,
38+
response: Optional[BoltResponse],
39+
api_calls: List[APICall],
40+
responds: List[Dict[str, Any]],
41+
):
42+
self.response = response
43+
self.api_calls = api_calls
44+
self.responds = responds
45+
46+
@property
47+
def status(self) -> Optional[int]:
48+
"""The HTTP status of the response, or None if no listener acknowledged the request."""
49+
return self.response.status if self.response is not None else None
50+
51+
@property
52+
def body(self) -> str:
53+
"""The raw response body, e.g. the text passed to `ack()`."""
54+
return self.response.body if self.response is not None else ""
55+
56+
@property
57+
def json(self) -> Optional[Dict[str, Any]]:
58+
"""The response body parsed as JSON, or None if it is empty or not JSON."""
59+
try:
60+
parsed = json.loads(self.body)
61+
except ValueError:
62+
return None
63+
return parsed if isinstance(parsed, dict) else None
64+
65+
def calls_to(self, method: str) -> List[APICall]:
66+
"""The Web API calls made for the given method while handling this request."""
67+
return [c for c in self.api_calls if c.method == method]
68+
69+
def __repr__(self) -> str:
70+
return f"DispatchResult(status={self.status!r}, body={self.body!r}, api_calls={self.api_calls!r})"
71+
72+
73+
class _BaseTestClient:
74+
server: MockSlackServer
75+
signing_secret: str
76+
77+
def _prepare(self, payload: Dict[str, Any], headers: Optional[Dict[str, str]]):
78+
body = payloads.to_http_body(payload)
79+
request_headers = payloads.signed_headers(body, self.signing_secret)
80+
request_headers.update(headers or {})
81+
return body, request_headers
82+
83+
def _with_response_url(self, kwargs: Dict[str, Any], key: str = "response_url") -> Dict[str, Any]:
84+
kwargs.setdefault(key, self.server.response_url())
85+
return kwargs
86+
87+
88+
class BoltTestClient(_BaseTestClient):
89+
"""Sends signed requests to a Bolt `App` in tests and reports what the app did.
90+
91+
server = MockSlackServer().start()
92+
app = App(client=server.web_client("xoxb-test"), signing_secret="secret")
93+
94+
@app.command("/hello")
95+
def hello(ack, respond):
96+
ack()
97+
respond("Hi!")
98+
99+
result = BoltTestClient(app, server).slash_command("/hello")
100+
assert result.status == 200
101+
assert result.responds == [{"text": "Hi!"}]
102+
103+
Requests run through the app's full middleware chain, including request verification
104+
and authorization. The test client points the app's Web API client at `server`, and runs
105+
listeners and lazy listeners in the calling thread, so all of their side effects are done
106+
when a call returns, even when `process_before_response` is False.
107+
"""
108+
109+
def __init__(self, app: App, server: MockSlackServer):
110+
self.app = app
111+
self.server = server
112+
self.signing_secret = app._signing_secret
113+
app.client.base_url = server.base_url
114+
executor = InlineExecutor()
115+
app.listener_runner.listener_executor = executor
116+
app.listener_runner.lazy_listener_runner.executor = executor # type: ignore[attr-defined]
117+
118+
def dispatch(self, payload: Dict[str, Any], *, headers: Optional[Dict[str, str]] = None) -> DispatchResult:
119+
"""Dispatches a payload (e.g. built with `slack_bolt.testing.payloads`) to the app."""
120+
body, request_headers = self._prepare(payload, headers)
121+
calls_before = len(self.server.calls)
122+
responds_before = len(self.server.responds)
123+
response = self.app.dispatch(BoltRequest(body=body, headers=request_headers))
124+
return DispatchResult(
125+
response=response,
126+
api_calls=self.server.calls[calls_before:],
127+
responds=self.server.responds[responds_before:],
128+
)
129+
130+
def event(self, event: Dict[str, Any], **kwargs: Any) -> DispatchResult:
131+
"""Sends an Events API request with the given event, e.g. `{"type": "app_mention", ...}`."""
132+
return self.dispatch(payloads.event(event, **kwargs))
133+
134+
def message(self, text: str, **kwargs: Any) -> DispatchResult:
135+
"""Sends a `message` event."""
136+
return self.dispatch(payloads.message(text, **kwargs))
137+
138+
def slash_command(self, command: str, text: str = "", **kwargs: Any) -> DispatchResult:
139+
"""Sends a slash command. `respond()` calls are recorded in the result."""
140+
return self.dispatch(payloads.slash_command(command, text, **self._with_response_url(kwargs)))
141+
142+
def block_action(self, action_id: str, **kwargs: Any) -> DispatchResult:
143+
"""Sends a `block_actions` request. `respond()` calls are recorded in the result."""
144+
return self.dispatch(payloads.block_action(action_id, **self._with_response_url(kwargs)))
145+
146+
def shortcut(self, callback_id: str, **kwargs: Any) -> DispatchResult:
147+
"""Sends a global shortcut."""
148+
return self.dispatch(payloads.shortcut(callback_id, **kwargs))
149+
150+
def message_shortcut(self, callback_id: str, **kwargs: Any) -> DispatchResult:
151+
"""Sends a message shortcut. `respond()` calls are recorded in the result."""
152+
return self.dispatch(payloads.message_shortcut(callback_id, **self._with_response_url(kwargs)))
153+
154+
def view_submission(
155+
self,
156+
callback_id: str,
157+
values: Optional[Dict[str, Dict[str, Any]]] = None,
158+
**kwargs: Any,
159+
) -> DispatchResult:
160+
"""Sends a `view_submission` request for a modal."""
161+
return self.dispatch(payloads.view_submission(callback_id, values, **kwargs))
162+
163+
164+
__all__: Sequence[str] = ["BoltTestClient", "DispatchResult", "InlineExecutor"]

0 commit comments

Comments
 (0)