💉

Goでテスタビリティを目的とした場合の最小限のDI設計

2024/04/02に公開

誰に対して向けた記事か

  • Clean Architecture的な思想で依存関係を逆転させた綺麗な設計にするのはぶっちゃけ面倒くせえ
  • でもテストのためにDIやmockはしなくちゃいけねえ

って人のための記事です。

何を解説する記事か

Goでテスタビリティを目的とした場合の最小限のDI設計を2つ紹介します。

  • funcを引数に取る方法
  • interfaceを使う方法

問題の例

以下のようなコードを書いたとします。
要はVery色んな処理が終わったらSlack通知を行うアプリケーションです。

package main

import (
	"fmt"
	"github.com/slack-go/slack"
	"os"
)

func main() {
	c := slack.New(os.Getenv("SLACK_TOKEN"))

	err := Very色んな処理()
	msg, err := HandleErrorV1(c, err)
	if err != nil {
		panic(err)
	}

	fmt.Println(msg)
}

func HandleErrorV1(c *slack.Client, err error) (string, error) {
	var msg string
	if err == nil {
		msg = "Very色んな処理が完了しました\n"
	} else {
		msg = fmt.Sprintf("エラーが発生しました: %v\n", err)
	}

	if _, _, err = c.PostMessage("#general", slack.MsgOptionText(msg, true)); err != nil {
		return "", fmt.Errorf("slackへのメッセージ送信に失敗しました: %v", err)
	}

	return msg, nil
}

func Very色んな処理() error {
	// 実際にはめっちゃ色んな処理がここに書かれてることにしてください
	return nil
}

この中のHandleErrorV1をテストしたいとします。
しかし、HandleErrorV1slack.Clientに依存しているため、テストを走らせるたびにSlackに通知が飛んでしまいます。

解決案 funcを引数に取る方法

「問題の例」の問題を解決するために、以下のようなコードを書くことができます。

package main

import (
	"fmt"
	"github.com/slack-go/slack"
	"os"
)

func main() {
	err := Very色んな処理()
	msg, err := HandleErrorV2(err, PostMessageImpl) // ここで注入
	if err != nil {
		panic(err)
	}

	fmt.Println(msg)
}

// 引数のfuncにてDIっぽいことを実現する
func HandleErrorV2(err error, postMessage func(string, slack.MsgOption) (string, string, error)) (string, error) {
	var msg string
	if err == nil {
		msg = "Very色んな処理が完了しました\n"
	} else {
		msg = fmt.Sprintf("エラーが発生しました: %v\n", err)
	}

	if _, _, err = postMessage("#general", slack.MsgOptionText(msg, true)); err != nil {
		return "", fmt.Errorf("slackへのメッセージ送信に失敗しました: %v", err)
	}

	return msg, nil
}

func PostMessageImpl(channel string, msgOption slack.MsgOption) (string, string, error) {
	return slack.New(os.Getenv("SLACK_TOKEN")).PostMessage(channel, msgOption)
}

func Very色んな処理() error {
	// 実際にはめっちゃ色んな処理がここに書かれてることにしてください
	return nil
}

以下の特徴があります。

  • HandleErrorV2slack.Clientに依存せず、PostMessageという関数を引数に取ることでDIを実現している
  • HandleErrorV2の使用側でPostMessageImplを注入する

これにより、HandleErrorV2のテストを以下のように書くことができます。

package main

import (
	"errors"
	"github.com/slack-go/slack"
	"github.com/stretchr/testify/assert"
	"testing"
)

func TestHandleErrorV2(t *testing.T) {
	mockPostMessage := func(channel string, msgOption slack.MsgOption) (string, string, error) {
		// 実際の通知をさせず、何も起こさない
		return "", "", nil
	}

	msg, err := HandleErrorV2(errors.New("何かしらのエラー"), mockPostMessage)
	assert.Nil(t, err)
	assert.Equal(t, "エラーが発生しました: 何かしらのエラー\n", msg)
}

解決案 interfaceを使う方法

また、interfaceを使う方法も紹介します。
Goであればできる設計であり、最小限の定義だけで実現できます。

package main

import (
	"fmt"
	"github.com/slack-go/slack"
	"os"
)

func main() {
	c := slack.New(os.Getenv("SLACK_TOKEN"))

	err := Very色んな処理()
	msg, err := HandleErrorV3(err, c)
	if err != nil {
		panic(err)
	}

	fmt.Println(msg)
}

func HandleErrorV3(err error, slackClient slackClientInterface) (string, error) {
	var msg string
	if err == nil {
		msg = "Very色んな処理が完了しました\n"
	} else {
		msg = fmt.Sprintf("エラーが発生しました: %v\n", err)
	}

	if _, _, err = slackClient.PostMessage("#general", slack.MsgOptionText(msg, true)); err != nil {
		return "", fmt.Errorf("slackへのメッセージ送信に失敗しました: %v", err)
	}

	return msg, nil
}

type slackClientInterface interface {
	// 以下のシグネチャをinterfaceとして定義しただけ
	// https://pkg.go.dev/github.com/slack-go/slack#Client.PostMessage
	// Goは明示的なimplementsが不要なので、ライブラリに合うinterfaceをこちらで勝手に定義して使える
	PostMessage(channelID string, options ...slack.MsgOption) (string, string, error)
}

func Very色んな処理() error {
	// 実際にはめっちゃ色んな処理がここに書かれてることにしてください
	return nil
}

以下の特徴があります。

  • HandleErrorV3slack.Clientに依存せず、slackClientInterfaceという独自定義のinterfaceを引数に取ることでDIを実現している
  • slackClientInterfaceslack.ClientPostMessageメソッドのシグネチャをそのまま定義しているだけ
  • slack.ClientslackClientInterfaceを実装したことになってる(Goは明示的なimplementsが不要
  • HandleErrorV3の使用側でslackClientInterfaceを実装した型であるslack.Clientを注入する

これにより、HandleErrorV3のテストを以下のように書くことができます。

package main

import (
	"errors"
	"github.com/slack-go/slack"
	"github.com/stretchr/testify/assert"
	"testing"
)

type mockSlackClient struct{}

func (m *mockSlackClient) PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) {
	// 実際の通知をさせず、何も起こさない
	return "", "", nil
}

func TestHandleErrorV2(t *testing.T) {
	msg, err := HandleErrorV3(errors.New("何かしらのエラー"), &mockSlackClient{})
	assert.Nil(t, err)
	assert.Equal(t, "エラーが発生しました: 何かしらのエラー\n", msg)
}

Discussion