Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
14
Help us understand the problem. What is going on with this article?
@coolwind0202

Discord.py テスト方法の模索

Pythonで記述された Discord API ラッパーである Discord.py は、絶大な人気を博すライブラリですが、一方でそのテストについては確立された「ベストプラクティス」が存在しないように思います。

この記事では、手作業によるテストと、自動化されたテストのメリット・デメリットを比較します。

注釈
本稿で取り扱うテストは、結合テストになります。
具体的には、イベントが発生したとき、Botは正しく対応できるか?ということを試すブラックボックステストです。

また、本記事における方法の評価は私の主観に基づくものになることをご容赦ください。

※ 記事中にミスなどありましたらコメント欄までご連絡ください

普通にテスト運用する

方法

テスト用のBOTアカウント、及びテスト用のDiscordサーバーを作成します。
そのあと、スクリプトをテスト用Botのトークンを使って実行し、テスト用のBOTアカウントが想定通りに動くかを検証します。

メリット

  • DiscordサーバーとBotアカウントさえ用意できればすぐに始められる
  • 柔軟である
  • 実際のDiscordの挙動を確認しながらテストできる

デメリット

  • Discordサーバーの状態に依存するので外部から依存するデータを注入することはできない
  • テストしたい項目が増えるほどテスト時の疲労が増大する

テストライブラリを使用する

本記事を書いた目的の一つです。
自動テストによって、「普通にテスト運用する」場合のデメリットを解消できないでしょうか。

1. dpytest

「Discord.py test」と検索して、最も上位にヒットしたPyPIパッケージです。

  • 更新頻度 ⭕
  • ドキュメントの詳細度 🔺 (Factory系のメソッドは、見出しだけ存在するものの全く文章が書かれていないような状態)
  • 利便性 ⭕ (メッセージ内容のテストはメソッドチェーンでシンプルに書ける)

dispatch() の呼び出しやWebSocket通信の模倣、フェイクのDiscordモデルの提供などにより、Botを実行した際の挙動を再現してテストを行います。
テストを書くときは「メッセージの送信」「メンバーの参加」などのイベントを擬似的に発生させ、各イベントリスナー呼び出しのトリガーとします。

2. distest

  • 更新頻度 🔺
  • ドキュメントの詳細度 ⭕ (サンプルが掲載されており、メソッドの説明も十分)
  • 利便性 ⭕ (様々な状況に応じた検証用メソッドが用意されている)

こちらは、Botアカウントを2つ用意し、それぞれに決まった動作を行わせることによって、正しく処理されるかをテストします。
ゆえに、テスト時には Discord への接続が必要です。

実際にテストしてみる

環境構築

  • Windows10
  • Python3.8
  • Discord.py 1.7.2

任意の場所に bot_test フォルダを作成し、 cd で移動。
普段 pipenv を使ってパッケージなど管理しているので、この記事でも pipenv を利用します。

$ git init
$ pipenv install --python 3.8
$ pipenv shell
$ pipenv install discord.py
$ pipenv install dpytest
$ pipenv install distest
$ mkdir cogs

以下のようにフォルダを構成します。

main.py
bot.py
Pipfile
Pipfile.lock
cogs
  -  level_one.py
  -  level_two.py
  -  level_three.py
.gitignore

テストには次項のコードを使用します。

Botの初期設定

コグファイルとして、 level_one.py level_two.py を読み込みます。

main.py
from bot import MyTestableBot

bot = MyTestableBot(command_prefix="-")
bot.run("TOKEN")
bot.py
import discord
from discord.ext import commands

EXT = (
    "level_one",
    "level_two",
    "level_three"
)

class MyTestableBot(commands.Bot):
    def __init__(self, command_prefix: str, **options):
        super().__init__(command_prefix, **options)
        for ext_name in EXT:
            self.load_extension("cogs." + ext_name)

    async def on_ready(self):
        print("ready...")

Lv.1 簡単なメッセージ返信

/neko と送信すると、 にゃーん と返信しますにゃーん

level_one.py
import discord
from discord.ext import commands

