Skip to content

Test utilities

ChatTestCase

Base class for testing handlers against a mocked signal-cli-rest-api.

Call setup() from a fixture, register handlers on self.signal_bot and decorate test methods with mock_chat. Messages come from the test group (group_id, group_name) unless built with one of the private new_* classmethods.

download_attachment_mock instance-attribute

download_attachment_mock: AsyncMock

Stub for downloading received attachments; returns empty base64 content.

react_mock instance-attribute

react_mock: ReactMock

Stub for sending reactions.

receipts_mock instance-attribute

receipts_mock: AsyncMock

Stub for sending read/viewed receipts; called with a Receipt request.

remote_delete_mock instance-attribute

remote_delete_mock: RemoteDeleteMock

Stub for deleting sent messages (bot.messages.remote_delete).

send_mock instance-attribute

send_mock: SendMock

Stub for sending messages (bot.messages.send, context.send, ...).

signal_bot instance-attribute

signal_bot: SignalBot

The bot mock_chat feeds messages to; set by setup().

start_typing_mock instance-attribute

start_typing_mock: AsyncMock

Stub for showing the typing indicator.

stop_typing_mock instance-attribute

stop_typing_mock: AsyncMock

Stub for hiding the typing indicator.

make_bot

make_bot() -> SignalBot

Build the bot under test; called by setup().

Override it when the bot is built by your own code, e.g. an application object that creates its SignalBot internally, and return that SignalBot (keeping a reference to the wrapper on self if tests need it). Use self.config for a configuration pointing at the mocked service. Assigning self.signal_bot directly works too.

Source code in src/signalbot/test_utils/chat_testing.py
def make_bot(self) -> SignalBot:
    """Build the bot under test; called by `setup()`.

    Override it when the bot is built by your own code, e.g. an application
    object that creates its `SignalBot` internally, and return that
    `SignalBot` (keeping a reference to the wrapper on `self` if tests need
    it). Use `self.config` for a configuration pointing at the mocked
    service. Assigning `self.signal_bot` directly works too.
    """
    return SignalBot(ChatTestCase.config)

new_edit_message classmethod

new_edit_message(
    text: str,
    *,
    source_uuid: str,
    target_sent_timestamp: int,
    timestamp: int | None = None
) -> Envelope

A direct message from source_uuid editing their earlier message sent at target_sent_timestamp; it is received as an EditMessage by DataMessageHandlers.

Parameters:

Name Type Description Default
text str

The new text of the message.

required
source_uuid str

The sender's uuid, i.e. the author of the edited message.

required
target_sent_timestamp int

The timestamp of the message being edited.

required
timestamp int | None

The timestamp of the edit; a fresh, unique one if None.

None
Source code in src/signalbot/test_utils/chat_testing.py
@classmethod
def new_edit_message(
    cls,
    text: str,
    *,
    source_uuid: str,
    target_sent_timestamp: int,
    timestamp: int | None = None,
) -> Envelope:
    """A direct message from `source_uuid` editing their earlier message sent
    at `target_sent_timestamp`; it is received as an
    [EditMessage][signalbot.messages.EditMessage] by `DataMessageHandler`s.

    Args:
        text: The new text of the message.
        source_uuid: The sender's uuid, i.e. the author of the edited message.
        target_sent_timestamp: The timestamp of the message being edited.
        timestamp: The timestamp of the edit; a fresh, unique one if `None`.
    """
    if timestamp is None:
        timestamp = _new_timestamp()
    return cls._received_envelope(
        source_uuid=source_uuid,
        timestamp=timestamp,
        editMessage={
            "targetSentTimestamp": target_sent_timestamp,
            "dataMessage": {
                "timestamp": timestamp,
                "message": text,
                "expiresInSeconds": 0,
                "viewOnce": False,
            },
        },
    )

new_message classmethod

new_message(text: str) -> Envelope

A text message sent in the test group (ChatTestCase.group_id) by the bot's own account.

This is what mock_chat turns plain str messages into.

Source code in src/signalbot/test_utils/chat_testing.py
@classmethod
def new_message(cls, text: str) -> Envelope:
    """A text message sent in the test group (`ChatTestCase.group_id`) by the
    bot's own account.

    This is what `mock_chat` turns plain `str` messages into.
    """
    timestamp = _new_timestamp()
    new_uuid = str(uuid.uuid4())
    return cls._sent_message_envelope(
        timestamp=timestamp, new_uuid=new_uuid, message=text
    )

