every Tech Blog

株式会社エブリーのTech Blogです。

Go testにおける可読性を保つ方法を考える

はじめに

TIMELINE開発部の内原です。

本日はGo言語のテストにおける可読性について考えてみます。この記事を読んでいただいている皆さんにも、テストを書いていて以下のような問題を感じた経験があるのではないでしょうか。

  • 既存のコードに機能追加をするためテストコードにもテストケースを追加しようとしたが、テストコードが複雑で読み解きづらく、テストを追加するのに苦労した
  • テストケースの種類が多く、少しデータを追加しただけでも既存のテストが動かなくなる
  • テストデータの登録方法が複雑で、テストコードの実装以前に手間取る

上記のような問題に対処するべく、実践的なシナリオに従ってGo言語のテストコードを実際に書きつつ都度改善していくことにします。

仕様(ver.1)

  • ユーザ情報には名前、状態(有効、無効)とがある
  • 有効なユーザ一覧を返却する関数 LoadActive() を実装する。その際並び順はIDの昇順とする

データ構造と実行SQL

type User struct {
  ID    int    `db:"id"`
  Name  string `db:"name"`
  State int    `db:"state"`
)
CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  PRIMARY KEY (id)
);
SELECT
    *
FROM
    users
WHERE
    state=1
ORDER BY
    id;

テストコードの実装(抜粋)

LoadActive() 関数のテストコードとしては以下のようになりました。

テストデータとしてActive, Inactiveのユーザを複数件登録し、Activeのユーザのみが返却されること、並び順がIDの昇順であることを確認しています

func setupUser() {
    u := NewUserRepository()
    u.Create(model.User{Name: "user1", State: Active})
    u.Create(model.User{Name: "user2", State: Inactive})
    u.Create(model.User{Name: "user3", State: Active})
}

func teardownUser() {
    // データのクリーンアップ処理など
}

func TestUserLoadActive(t *testing.T) {
    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user3" {
        t.Fatalf("expected user3 but got %v", users[1].Name)
    }
}

仕様(ver.2)

ver.1の仕様に対し、以下の機能追加をすることになりました。

  • 新たにグループというデータ構造を設ける
  • ユーザは1つ以下のグループに属することができるものとする(しないこともできる)
  • LoadActive() が返却するユーザは、グループに属しているもののみとする

データ構造と実行SQL

type User struct {
  ID      int    `db:"id"`
  Name    string `db:"name"`
  State   int    `db:"state"`
  GroupID *int   `db:"group_id"`
)
type Group struct {
  ID   int    `db:"id"`
  Name string `db:"name"`
)
CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  group_id integer NULL,
  PRIMARY KEY (id)
);
CREATE TABLE groups (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  PRIMARY KEY (id)
);

SELECT
    u.*
FROM
    users u
JOIN
    groups g ON u.group_id=g.id
WHERE
    u.state=1
ORDER BY
    u.id;

テストコードの実装(抜粋)

LoadActive() 関数のテストコードにテストデータを追加して、新たに追加されたグループの仕様についてもテストされるようにしました。

新たにユーザ用のレコードを追加し、Activeであってもグループに属していないため返却されない、という確認をしています。

この時点ではテストコード自体には手を入れず、テストデータの追加のみを行いました。それでも追加仕様に対する確認要件は満たせているためです。

func setupUser() {
    g := NewGroupRepository()
    group, _ := g.Create(model.Group{Name: "group"})

    u := NewUserRepository()
    u.Create(model.User{Name: "user1", State: Active, GroupID: &group.ID})
    u.Create(model.User{Name: "user2", State: Inactive, GroupID: nil})
    u.Create(model.User{Name: "user3", State: Active, GroupID: &group.ID})
    u.Create(model.User{Name: "user4", State: Active, GroupID: nil})
    u.Create(model.User{Name: "user5", State: Inactive, GroupID: &group.ID})
}

func teardownUser() {
    // データのクリーンアップ処理など
}