class LevelOneCog(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot

    @commands.Cog.listener(name="on_message")
    async def nyan(self, message: discord.Message):
        if message.author.bot:
            return  #  無限ループを回避

        if message.content == "/neko":
            await message.channel.send("にゃーん")

def setup(bot):
    bot.add_cog(LevelOneCog(bot))

Lv.2 コマンドを送信するとリアクション & ロール付与

-neko と送信すると、 command として処理され 😺 リアクションが付与されたあと にゃーん という名前のロールが付与されます。

level_two.py
import discord
from discord.ext import commands

class LevelTwoCog(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot

    @commands.command()
    async def neko(self, ctx: commands.Context):
        await ctx.message.add_reaction("😺")
        role = discord.utils.get(ctx.guild.roles, name="にゃーん")
        await ctx.author.add_roles(role)

def setup(bot):
    bot.add_cog(LevelTwoCog(bot))

Lv.3 メッセージを削除すると削除者と被削除者を取得し5秒後埋め込みで投稿

level_three.py
from typing import Union
import asyncio

import discord
from discord.ext import commands

class LevelThreeCog(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot

    @commands.Cog.listener(name="on_message_delete")
    async def get_deleter(self, message: discord.Message):
        guild: discord.Guild = message.guild

        async for entry in guild.audit_logs(limit=100, action=discord.AuditLogAction.message_delete, after=message.created_at):
            delete_message_author: Union[discord.Member, discord.User] = entry.target

            if delete_message_author != message.author:
                continue

            embed = discord.Embed(title="メッセージが削除されました")
            embed.add_field(name="送信者", value=str(message.author))
            embed.add_field(name="削除者", value=str(entry.user))
            await asyncio.sleep(5)
            await message.channel.send(embed=embed)
            break

def setup(bot: commands.Bot):
    bot.add_cog(LevelThreeCog(bot))

dpytest の場合

Lv.1

テスト用のスクリプトを記述します。

test_dpytest.py
import pytest
import discord.ext.test as dpytest
from bot import MyTestableBot

import discord

@pytest.fixture
def bot(event_loop):
    bot = MyTestableBot("-", loop=event_loop)
    dpytest.configure(bot)
    return bot

@pytest.mark.asyncio
async def test_one_1(bot):
    await dpytest.message("/neko")
    assert dpytest.verify().message().content("にゃーん")

同じフォルダ上で pytest-m オプションを付けて実行します。

$ py -m pytest

すると・・・

========================================================== test session starts ==========================================================
platform win32 -- Python 3.8.10, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: 
plugins: asyncio-0.15.1
collected 1 item

test_dpytest.py F                                                                                                                  [100%]

=============================================================== FAILURES ================================================================ 
______________________________________________________________ test_one_1 _______________________________________________________________ 

bot = <bot.MyTestableBot object at 0x0000019DAF476A00>

    @pytest.mark.asyncio
    async def test_one_1(bot):
>       await dpytest.message("/neko")

test_dpytest.py:13:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
..\..\..\..\.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\ext\test\runner.py:184: in message
    mes = back.make_message(content, member, channel, attachments=attachments)
..\..\..\..\.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\ext\test\backend.py:769: in make_message
    data = facts.make_message_dict(
..\..\..\..\.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\ext\test\factories.py:379: in make_message_dict
    'author': dict_from_user(author),
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

user = None

    def dict_from_user(user: discord.User) -> _types.JsonDict:
        out = {
>           'id': user.id,
            'username': user.name,
            'discriminator': user.discriminator,
            'avatar': user.avatar
        }
E       AttributeError: 'NoneType' object has no attribute 'id'

..\..\..\..\.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\ext\test\factories.py:85: AttributeError
---------------------------------------------------------- Captured log setup ----------------------------------------------------------- 
WARNING  discord.client:client.py:257 PyNaCl is not installed, voice will NOT be supported
======================================================== short test summary info ========================================================
FAILED test_dpytest.py::test_one_1 - AttributeError: 'NoneType' object has no attribute 'id'
=========================================================== 1 failed in 0.65s ===========================================================

ソースを読むと、 await dpytest.message() が呼び出されたあと、 runner のもつ _cur_config という設定リストにアクセスして、
メッセージを送信したユーザーの情報を dict_from_user に渡しているようです。

しかし、

print(dpytest.runner._cur_config.members)

と実行してみると、メンバーリストはNoneで埋まっていることが確認できます。うーん?

解決法は公式サーバーの中に

README.md にあったサポートDiscordサーバーで同じ問題を探していたところ、ちょうど2日前に全く同じ質問をしていたユーザーを見つけました。

Discord.pyのバージョンが 1.5 以上の場合は、インテントを利用する必要があるようです。

@pytest.fixture
def bot(event_loop):
    intents = discord.Intents.all()
    bot = MyTestableBot("-", loop=event_loop, intents=intents)
    dpytest.configure(bot)
    return bot

このように変更したあと、 py -m pytest を実行すると、

======================================== test session starts =========================================
platform win32 -- Python 3.8.10, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: 
plugins: asyncio-0.15.1
collected 1 item

test_dpytest.py .                                                                               [100%]

========================================= 1 passed in 0.44s ========================================== 

無事にテストが通りました。

テストケースを追加する

ところでこのコード、/neko 以外のメッセージを送信した場合は何も返信しないことが期待されますが、正しく動作するでしょうか?
/inu と送信しても、何も返ってこないことを確認するテストを追加してみます。

@pytest.mark.asyncio
async def test_one_2(bot):
    await dpytest.message("/inu")
    assert dpytest.verify().message().nothing()

verify が検証開始用のメソッドで、メッセージについて検証する場合は更に message メソッドを呼び出します。
以降は、検証するメッセージが満たすべき条件を、メソッドチェーンで記述します。
最後にassert文が実行されるタイミングで、 __bool__ が呼び出され、指定された条件を満たしているかが判定されます。

メソッド 効果 詳細
nothing メッセージが「送信されていない」かを検証する機能を適用する。 -
contains 部分一致を適用する。 contains を呼び出した状態だと、 content の判定基準が部分一致になる。
呼び出していないなら、完全一致になる。
content メッセージの内容が引数と同一か検証する機能を適用する。 渡された引数が文字列なら、同一の文字列か検証する。
引数が None なら、空のメッセージかどうか検証する。
contains を呼び出すと部分一致になる)

contains について、具体例を上げると、先に書いた test_one_1 テストにおいて、 にゃーん と送信されることを期待していますが・・・

await dpytest.message("/neko")
await dpytest.verify().message().contains().content("にゃーん")

このように記述した場合、Botが にゃーんにゃーん にゃーん? と送信してもテストが通ります。

Lv.2

まず、 -neko と送信したら 😺 絵文字のリアクションが付くことを確認します。

@pytest.mark.asyncio
async def test_two_1(bot: MyTestableBot):
    message = await dpytest.message("-neko")
    new_message: discord.Message = await message.channel.fetch_message(message.id)

    assert str(new_message.reactions[0].emoji) == "😺"

fetch_message するのは、送信時に返ってくるメッセージオブジェクトと、fetch したときのメッセージオブジェクトは異なるためです
dpytest/test/test_reactions.py より)。

しかし・・・

===================================== test session starts =====================================
platform win32 -- Python 3.8.10, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: 
plugins: asyncio-0.15.1
collected 3 items

test_dpytest.py ..F                                                                      [100%]

========================================== FAILURES =========================================== 
_________________________________________ test_two_1 __________________________________________

# (中略)

if not atomic:
    new_roles = utils._unique(Object(id=r.id) for s in (self.roles[1:], roles) for r in s)
    await self.edit(roles=new_roles, reason=reason)
else:
    req = self._state.http.add_role
    guild_id = self.guild.id
    user_id = self.id
    for role in roles:
>       await req(guild_id, user_id, role.id, reason=reason)
E       AttributeError: 'NoneType' object has no attribute 'id'

ロールを付与する処理でエラーが発生しました。
原因の予想は容易で、「にゃーん」という名前のロールが見つからなかったからだと考えられます。

dpytest のオブジェクトについて

こうなってしまったからには、ロールを作成するほかないでしょう。

@pytest.mark.asyncio
async def test_two_1(bot: MyTestableBot):
    guild: discord.Guild = bot.guilds[0]
    await guild.create_role(name="にゃーん")
    message = await dpytest.message("-neko")
    new_message: discord.Message = await message.channel.fetch_message(message.id)

    assert str(new_message.reactions[0].emoji) == "😺"
guild: discord.Guild = bot.guilds[0]

まず、 bot.guilds には dpytest が用意した Test Guild 0 , Test Guild 1, Test Guild ... という名前の、架空のサーバーが入っています
(要素数は dpytest.configure() を呼び出したときに決定します)。

ゆえに今、 guild 変数は Test Guild 0 という架空のサーバーを参照しています。
架空とはいえ、普通の discord.Guild オブジェクトと同じようにロールを作成できるように dpytest が仲介してくれます。
(このあたりはドキュメントに一切記述がないので、実際に実行して確認してみなければ分かりません)

これでテストが通りました。

注釈
dpytest.configure() メソッドに渡せる引数は、テスト対象のBotのほかに、 num_guildsnum_channelsnum_members があります。

bot.guilds の要素数は、num_guilds の値によって決定しますが、 num_channels を指定した場合、
  • Test Guild 0
    • Channel_0
    • Channel_1
    • Channel_N
  • Test Guild 1
    • Channel_0
    • Channel_1
    • Channel_N
  • etc…
という構造になります。

また、num_members を指定した場合、
  • Test Guild 0
    • TestUser0 #0001
    • TestUser1 #0002
    • TestUserN #000N
  • Test Guild 1
    • TestUser0 #0001
    • TestUser1 #0002
    • TestUserN #000N
  • etc…
というふうに、各サーバーに TestUser という名前のメンバーが充てられます。

なお、ロールは各サーバーともに @everyone のみで、ボイスチャンネルもありません。

Lv.2 のつづき

最後に、ロールが正常に付与されるかどうか、テストしなければなりません。

@pytest.mark.asyncio
async def test_two_2(bot: MyTestableBot):
    guild: discord.Guild = bot.guilds[0]
    await guild.create_role(name="にゃーん")
    message: discord.Message = await dpytest.message("-neko", guild.text_channels[0])
    assert discord.utils.get(message.author.roles, name="にゃーん") is not None

当然かもしれませんが、ロール情報などはテストごとに独立しています。
discord.utils.get を使い、もしメッセージ送信者のロールリストに「にゃーん」という名前のロールがあれば、成功したものとみなします。

注釈
dpytest.message() は、メッセージを送信するためのメソッドですが、様々なオプションが存在します。

test_two_2 テストケースで利用した第2引数 channel で、送信先のテキストチャンネルを指定できます。

また、第3引数 member で、送信者を指定できます。
デフォルトでは、Bot自身(dpytest内でのユーザ名: FakeApp #0001)が送信するのではなく、架空のユーザー TestUser0 #0001 が送信します。
この引数を利用すれば、Botがメッセージを送信した場合のテストも実現できそうです。

添付ファイルを指定することもできるようですが、ここでは省略します。

Lv.3

事前にどのような操作を行うべきか考えると、

  1. ユーザー A がメッセージを送信する。
  2. ユーザー B が 1. のメッセージを削除する。

したがって、二人のユーザーが必要です。
TestUser1 #0002 がメッセージを送信し、 次に TestUser0 #0001 がそのメッセージを削除するように記述しましょう。
削除させる場合には、TestUser0 #0001 に対する削除権限の付与も必要ですね。

ほとんど文章通りに書いたのが以下です。

@pytest.mark.asyncio
async def test_three_1(bot: MyTestableBot):
    guild: discord.Guild = bot.guilds[0]
    channel: discord.TextChannel = guild.text_channels[0]

    member_0: discord.Member = discord.utils.get(guild.members, name="TestUser0")
    overwrite = discord.PermissionOverwrite()
    overwrite.manage_messages = True
    await dpytest.set_permission_overrides(member_0, channel, overwrite)

    member_1: discord.Member = discord.utils.get(guild.members, name="TestUser1")
    message: discord.Message = await dpytest.message("Hello!", guild.text_channels[0], member_1)
    await message.delete()

    expected_embed = discord.Embed(title="メッセージが削除されました")
    expected_embed.add_field(name="送信者", value=str(member_1))
    expected_embed.add_field(name="削除者", value=str(member_0))

    assert dpytest.verify().message().embed(expected_embed)

VerifyMessage の検証条件設定で、 embed() メソッドを呼び出すと、引数に渡した埋め込みの内容と一致しているかどうかを検証してくれる・・・

はずでした

============================================= test session starts ==============================================
platform win32 -- Python 3.8.10, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: 
plugins: asyncio-0.15.1
collected 5 items

test_dpytest.py ....F                                                                                     [100%]

=================================================== FAILURES =================================================== 
_________________________________________________ test_three_1 _________________________________________________ 

#(中略)

--------------------------------------------- Captured stderr call --------------------------------------------- 
Ignoring exception in on_message_delete
Traceback (most recent call last):
  File "C:\Users\  \.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\client.py", line 343, in _run_event
    await coro(*args, **kwargs)
  File "C:\Users\  \Documents\Programs\Python\bot_test\cogs\level_three.py", line 15, in get_deleter
    async for entry in guild.audit_logs(limit=100, action=discord.AuditLogAction.message_delete, after=message.created_at):
  File "C:\Users\  \.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\iterators.py", line 91, in __anext__
    msg = await self.next()

# (中略)

  File "C:\Users\  \.virtualenvs\bot_test-yk-UKBrg\lib\site-packages\discord\ext\test\backend.py", line 87, in request
    raise NotImplementedError(

NotImplementedError: Operation occured that isn't captured by the tests framework. This is dpytest's fault, please reportan issue on github. Debug Info: GET https://discord.com/api/v7/guilds/853163028638728244/audit-logs with {'params': {'limit': 100, 'action_type': 72}}

アッ


トレースバックの内容からして、まだ監査ログ取得のAPIは再現できていないということでしょうか。

総評

dpytest は、ドキュメントこそまだ整備が進んでいないが、十分に利用できると考えられます。
すべての通信は dpytest によって再現されるため、ローカルな環境下でも実際のDiscordと同様の挙動を期待できます。

任意の情報を持ったDiscordモデルをコード内で利用するときなどにはドキュメントが不十分であることが問題になるかもしれません。
現段階では、問題が発生した場合、ドキュメントと、GitHub内のソースコード、GitHub内のテスト、そしてサポートDiscordサーバーの4つの箇所を確認することになると思います。

そもそも README.md によれば、まだ dpytest は開発の初期段階にあります。

The library is currently in its infancy, and only supports a subset of the discord API. Also, the API is not yet finalized, and may change somewhat rapidly. Breaking changes can be expected until the library hits version 1.0.

バージョン1.0に達するまでは破壊的変更がなされる可能性が示されています。

distest の場合

distest ではDiscord Botアカウントをもうひとつ用意してテストします!
早速作ってきました!

image.png

このBotをテスト用サーバーに招待し、 Server Members Intent をオンにすることを忘れないでください。

Lv.1

dpytest のときと同じように、テスト用のスクリプトを記述しましょう。

distest_script.py
import sys
from distest import TestCollector
from distest import run_dtest_bot

test_collector = TestCollector()
created_channel = None

@test_collector()
async def test_one_1(interface):
    await interface.assert_reply_equals("/neko", "にゃーん")

@test_collector()
async def test_one_2(interface):
    await interface.send_message("/inu")
    await interface.ensure_silence()

if __name__ == "__main__":
    run_dtest_bot(sys.argv, test_collector)

このスクリプトを クイックスタート 通りに

$ python distest_script.py TARGET_ID TESTER_TOKEN

このように実行します (TARGET_ID にはテスト対象のBotのIDを、 TESTER_TOKEN にはテスト用Botのトークンを入れます)。

すると、テスト用Botが起動します。
そのあとは、テスト用のテキストチャンネルへ移動し、 ::run all と送信します。

image.png

このようになります。

問題発生

Discord Bot がコマンドを処理するときは、Botからのメッセージを無視する。
これはBot開発において、無限ループを抑制するための有名な対策です。
だから今回、テスト用Botから送られてきた /neko メッセージをあろうことか無視してしまいました。

これではテストにならない・・・。

Bot Commands Frameworkで記述したコマンドも process_commands でBotのメッセージを弾くようになっているので、当然テストできません。

総評

Commandをテストできないのはあまりよいとは思えなかったので、これをテストライブラリとして採用するのは個人的にはちょっと・・・・
ただし、dpytest よりシンプルで検証用のメソッドも多く用意されていたので、場合によっては良い選択肢といえると思います。

dpytest と GitHub Actions でデプロイ時にテスト

どうせならGitHub Actionsを使って、自動的にテストが実行されるようにしましょう。

GitHubにリモートリポジトリを作成しpushしたあと、以下のようなActionsを作成します。
ただ pipenv を install したあと、依存パッケージもインストールして、 pytest を呼び出しているだけです。

image.png

いい感じに実行できています。
(Lv.3 部分のテストケースはなかったことになりました)

結論

テストライブラリを用いたテストは、すこし時期尚早に感じます。
しかし、簡単なテストを大量に行う場合にはこれらのライブラリは有用でしょう。

この機会に、テストライブラリの開発に参加してみるのも良いかもしれませんね。

14
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
14
Help us understand the problem. What is going on with this article?