discordgoでE2Eのテストコードを書く

2024/04/30に公開

あいさつ

みなさん、こんにちは!マグロです。
4月になり、新年度が始まりました。新しい環境でのスタートはいかがでしょうか?

僕は新年度になってもdiscordbotを作っています。

今回は、discordgoでテストコードを書く方法について解説します。

テストコードとは

プログラムの動作を確認するためのコードのことです。
テストコードを書くことで、プログラムの動作が正しいかどうかを確認することができます。

まあ要するに書けばお得です。

Goでのテストコードの書き方

知見がある人はスキップしてください。

Goでのテストコードの書き方

Goでは、テストコードを書くための標準パッケージが用意されています。

testファイルの作成

テストコードは、テスト対象のコードと同じディレクトリに_test.goという名前のファイルを作成します。

例えば、add.goというファイルがある場合、テストコードはadd_test.goという名前のファイルに書きます。

テスト関数の作成

テストコードは、Testから始まる関数を作成します。

add_test.go
package add

import "testing"

func TestAdd(t *testing.T) {
    if add(1, 2) != 3 {
        t.Error("add(1, 2) should be 3")
    }
}

func add(a, b int) int {
    return a + b
}

TestAdd関数は、add(1, 2)3を返すかどうかを確認しています。

テストの実行

テストコードは、以下のコマンドで実行します。

$ go test

interfaceを使った結合テスト

Goにはinterfaceというものがあります。

簡単に言うと、中身の関数(メゾット)が同じであれば、異なる構造体でもひとくくりにして扱えます。

例えば、以下のようなコードがあるとします。

package main

import "fmt"

type math struct{}

func (m *math) adder(a, b int) int {
	return a + b
}

func addCmd(m *math) int {
	a := 1
	b := 2
	return m.adder(a, b)
}

func main() {
	m := math{}
	a := addCmd(&m)
	fmt.Println(a)
}

上記の処理はただの足し算ですが、データベースや外部APIとの通信など、複雑な処理を行う場合、テストを書くことが難しくなります。

そこで、interfaceを使ってテストを行います。

package main

import (
    "testing"
)

type math struct{}

func (m *math) adder(a, b int) int {
    return a + b
}

type mathMock struct {
    adderFunc func(a, b int) int
}

func (m *mathMock) adder(a, b int) int {
    return m.adderFunc(a, b)
}

type mathFunc interface {
    adder(a, b int) int
}

var (
    _ mathFunc = (*mathMock)(nil)
    _ mathFunc = (*math)(nil)
)

func addCmd(m mathFunc) int {
    a := 1
    b := 2
    return m.adder(a, b)
}

func TestAdd(t *testing.T) {
    m := &mathMock{
        adderFunc: func(a, b int) int {
            return 3
        },
    }
    if addCmd(m) != 3 {
        t.Error("addCmd should be 3")
    }
}

上記のコードでは、math構造体の代わりにmathMock構造体を使っています。

mathMock構造体は、adderFuncという関数を持っており、コード内のように戻り値を指定することができます。

データベース関連の操作であれば、データベースを使用せずにテストを行うことができます。

このように、interfaceを使うことで、結合テストを行いやすくすることができます。

discordgoでのテストコードの書き方

ディレクトリ構成です。以下のGitHubリポジトリを参考にしています。
ここを見ながら進めていきます。

https://github.com/maguro-alternative/discordgo-test-sample

├── bot                         // DiscordBotを動かすためのディレクトリ
│   ├── cogs                    // DiscordBotのコグ
|   ├── commands                // スラッシュコマンド
│   ├── config                  // 環境変数設定ファイル
│   └── main.go

該当項目でも書きますが、今回行うテストするものの仕様は以下の通りです。

  • cog

    • onMessageCreateメゾット
      • Botからのメッセージが送信された場合、何も返さない。
      • pingが送信された場合、pongを返す。
      • !helloが含まれるメッセージが送信された場合、何も返さない。
      • 上記以外の場合、Hello, World!を返す。
  • commands

    • command_hanlder
      • 登録するのはpingコマンド。
      • 同じコマンド名は登録できない。
      • 登録した場合、構造体にデータが格納される。(ローカルでの参照が可能)
      • 登録に失敗した場合、構造体にデータは格納されない。
      • getCommand関数でコマンドを取得する。
      • commandDelete関数でコマンドを削除する。
    • ping
      • 実行されるとpongを返す。
      • responseオプションを適応すると、そのオプションで指定した文字のメッセージを返す。

共通事項

当たり前ですが、単体テストはdiscordgo側が行っています。
そのため、今回書くのは結合テストです。

結合テストのため、discordgoの機能を使っている部分をモック化します。
clientもモック化していますが、今回は使用しません。
*discordgo.Sessionは引数に指定せず、mock.Sessionというinterfaceを使用します。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/testutil/mock/session.go

discordgoでは、discordgo.Session.AddHandlerでイベントを受け取った際の処理を登録できます。
しかし、引数と戻り値の指定がある都合上、戻り値にerrorなどを指定できず、正しい挙動をしたかどうかの確認ができません。

