DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

「テスタビリティの高いGoのAPIサーバを開発しよう」というハンズオンを公開しました

はじめに

SWETグループのGoチームの伊藤(@akito0107)です。 「テスタビリティの高いGoのAPIサーバを開発しよう」というタイトルでGoを用いてWeb APIを書くエンジニア向けのハンズオンを公開しました。 この記事ではハンズオンの内容と補足を紹介しようと思います。

ハンズオンのねらい

このハンズオンでは、APIサーバーを題材としてテスタビリティを担保した設計をするためには何が必要なのかを学んでもらうことを目的にしています。 特に、私自身がテスタビリティを考える上で重要だと考える、Dependency InversionやDependency Injection (DI), Test Doubleについて詳しく説明し、さらには自分で実装してもらう形をとっています。

Goでは他の言語と違い、デファクトのWeb Frameworkなどはありません(強いて言うなら標準がデファクトですが)。 そのため、Goで現実のプロダクトを開発する際には、必要に応じてライブラリ同士を組み合わせる、または自分たちで開発する必要があります。 その際の指標として役立つようなものを狙ってハンズオンを作成しました。

ハンズオンの内容

今回のハンズオンでは、Go言語を書いたことがある人や(言語問わずに)APIサーバーを実装したことがある人向けに、より「テスタビリティ」が高い設計にするためにはどうすればよいのか、といった内容を紹介しました。

以下がハンズオンとあわせて公開した資料となります。

speakerdeck.com

なお、Codelabの回答もrepository上に公開してありますので、もしわからないところがありましたら参照してください。

ハンズオンは[スライドを用いた講義]→[codelabを用いた実習]の流れを1Chapterとし、全部で3つのChapterから構成されております。 講義による知識のインプット + codelabで実際に手を動かすことによるアウトプットを体験することで、より実践的な技術を身に着けてもらうことを想定しております。

Codelabでは簡単なAPIサーバのサンプルアプリケーションを題材とし、テスタビリティが低い設計から高い設計へのリファクタを行っていきます。 その過程でテスタビリティの高い設計や、テストを書く際のテクニックなどが学べる構成になっています。

各チャプターは以下のような内容になっています。

  • Chapter1 テスタビリティについて
  • Chapter2 アーキテクチャについて
  • Chapter3 Test Doubleについて

以下にそれぞれのChapterの内容を簡単に説明します。

Chapter1 テスタビリティについて

最初のChapterでは、今回のハンズオンで一貫して対象とするテスタビリティの定義をしました。 テスタビリティには様々な定義があります。今回はソフトウェアテスト293の鉄則*1

テスト容易性とは可視性と操作性である

という定義を採用し、テスト対象システム(今回の場合はAPIサーバー)の可視性と操作性を上げるための設計について考えていきます。

f:id:swet-blog:20210909123728p:plain
Testabilityの定義について

Codelabではサンプルアプリケーションのビルドおよび次のChapter以降で行うリファクタの準備をしました。

Chapter2 アーキテクチャについて

このChapterではテスタビリティが高いアーキテクチャについて解説しました。 いくつか例はあるものの、APIサーバを実装する上で最も古典的な設計である3層アーキテクチャについて解説し、テスタビリティを担保する上で特に重要な概念であるDependency Inversion(依存関係逆転)について解説をしました。

f:id:swet-blog:20210909123918p:plain
3層アーキテクチャについて

CodelabではChapter1でビルドしたサンプルアプリケーションをリファクタし、3層アーキテクチャに直した上で、依存関係の整理ができるような設計にすることをゴールにしています。

Chapter3 Test Doubleについて

最後のChapterではTest Doubleについての紹介しました。mockやstubという言葉は聞いたことがあるかもしれませんが、それらの総称がTest Doubleです。 Test Doubleのメリットや分類を紹介し、最後にGoでの実装例を話しました。

f:id:swet-blog:20210909124059p:plain
Test Doubleについて

CodelabではChapter2でリファクタしたサンプルアプリケーションに対し、Test Doubleを用いたテストを追加していくということを行いました。

時間の都合上、Codelabを最後までやりきれなかった方もいらっしゃるかとは思いますが上記の通り資料は公開されており、また、Codelabの回答もrepository上に公開されていますので、時間のあるときに最後までチャレンジできるようになっています。

補足

以下にスライド・Codelabには盛り込めなかった話題について補足をしておきたいと思います。

結合度の高いテストについて