func TestUserLoadActive(t *testing.T) {
    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user3" {
        t.Fatalf("expected user3 but got %v", users[1].Name)
    }
}

仕様(ver.3)

ver.2の仕様に対し、さらに以下の機能追加をすることになりました。

  • グループにも状態(有効、無効)を設ける
  • LoadActive() が返却するユーザは、有効なグループに属しているもののみとする

データ構造と実行SQL

CREATE TABLE users (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  group_id integer NULL,
  PRIMARY KEY (id)
);
CREATE TABLE groups (
  id integer NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  state integer NOT NULL,
  PRIMARY KEY (id)
);

SELECT
    u.*
FROM
    users u
JOIN
    groups g ON u.group_id=g.id
WHERE
    u.state=1 AND
    g.state=1
ORDER BY
    u.id;

テストコードの実装(抜粋)

ver.2 の対応と同じようにテストデータのパターンを増やすこともできますが、今でもそれなりにレコード数があるのにさらに増やすとなると、ユーザの状態、グループ所属有無、グループの状態の組み合わせぶんレコードを作らなければならず、考えただけでも大変そうです。

だんだんとテストを書くのが辛くなってきました。というわけでアプローチを変えてみます。

そもそも LoadActive() が提供している機能はなんでしょうか?

  1. 指定の条件に合致したレコードを返却する
  2. レコードの並び順を定まったものにする

上記の2つであると考えられそうです。分かりやすくするため、上記それぞれについてテストを分けてみます。

1番目については、単に返却されるかされないかだけに着目すればよいので、1件のデータのみを対象とすることにします。 またその際テーブル駆動テストのアプローチを用いて、全組み合わせのテストデータを用意したとしても、テストコードが冗長にならないようにします。

// LoadActive の並び順についてのテスト
func TestUserLoadActive_Order(t *testing.T) {
    setupUser := func() {
        g := NewGroupRepository()
        group, _ := g.Create(model.Group{Name: "group", State: Active})

        u := NewUserRepository()
        u.Create(model.User{Name: "user1", State: Active, GroupID: &group.ID})
        u.Create(model.User{Name: "user2", State: Active, GroupID: &group.ID})
    }

    t.Cleanup(teardownUser)
    setupUser()

    u := NewUserRepository()
    users, err := u.LoadActive()
    if err != nil {
        t.Fatalf("expected no error but got %v", err)
    }
    if len(users) != 2 {
        t.Fatalf("expected 2 users but got %v", len(users))
    }
    if users[0].Name != "user1" {
        t.Fatalf("expected user1 but got %v", users[0].Name)
    }
    if users[1].Name != "user2" {
        t.Fatalf("expected user2 but got %v", users[1].Name)
    }
}

// LoadActive の返却条件についてのテスト
func TestUserLoadActive_Condition(t *testing.T) {
    tests := []struct {
        name       string
        userState  int64
        hasGroup   bool
        groupState int64
        hasUser    bool
    }{
        {"active user,active group", Active, true, Active, true},
        {"active user,inactive group", Active, true, Inactive, false},
        {"active user,no group", Active, false, Inactive, false},
        {"inactive user,active group", Inactive, true, Active, false},
        {"inactive user,inactive group", Inactive, true, Inactive, false},
        {"inactive user,no group", Inactive, false, Inactive, false},
    }

    setupUser := func(userState int64, hasGroup bool, groupState int64) {
        var groupID *int64
        if hasGroup {
            g := NewGroupRepository()
            group, _ := g.Create(model.Group{Name: "group", State: groupState})
            groupID = &group.ID
        }

        u := NewUserRepository()
        u.Create(model.User{Name: "user", State: userState, GroupID: groupID})
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Cleanup(teardownUser)
            setupUser(tt.userState, tt.hasGroup, tt.groupState)

            u := NewUserRepository()
            users, err := u.LoadActive()
            if err != nil {
                t.Fatalf("expected no error but got %v", err)
            }
            if tt.hasUser {
                if len(users) != 1 {
                    t.Fatalf("expected 1 user but got %v", len(users))
                }
            } else {
                if len(users) != 0 {
                    t.Fatalf("expected 0 user but got %v", len(users))
                }
            }
        })
    }
}