new_private_message classmethod

new_private_message(
    text: str | None,
    *,
    source_uuid: str | None = None,
    timestamp: int | None = None,
    quote: dict | None = None,
    attachments: list[dict] | None = None,
    view_once: bool = False
) -> Envelope

A direct (one-on-one) message to the bot, so message.is_private() is true and it reaches handlers registered with groups=False.

Parameters:

Name Type Description Default
text str | None

The message text.

required
source_uuid str | None

The sender's uuid; a random uuid4 if None.

None
timestamp int | None

The message timestamp in milliseconds; a fresh, unique one if None. Pass it explicitly to refer to this message later (e.g. from new_edit_message or new_remote_delete).

None
quote dict | None

The quoted message in signal-cli's raw format, e.g. {"id": <quoted timestamp>, "author": ..., "authorUuid": ..., "text": ...}.

None
attachments list[dict] | None

Attachments in signal-cli's raw format, e.g. {"id": ..., "contentType": "image/png", "isVoiceNote": False}.

None
view_once bool

Whether the message is a view-once message.

False
Source code in src/signalbot/test_utils/chat_testing.py
@classmethod
def new_private_message(  # noqa: PLR0913
    cls,
    text: str | None,
    *,
    source_uuid: str | None = None,
    timestamp: int | None = None,
    quote: dict | None = None,
    attachments: list[dict] | None = None,
    view_once: bool = False,
) -> Envelope:
    """A direct (one-on-one) message to the bot, so `message.is_private()` is
    true and it reaches handlers registered with `groups=False`.

    Args:
        text: The message text.
        source_uuid: The sender's uuid; a random uuid4 if `None`.
        timestamp: The message timestamp in milliseconds; a fresh, unique one
            if `None`. Pass it explicitly to refer to this message later
            (e.g. from `new_edit_message` or `new_remote_delete`).
        quote: The quoted message in signal-cli's raw format, e.g.
            `{"id": <quoted timestamp>, "author": ..., "authorUuid": ...,
            "text": ...}`.
        attachments: Attachments in signal-cli's raw format, e.g.
            `{"id": ..., "contentType": "image/png", "isVoiceNote": False}`.
        view_once: Whether the message is a view-once message.
    """
    if timestamp is None:
        timestamp = _new_timestamp()
    data_message: dict[str, object] = {
        "timestamp": timestamp,
        "message": text,
        "expiresInSeconds": 0,
        "viewOnce": view_once,
    }
    if quote is not None:
        data_message["quote"] = quote
    if attachments is not None:
        data_message["attachments"] = attachments
    return cls._received_envelope(
        source_uuid=source_uuid or str(uuid.uuid4()),
        timestamp=timestamp,
        dataMessage=data_message,
    )

new_reaction_message classmethod

new_reaction_message(emoji: str) -> Envelope

A reaction with emoji, sent in the test group by the bot's own account.

Source code in src/signalbot/test_utils/chat_testing.py
@classmethod
def new_reaction_message(cls, emoji: str) -> Envelope:
    """A reaction with `emoji`, sent in the test group by the bot's own
    account."""
    timestamp = _new_timestamp()
    new_uuid = str(uuid.uuid4())
    return cls._sent_message_envelope(
        timestamp=timestamp,
        new_uuid=new_uuid,
        message=None,
        reaction={
            "emoji": emoji,
            "targetAuthor": ChatTestCase.phone_number,
            "targetAuthorNumber": ChatTestCase.phone_number,
            "targetAuthorUuid": new_uuid,
            "targetSentTimestamp": timestamp,
            "isRemove": False,
        },
    )

new_remote_delete classmethod

new_remote_delete(
    *,
    source_uuid: str,
    target_sent_timestamp: int,
    timestamp: int | None = None
) -> Envelope

A direct message from source_uuid deleting their earlier message sent at target_sent_timestamp; it is received by RemoteDeleteHandlers as a RemoteDelete whose timestamp is target_sent_timestamp.

Parameters:

Name Type Description Default
source_uuid str

The sender's uuid, i.e. the author of the deleted message.