今回紹介したアーキテクチャは各レイヤーの結合度を下げ、各レイヤー単体でテストを書くことが簡単になるような点を目的として設計しています。 一方で、コードの記述量が増え、コードの見通しが悪くなる、といったデメリットもあげられるかもしれません。

講義の内部でも触れましたが、最近はミドルウェア(特にDocker)やライブラリの進化で、手元で簡単にDB等のインフラ環境が整えられるようになってきました。 Cloud Providerが提供しているコンポーネントも、以前であれば、手元に環境を再現するのが難しくテストを書こうとすると必然的にコード上でTest Doubleを用意する必要がありました。 しかし、現在ではAWSであればlocalstackや、GCPであれば公式が提供しているemulatorなどが出てきて、手元で環境を再現することが容易になってきました。 そのため、以前よりも結合度の高いテストを書くことは容易になってきており、相対的に単体のレイヤーで行うテストの重要性や必然性は薄れてきているかもしれません。

それでもあえて今回このある意味レガシーな設計を紹介したのはいくつかの意図があります。代表的なものを紹介します。

まず第一に、(これは当たり前ですが)いくらlocalで使えるコンポーネントが出てきたといっても全てがそろっているわけではないので、ある程度複雑なシステムを対象にする場合は、何かしらのTest Doubleを使う必要があるということ。

そして第二に、再現テストを書くための土台を作っておくということがあげられます。

再現テストとは、何かしらのバグが発生した際に、そのバグが発生するような状況を再現するために書くテストです。 再現テストを実装した上で、バグを修正し、テストが通ることを確認するというような使い方をします。 バグの発生原因を特定し、確実に修正されていることを確認できる手法で、これをやるかやらないかでバグ修正の際の安心感や効率が変わってくると感じています。

再現テストを実装する上では、例えばDBからエラーが帰ってきた場合など、特殊な状況・状態に依存することが多く、この状況・状態の再現は仮にエミュレータを使っていたとしても非常に難しくなる場合があります。 そういった場合でもTest Doubleなどを使い、システムの状態を簡単にいじれるようにしておくと簡単に再現テストを書くことができます。

実運用中にバグが発覚し、いざ再現テストを書こうと思ったときには外部環境に密結合でテストが書けないといった状況を防ぐためにも、やや冗長に感じられるかもしれませんが、外部から依存を注入できるような設計は今でも有効だと感じています。

より良いテストを書くために

Codelabで記述したテストコードは、繰り返しが多く冗長な実装になっていると感じるかもしれません。 本来は、テストコードを記述した後にテストコード自体のメンテナンス性を上げる必要があります。 GoであればTable Driven Testsなどを使うと良いかもしれないです。

以下の例はCodelabのChapter3のuserUsecaseのtestをTable Driven Testingを使うようにリファクタした例です。

func TestUser_Create2(t *testing.T) {
    cases := []struct {
        name      string
        mockFn    func(t *testing.T) *userRepositoryMock
        mock      *userRepositoryMock
        expectErr error
        in        *model.User
    }{
        {
            name: "success",
            mockFn: func(t *testing.T) *userRepositoryMock { // testing.Tをt.Run内で受け取る必要があるため関数でwrapしている
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        if email != "test@dena.com" {
                            t.Errorf("email must be test@dena.com but %s", email)
                        }
                        return nil, apierr.ErrUserNotExists
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
        },
        {
            name: "return user from repository",
            mockFn: func(t *testing.T) *userRepositoryMock {
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        return &model.User{}, nil
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
            expectErr: apierr.ErrEmailAlreadyExists,
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            userUsecase := NewUser(c.mockFn(t), nil)
            err := userUsecase.Create(context.Background(), c.in)

            if err != c.expectErr {
                t.Errorf("expectErr is: %v but actual: %v", c.expectErr, err)
            }
        })
    }
}

まとめ

Goのハンズオンを公開し、その内容の紹介と補足をしました。 講義を受けていなくても、スライド、Codelabそれぞれで完結して内容を把握できると思いますので、興味がありましたらぜひご覧になってみてください。

DeNAではGo以外にもAndroidのTestに関するハンズオンを公開しております。 せひそちらも覗いてみていただければと思います。

最後に、SWETでは一緒に働いてくれる人を募集しています。下記職種で募集しているのでぜひご応募ください。

career.dena.jp

*1:セム ケイナー,ジャームズ バック,ブレット ペティコード. ソフトウェアテスト293の鉄則