テストのためだけに`interface`を書きたくないでござる

golangでテストのためだけにinterfaceを書くのが死ぬほど嫌だったので編み出した技を紹介します。

TL;DR

  • テスト(=mock)のためだけにinterfaceは切りたくない
  • 型エイリアスとビルドタグを組み合わせるとinterfaceがなくてもモックが作れる
  • この手法に必要なモックを自動生成するプログラムを作った
  • interfaceは本当に必要なシーンで使うべき

Background

現在モックを使った単体テストは一般的です。

Javaでの例を挙げると、モックしたいコンポーネントについて予めinterfaceを定義しておき、モックではそのインターフェースを実装するのが定石です。

しかしgolangのinterfaceはJavaなどのそれとは若干性質が異なるため、テスト=モックのためだけにinterfaceを書くのはオーバーワーク気味です。

そうテストのためだけにinterfaceを書くのはまっぴらごめんなのです。

interfaceをテストのために書くのが嫌すぎて、いろいろ頭をひねっていたら書かなくて良い方法を思いつきました。

その方法を端的に言うと、型エイリアスで外のパッケージにある型を自分の空間にインポートし、ビルドタグでテストの時だけその実装をモックに入れ替える、となります。

ではサンプルコードを示しましょう。 以下はサンプルプロジェクトのディレクトリ構成です。 典型的なTodoアプリのDDD風味を想定して、do/ にはドメインオブジェクトを、repositoryには永続化を、serviceにはビジネスロジックを置く形にしています。

github.com/koron/todo-noif-mock/
  +-- do/
  |   +-- todo.go
  +-- repository/
  |   +-- todo_repository.go
  +-- service/
      +-- repository.go
      +-- repository_mock.go
      +-- todo_service.go
      +-- todo_service_test.go

まず do/todo.goTodo オブジェクトを定義するだけですから極めて単純です。 DTO (Data Transfer Object)と言っても良いでしょう。

package do

type Todo struct {
    ID   int64
    Task string
    // ... そのほかTodoに要求されるフィールドを定義する
}

repository/todo_repository.go は、とりあず Insert() メソッドを持つだけとしておきましょう。 これは説明のための簡略化ですから本来は別名のメソッドであっても良いし、複数あっても良いです。

package repository

import "github.com/koron/todo-noif-mock/do"

type Repository struct {
}

func (r *Repository) Insert(todo *do.Todo) (*do.Todo, error) {
    // 典型的なDB操作(insert)がココに入る
    return todo, nil
}

この時に service/repository.go として型エイリアスとビルドタグを用いた以下のような内容のファイルを作成します。 このファイルはビルドタグ mock が指定されなければコンパイル対象となります。

// +build !mock

package service

import "github.com/koron/todo-noif-mock/repository"

type Repository = repository.Repository

一方でモックの実態は service/repository_mock.go に以下のように定義します。 このファイルはビルドタグ mock が指定されたときだけコンパイル対象となります。

// +build mock

package service

import "github.com/koron/todo-noif-mock/do"

type Repository struct {
    // モックに必要なフィールドをここに置く
}

func (m *Repository) Insert(todo *do.Todo) (*do.Todo, error) {
    // モックのコードをココに書く
    return todo, nil
}

勘の良い方にはもう説明の必要はないでしょうが、 service/todo_service.goservice/todo_service_test.go のコードを続けて示しましょう。

package service

import "github.com/koron/todo-noif-mock/do"

type Service struct {
    repos *Repository // Repositoryは自空間にエイリアスで定義してるのでpackageの指定は不要
}

func (srv *Service) PostTodo(task string) (*do.Todo, error) {
    // なにかしら必要な手続きをしたあとに…Repository のコードを呼び出す
    d, err := srv.repos.Insert(&do.Todo{Task: task})
    if err != nil {
        return nil, err
    }
    return d, nil
}
package service

import "testing"

func TestServiceCreate(t *testing.T) {
    r := &Repository{
        // モック用のRepositoryの初期化をここに書く
    }
    srv := &Service{repos: r}
    d, err := srv.PostTodo("テストのタスク1")
    if err != nil {
        t.Fatal(err)
    }

    // TODO: dをチェックする
    _ = d

    // TODO: Insertが正しく呼ばれたか`r`の中身をチェックするなど
}

以上のようにしておけば普通のビルドはgo build ./serviceで、 テストはgo test -tags mock ./serviceでそれぞれ実行できるのです。

-tags mockを忘れてテストを実行すると当然失敗することにご留意を。

ここまでのサンプルはGitHubのココを参照してください。

で、ここで使うモックをいちいち手書きしてらんねぇってことで自動生成するツールを作りました。 以下のコマンドで入手できます。

$ go get github.com/koron/mockgo

このツールは各メソッドの呼び出しパラメータを保存し戻り値を予め指定できるだけのモックを生成します。

$ cd service
$ mockgo -package ../repository Repository
$ cd ..

生成されたモックのコードは省略しますが、それを使ったservice/todo_service_test.goは以下のように変わります。

func TestServiceCreate(t *testing.T) {
    r := &Repository{
        // モック用のRepositoryの初期化をここに書く
        Insert_Rs: []*RepositoryInsert_R{
            {&do.Todo{ID: 123, Task: "mytask1"}, nil},
        },
    }
    srv := &Service{repos: r}
    d, err := srv.PostTodo("in_task1")
    if err != nil {
        t.Fatal(err)
    }
    // dをチェックする
    if !reflect.DeepEqual(d, &do.Todo{ID: 123, Task: "mytask1"}) {
        t.Fatal("unexpected response")
    }
    // Insertが正しく呼ばれたかチェック
    if !reflect.DeepEqual(r.Insert_Ps, []*RepositoryInsert_P{
        {&do.Todo{Task: "in_task1"}},
    }) {
        t.Fatal("unexpected Insert calls")
    }
}

またservice/repository.goに次のように書いておけばgo generate ./serviceするだけでモックの更新が可能になります。

//go:generate mockgo -package ../repository Repository

モックを自動生成した版のサンプルはGitHubのココを参照してください。

こうしてモックを使ってserviceとrepositoryをたくさん書いてM:Nの関係を持ち込むようになると 各serviceで自分の使わないmockを生成するのが馬鹿らしくなります。

そこで初めて各serviceで興味のあるメソッドだけを そのサービスにおいてinterfaceで定義したほうが良いと気が付くのです。

その話はまたいずれ。