required
target_sent_timestamp int

The timestamp of the message being deleted.

required
timestamp int | None

The timestamp of the delete itself; a fresh, unique one if None.

None
Source code in src/signalbot/test_utils/chat_testing.py
@classmethod
def new_remote_delete(
    cls,
    *,
    source_uuid: str,
    target_sent_timestamp: int,
    timestamp: int | None = None,
) -> Envelope:
    """A direct message from `source_uuid` deleting their earlier message sent
    at `target_sent_timestamp`; it is received by `RemoteDeleteHandler`s as a
    [RemoteDelete][signalbot.messages.RemoteDelete] whose `timestamp` is
    `target_sent_timestamp`.

    Args:
        source_uuid: The sender's uuid, i.e. the author of the deleted message.
        target_sent_timestamp: The timestamp of the message being deleted.
        timestamp: The timestamp of the delete itself; a fresh, unique one if
            `None`.
    """
    if timestamp is None:
        timestamp = _new_timestamp()
    return cls._received_envelope(
        source_uuid=source_uuid,
        timestamp=timestamp,
        dataMessage={
            "timestamp": timestamp,
            "expiresInSeconds": 0,
            "viewOnce": False,
            "remoteDelete": {"timestamp": target_sent_timestamp},
        },
    )

setup

setup() -> None

Create the bot under test as self.signal_bot (see make_bot).

Call it from a fixture, then register handlers on self.signal_bot.

Source code in src/signalbot/test_utils/chat_testing.py
def setup(self) -> None:
    """Create the bot under test as `self.signal_bot` (see `make_bot`).

    Call it from a fixture, then register handlers on `self.signal_bot`.
    """
    self.signal_bot = self.make_bot()

Envelope

Bases: str

A raw signal-cli-rest-api JSON envelope, exactly as received over the websocket.

mock_chat and ReceiveMock.define feed an Envelope to the bot untouched, whereas a plain str is treated as the text of a group message and wrapped with ChatTestCase.new_message. Build envelopes with the ChatTestCase.new_* classmethods, or wrap your own JSON with Envelope(json.dumps(...)).

SendMock

SendMock(**kwargs: object)

Bases: _FirstArgResultsMock

Stub for sending messages.

Each call returns one response per recipient, each with a fresh, strictly increasing timestamp (FIRST_TIMESTAMP, then +1 per response), so the SentMessages a handler gets back can be told apart, e.g. to match later edits, remote deletes or quotes. Setting return_value explicitly overrides this.

Source code in src/signalbot/test_utils/chat_testing.py
def __init__(self, **kwargs: object) -> None:
    super().__init__(**kwargs)
    self._timestamps = itertools.count(self.FIRST_TIMESTAMP)
    self.side_effect = self._respond

results

results() -> list[SendMessageV2]

The message request passed to each send, in call order.

Source code in src/signalbot/test_utils/chat_testing.py
def results(self) -> list[SendMessageV2]:
    """The message request passed to each send, in call order."""
    return self._first_args()

ReactMock

Bases: _FirstArgResultsMock

Stub for sending reactions.

results

results() -> list[SendReactionRequest]

The reaction request passed to each call, in call order.

Source code in src/signalbot/test_utils/chat_testing.py
def results(self) -> list[SendReactionRequest]:
    """The reaction request passed to each call, in call order."""
    return self._first_args()

RemoteDeleteMock

RemoteDeleteMock(**kwargs: object)

Bases: _FirstArgResultsMock

Stub for deleting sent messages.

Each call returns a fresh, strictly increasing timestamp (FIRST_TIMESTAMP, then +1 per call). Setting return_value explicitly overrides this.

Source code in src/signalbot/test_utils/chat_testing.py
def __init__(self, **kwargs: object) -> None:
    super().__init__(**kwargs)
    self._timestamps = itertools.count(self.FIRST_TIMESTAMP)
    self.side_effect = self._respond

results

results() -> list[RemoteDeleteRequest]

The delete request passed to each call, in call order.

Source code in src/signalbot/test_utils/chat_testing.py
def results(self) -> list[RemoteDeleteRequest]:
    """The delete request passed to each call, in call order."""
    return self._first_args()

ReceiveMock

Bases: MagicMock

define

define(messages: Sequence[str]) -> None