なぜ戻り値を指定できないのか
s.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
    // ここに処理を書く
})

上記のコードは、smを引数に取りますが、戻り値と引数をこれ以上追加するとエラーが発生します。
どのような関数なのかでイベントを判別しているためです。

結合テストでは、正しく処理できたか、想定通りのエラーが発生するかを確認する必要があります。

最低限errorを返さないと、テストで確認できるものがありません。

cogsのテストコードの書き方

cogsは、discordbotの機能を追加するためのディレクトリです。
cog_handler.goというファイルに、コグの登録処理を記述します。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/cogs/cog_handler.go

cogHandlerという構造体があります。

cogHandlerにはonMessageCreateというメゾットがあり、discordgo.MessageCreateイベントを受け取った際の処理を記述します。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/cogs/on_message_create.go

とりあえず注目してほしいところは、onMessageCreateメゾットです。

func (h *cogHandler) onMessageCreate(s *discordgo.Session, vs *discordgo.MessageCreate) {
	ctx := context.Background()
	_, err := onMessageCreateFunc(ctx, h.client, s, s.State, vs)
	if err != nil {
		slog.ErrorContext(ctx, "OnMessageCreate Error", "Error:", err.Error())
	}
}

本来ここでは、discordgo.MessageCreateイベントを受け取り、処理を行います。
しかし、テストコードを書くためには、errorを返す必要があります。

そこで本来の処理を書き込んだonMessageCreateFuncメゾットを作成します。

onMessageCreateメゾットは、onMessageCreateFuncメゾットを呼び出し、errorを返します。
ついでに何を送信したのかも返すように*discordgo.Messageも返します。

func onMessageCreate(
	ctx context.Context,
	client *http.Client,
	s mock.Session,
	state *discordgo.State,
	vs *discordgo.MessageCreate,
) (*discordgo.Message, error) {
    // 本来の処理
}

これで正しい処理が行われたか確認できます。
というわけで、テストコードを書いていきます。

サンプルの仕様は以下の通りです。
テストするonMessageCreateFuncは以下のような仕様です。

  • Botからのメッセージが送信された場合、何も返さない。
  • pingが送信された場合、pongを返す。
  • !helloが含まれるメッセージが送信された場合、何も返さない。
  • 上記以外の場合、Hello, World!を返す。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/cogs/on_message_create_test.go

&mock.SessionMockを引数に渡すことで、モックを使用してテストを行います。
実際に送信はされず、送信されたとしてレスポンスを返すようにしています。

テストを実行して、正しく処理が行われているか確認してみましょう。

commandsのテストコードの書き方

登録のテスト

commandsは、discordbotのスラッシュコマンドを追加するためのディレクトリです。
command_handler.goというファイルに、スラッシュコマンドの登録処理を記述します。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/commands/command_handler.go

commandRegister関数でコマンドを登録し、handler.commandsに格納します。
commandRemove関数でコマンドを削除し、handler.commands内のデータも削除します。
getCommand関数でhandler.commandsからコマンドを取得します。

登録はDiscordとの通信以外にも、handler.commandsに格納することで、どのコマンドが登録されているかを確認できます。
コマンドが登録されているかどうかを確認するために、テストコードを書いていきます。
確認しているのは以下の項目です。

  • pingコマンドが登録されているか。
  • 同じコマンド名は登録できないか。
  • 登録に失敗した場合、構造体にデータが格納されていないか。
  • コマンドが削除されているか。
  • 未登録の場合、エラーが発生するか。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/commands/command_handler_test.go

cogsと同様に&mock.SessionMockを引数に渡すことで、モックを使用してテストを行います。

同じくテストを実行して、正しく処理が行われているか確認してみましょう。

実行のテスト

スラッシュコマンドの処理は以下のように記述します。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/commands/ping.go

Optionはスラッシュコマンドのオプション(discord.go公式のサンプルコード参照)、Executorは実行する関数を指定します。
Executorに格納する関数は以下のようなものです。
スラッシュコマンドに対する返信とエラーを返します。

func(s mock.Session, i *discordgo.InteractionCreate) (*discordgo.InteractionResponse, error)

pingは以下のような仕様です。

  • 実行されるとpongを返す。
  • responseオプションを適応すると、そのオプションで指定した文字のメッセージを返す。

また、discordgoの仕様上、複数のスラッシュコマンドが登録されている場合、コマンド名関係なくすべて実行してしまいます。
そのため、コマンド名が違う場合のテストケースも含めて書きます。

https://github.com/maguro-alternative/discordgo-test-sample/blob/main/bot/commands/ping_test.go

これも&mock.SessionMockを引数に渡すことで、モックを使用してテストを行います。

テストを実行して、正しく処理が行われているか確認してみましょう。

まとめ

今回は、discordgoでテストコードを書く方法について解説しました。
動作確認と挙動の理解が深まるので、おすすめです。

今回はあくまでも一例で、他にも様々なテストコードの書き方があります。
用途に応じて適切なテストコードを書いていきましょう。

GitHubで編集を提案

Discussion