さらなる改善

現時点でもそれなりに読みやすいテストコードにはなったと思いますが、まだテストデータの登録処理においていくつか課題があります。

  • テストでは意識する必要がなくとも非NULLなカラム(Group.Nameなど)にはなんらか値を指定しなければならない
  • データの依存関係をテストコード内で意識しておかなければならない
  • 作成処理のエラーハンドリングを省略しており、仮に登録に失敗していた場合テスト自体も正常に動作しなくなる

簡単にテストデータを作成するために factory-go というライブラリを利用することにします。 これはRuby on Railsでよく用いられる factory_bot というライブラリにインスパイアされたもので、使い方は似ています。

以下のようなFactoryを用意しておきます。 usersがgroupsに依存しているため、SubFactoryという機能を用いています。

var UserFactory = factory.NewFactory(
    &model.User{},
).SeqInt64("ID", func(n int64) (interface{}, error) {
    return n, nil
}).Attr("Name", func(args f.Args) (interface{}, error) {
    user := args.Instance().(*model.User)
    return fmt.Sprintf("username-%d", user.ID), nil
}).Attr("State", func(args f.Args) (interface{}, error) {
    return Active, nil
}).SubFactory("Group", GroupFactory).OnCreate(func(args f.Args) error {
    m := args.Instance().(*model.User)
    return insertUser(m)
})

func insertUser(m *model.User) error {
    if m.Group != nil {
        m.GroupID = &m.Group.ID
    }
    _, err := // INSERT INTO usersする処理
    return err
}

var GroupFactory = factory.NewFactory(
    &model.Group{},
).SeqInt64("ID", func(n int64) (interface{}, error) {
    return n, nil
}).Attr("Name", func(args f.Args) (interface{}, error) {
    group := args.Instance().(*model.Group)
    return fmt.Sprintf("groupname-%d", group.ID), nil
}).Attr("State", func(args f.Args) (interface{}, error) {
    return Active, nil
}).OnCreate(func(args f.Args) error {
    m := args.Instance().(*model.Group)
    return insertGroup(m)
})

func insertGroup(m *model.Group) error {
    _, err := // INSERT INTO groupsする処理
    return err
}

上記のようなFactoryを用意しておくことで、テストコードの登録処理が以下のように簡略化できます。

  • MustCreate... の関数は登録処理に失敗するとpanicするため、正しいテストデータが準備できていないままテストが続行されるということはなくなる
  • テストにおいて関心のないカラムについては指定する必要がなくなる(指定してもよい)
  • データの依存関係についてテストコード側で把握しておく必要はなく、Factoryの使用方法を理解しておけば適切なデータ生成が行われる
func TestUserLoadActive_Order(t *testing.T) {
    setupUser := func() {
        UserFactory.MustCreateWithOption(map[string]interface{}{"Name": "user1"})
        UserFactory.MustCreateWithOption(map[string]interface{}{"Name": "user2"})
    }
    // ...
}

func TestUserLoadActive_Condition(t *testing.T) {
    // ...
    setupUser := func(userState int64, hasGroup bool, groupState int64) {
        var group *model.Group
        if hasGroup {
            group = GroupFactory.MustCreateWithOption(map[string]interface{}{
                "State": groupState,
            }).(*model.Group)
        }

        User.MustCreateWithOption(map[string]interface{}{
            "State": userState,
            "Group": group,
        })
    }
    // ...
}

まとめ

今回はGo言語におけるテストコードの可読性を上げるアプローチについて、実際にコードを交えながら考えてみました。

テストコードは挙動を担保する重要な役割を持っていますが、テストコード自体のメンテナンス性が下がると徐々に十分なテストが行われない状態に陥いりがちです。

そういった将来の問題を避けるためにも、自分がテストコードを書くタイミングで、他人が見て理解しやすいコードになっているかを意識しておくのが重要と考えています。