Make receive() yield messages: an Envelope is yielded as-is, a plain str is wrapped with ChatTestCase.new_message.

Source code in src/signalbot/test_utils/chat_testing.py
def define(self, messages: Sequence[str]) -> None:
    """Make `receive()` yield `messages`: an
    [Envelope][signalbot.test_utils.Envelope] is yielded as-is, a plain `str`
    is wrapped with `ChatTestCase.new_message`."""
    json_messages = [
        m if isinstance(m, Envelope) else ChatTestCase.new_message(m)
        for m in messages
    ]
    mock_iterator = AsyncMock()
    mock_iterator.__aiter__.return_value = json_messages
    self.return_value = mock_iterator

mock_chat

mock_chat(
    *messages: str,
) -> Callable[[AsyncTestMethod], AsyncTestMethod]

Run the bot on messages before the decorated ChatTestCase test runs.

Each message is either an Envelope, fed to the bot as-is, or a plain str, which becomes a group text message (see ChatTestCase.new_message).

Before the test body runs, the signal-cli-rest-api calls are stubbed (see the *_mock attributes on ChatTestCase), the bot is initialised (ready handlers run, but no background producer/consumer tasks are started) and every message is dispatched to the registered handlers, which run to completion one at a time. The test body then inspects the stubs, e.g. self.send_mock.results().

Source code in src/signalbot/test_utils/chat_testing.py
def mock_chat(*messages: str) -> Callable[[AsyncTestMethod], AsyncTestMethod]:
    """Run the bot on `messages` before the decorated `ChatTestCase` test runs.

    Each message is either an [Envelope][signalbot.test_utils.Envelope], fed to
    the bot as-is, or a plain `str`, which becomes a group text message (see
    [ChatTestCase.new_message][signalbot.test_utils.ChatTestCase.new_message]).

    Before the test body runs, the signal-cli-rest-api calls are stubbed (see the
    `*_mock` attributes on `ChatTestCase`), the bot
    is initialised (ready handlers run, but no background producer/consumer
    tasks are started) and every message is dispatched to the registered
    handlers, which run to completion one at a time. The test body then inspects
    the stubs, e.g. `self.send_mock.results()`.
    """

    def decorator_chat(func: AsyncTestMethod) -> AsyncTestMethod:
        @functools.wraps(func)
        async def wrapper_chat(
            self: ChatTestCase,
            mocker: MockerFixture,
            *args: object,
            **kwargs: object,
        ) -> None:
            self.react_mock = mocker.patch(
                "signalbot._client.reactions.ReactionsClient.react",
                new_callable=ReactMock,
            )
            self.send_mock = mocker.patch(
                "signalbot._client.messages.MessagesClient.send",
                new_callable=SendMock,
            )
            receive_mock = mocker.patch(
                "signalbot._client.messages.MessagesClient.receive",
                new_callable=ReceiveMock,
            )
            mocker.patch(
                "signalbot._client.groups.GroupsClient.get_all",
                new_callable=GetAllMock,
            )
            mocker.patch(
                "signalbot._client.general.GeneralClient.about",
                new_callable=AboutMock,
            )
            mocker.patch(
                "signalbot._client.SignalAPI.check_signal_service",
                new_callable=CheckSignalServiceMock,
            )
            self.receipts_mock = mocker.patch(
                "signalbot._client.receipts.ReceiptsClient.send",
                new_callable=AsyncMock,
            )
            self.start_typing_mock = mocker.patch(
                "signalbot._client.messages.MessagesClient.start_typing",
                new_callable=AsyncMock,
            )
            self.stop_typing_mock = mocker.patch(
                "signalbot._client.messages.MessagesClient.stop_typing",
                new_callable=AsyncMock,
            )
            self.remote_delete_mock = mocker.patch(
                "signalbot._client.messages.MessagesClient.remote_delete",
                new_callable=RemoteDeleteMock,
            )
            self.download_attachment_mock = mocker.patch(
                "signalbot._client.attachments.AttachmentsClient.download",
                new_callable=AsyncMock,
                return_value="",
            )

            receive_mock.define(messages)
            await self.signal_bot._async_init()
            await self.run_bot()

            return await func(self, mocker, *args, **kwargs)

        return wrapper_chat

    return decorator_chat