UPSIDER Tech Blog

決済チームがテストコードを書く際に気を付けていること

こんにちは。決済チームでエンジニアとして働いている芦川です。

UPSIDER Tech blog 第2弾として「決済チームがテストコードを書く際に気をつけていること」を紹介しようと思います。

TL;DR

  • 100%のテストカバレッジを目指す
  • テストはブラックボックスを優先して記述、どうしても到達できない場合はホワイトボックス
  • 最初のテストケースは、テスト対象が動作する最も一般的なケースであるべき

私たちは日々大量のコードを書いており、そのシチュエーションは多岐にわたります。

そういった環境において、動作確認からのコード改修のコストを考えた場合、自動テストの有無によって生産性に大きく差が出ることは容易に想像ができます。また、既存のサービスに改修を加えるために、そのサービスの概要を把握したい場合、良いテストコードはドキュメントとして役立ちます。

以前、私はテストコードを一切書かないプロダクトの開発に従事していたこともありますが、今となってはどういう理屈でプログラムが正常に動作していたのか不思議でなりません。おそらく超自然的な何らかの力が作用していたものと考えられますが、その点についての言及は題とずれますので今回は控えさせていただきます。

というわけで、今回は私たちがテストコードを書く場合、指標として取り扱っている項目のうち、代表的なものを3つほど紹介しようと思います。


100%のテストカバレッジを目指す

これは私たちのチームが最も重要視している指標の一つです。

私たちが提供している決済サービスが正常に動作していない場合、お客様に与える影響は計り知れません。例えば、「お客様が本来拒否すべきである決済を誤って成立させてしまう」といったことも考えられます。

UPSIDERには、日次のリミットを設定できる機能があります。これは1日に利用できる金額に制限を設けるといったものです。上限を超えるような金額の決済が発生した場合に、それが正常に処理されなかったとすると、お客様からすると想定外の利用が発生してしまったことになります。

もしこういったことが頻繁に起こると、お客様に新しい機能を提供した際にも信用低下を原因に利用していただけず、より良い体験が提供できなくなってしまいます。そのようなことが起こらないよう、お客様に信用いただける品質の機能を提供するために、私たちは自動テストを重厚に書いています。

どうしても到達できないコード以外は絶対にテストケースを書きますし、ストアされているデータのパターンについても複数パターンを用意してテストします。

計測手法としてgo testカバレッジを利用するのは有用ですが、それだけを満たせば良いというわけではありません。決済システムにおいては、より網羅性の高いテストを目指すべきなので、go testで得られるようなC0のステートメントカバレッジを高い基準で満たすのは当然のこととして、C1やC2といったより複合的なカバレッジについても100%を満たすよう努力しています。


テストはブラックボックスを優先して記述し、どうしても到達できない場合にホワイトボックステストを書く

Go言語においてテストを書く場合のパッケージについて、大きく二つに分けることができます。packagename_testパッケージ名でファイルを作成する方法と、packagenameのパッケージ名でテストファイルを作成する方法であり、前者をブラックボックステスト、後者をホワイトボックステストと呼んでいます。

ホワイトボックステストの場合、そのパッケージ内のプライベートな関数や変数に直接アクセスすることができるため網羅性も高まりますし、基本的にテストコード自体の書きやすさも得られます。

しかし、ホワイトボックステストの採用には、以下のような欠点もあります。

  1. アクセシビリティを意識しないことから、必要以上の情報を公開してしまう恐れがある
  2. エッジケースの判断が難解になる
  3. 本来必要ない実装を見逃す恐れがある

1, 2は直感的なことだと思いますので、3つ目の観点を深掘りたいと思います。

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

package pkg

import (
    "errors"
)

func GetUserWithCurrentUsage(userID string) (*User, int, error) {
    u, err := getUser(userID)
    if err != nil {
        return nil, 0, err
    }
    cu, err := getCurrentUsage(userID)
    if err != nil {
        return nil, 0, err
    }
    return u, cu, err
}

func getUser(userID string) (*User, error) {
    if userID == "" {
        return nil, errors.New("userID is empty")
    }
    req := &GetUserRequest{
        UserID: userID,
    }
    return callUserService(req)
}

func getCurrentUsage(userID string) (int, error) {
    if userID == "" {
        return 0, errors.New("userID is empty")
    }
    req := &GetCurrentUsageRequest{
        UserID: userID,
    }
    return callUsageService(req)
}

これはユーザのデータと現在の使用量を取得する関数群です。

ホワイトボックスで全てのテストを記述した場合、容易に100%のカバレッジを得ることができるでしょう。しかし、ブラックボックステストのみを記述した場合、それは不可能です。

よく見てみるとgetCurrentUsageでのuserIDのバリデーションは、すでにgetUserで同様のバリデーションが行われていることがわかります。そのため、公開されている関数であるGetUserWithCurrentUsageのみをテストするとその条件分岐が不要であることがわかります。

この結果を経てリファクタリングすると、以下のようなコードになるでしょう。

package pkg

import (
    "errors"
)

func GetUserWithCurrentUsage(userID string) (*User, int, error) {
    // add
    if userID == "" {
        return nil, errors.New("userID is empty")
    }
    u, err := getUser(userID)
    if err != nil {
        return nil, 0, err
    }
    cu, err := getCurrentUsage(userID)
    if err != nil {
        return nil, 0, err
    }
    return u, cu, err
}

func getUser(userID string) (*User, error) {
    // remove
    // if userID == "" {
    //     return 0, errors.New("userID is empty")
    // }
    req := &GetUserRequest{
        UserID: userID,
    }
    return callUserService(req)
}

func getCurrentUsage(userID string) (int, error) {
    // remove
    // if userID == "" {
    //     return 0, errors.New("userID is empty")
    // }
    req := &GetCurrentUsageRequest{
        UserID: userID,
    }
    return callUsageService(req)
}

これによりブラックボックステストのみ記述することで、100%のカバレッジを達成することができます。こういった背景から、私たちは基本的にブラックボックスの形でテストコードを記述しています。

その上で、到達できないエッジケースのテストをホワイトボックスに閉じ込めることで、ブラックボックステストのコードを陳腐化させず、かつドキュメンテーションの役割を持たせることも意識しています。


最初のテストケースは、テスト対象が動作する最も一般的なケースであるべき

前提として、私たちはテストケースをテーブルドリブンで記述しています。

テーブルドリブンテストはGo言語のテストコードで広く採用されている手法で、以下のようにケース単位でテスト内容を記述する方法です。

テーブルドリブンテストをmapではなくsliceで定義する方法もありますが、テストケースにタイトルは必須だと考えているため、弊社ではmapで表現しています。

func TestSum(t *testing.T) {
    cases := map[string]struct {
        num1 int
        num2 int

        want    int
        wantErr error
    }{
        "Success case: 1 + 2": {
            num1: 1,
            num2: 2,
            want: 3,
        },
        "Success case: 106 + 24": {
            num1: 106,
            num2: 24,
            want: 130,
        },
        "Fail case: Max value that can be represented by an int is exceeded": {
            num1   : 9223372036854775807,
            num2   : 1,
            wantErr: invalidLengthErr,
        },
    }

    for name, tc := range cases {
        t.Run(name, func(t *testing.T) {
            res, err := mypackage.Sum(tc.num1, tc.num2)

            // 実際のアサーションにはgo-cmpを利用しています
            if res != tc.want {
                t.Errorf("received data didn't match: want %#v, got %#v", tc.want, res)
            }
            if !errors.Is(err, tc.wantErr) {
                t.Errorf("error didn't match: want %#v, got %#v", tc.wantErr, err)
            }
        })
    }
}

テーブルドリブンテストの効力として、うまく活用すればテストケースがそのパッケージのドキュメンテーションになりうるというものがあります。上述の例でも何に利用するかが明確で、かつどう言った場合にエラーとなりうるかを一覧化することができています。

そのため、パッケージの利用者はテストコードを閲覧することで、利用方法と利用に際して気を付けるべきことを簡潔に理解することができます。

さて、この利点を最大限に活かすために気をつけるべきことが、タイトルの通り「最初のテストケースは、テスト対象が動作する最も一般的なケースであるべき」というものです。

以下の例をご覧ください

func TestSetTransactionUpperLimit(t *testing.T) {
    cases := map[string]struct {
        userID string
    limit  int

        want    int
        wantErr error
    }{
        "Success case: Simple case": {
            userID: "some-user-id",
            limit : 1,

            want: 1,
        },
        "Fail case: Specify a negative value": {
            userID: "some-user-id",
            limit : -10000,

            wantErr: errNegativeValue,
        },
    }

    for name, tc := range cases {
        t.Run(name, func(t *testing.T) {
            res, err := userpackage.SetTransactionUpperLimit(tc.userID, tc.limit)

            if res != tc.want {
                t.Errorf("received data didn't match: want %#v, got %#v", tc.want, res)
            }
            if !errors.Is(err, tc.wantErr) {
                t.Errorf("error didn't match: want %#v, got %#v", tc.wantErr, err)
            }
        })
    }
}

これは、決済あたりの取引額を制限する機能のセットアップ部分のテストコードを極度に簡略化したものです。このテストコードは実際に動作するでしょうし、カバレッジは満たしています。

しかし、ドキュメンテーションとしての能力は低いと言わざるを得ません。パッケージの利用者は、このテストケースを見ただけでは、関数の利用シーンについて混乱してしまうでしょう。

お分かりかと思いますが、その理由は上限の設定にあります。

実際にこの機能の利用シーンを想定した場合、ユーザは1円を決済あたりの取引額の上限に設定するでしょうか?もしかしたらそういった場合もあるかもしれませんが、基本的にそれはエッジケースでしょう。

このテストの最初のテストケースは、より高額な値であるべきです。利用シーンは複数想定されるため、絶対的に正しい値というのはありませんが、より実務に沿った値とすることが重要です。一例として、私たちの実際のテストコードの最初のケースでは300000を設定しています。

このように、テストを書く際に実際のユースケースを想定して記述することで、テストコードに単なる単体テストで終わらせる以上の価値を付与することを重要視しています。


今回は以上となります、お読みいただきありがとうございました。今後も共有の価値があるインサイトがあれば、また記事にしようと思っていますので、その際はまたご覧いただけると嬉しいです。

宣伝

UPSIDERでは、成長企業のための法人カード「UPSIDER」と、すべてのBtoB取引でクレジットカードを利用できるビジネスあと払いサービス「支払い.com」を提供しております。

まだまだプロダクトで実現したいことがたくさんあり、プロダクトの急激な成長に伴う課題も増えている中で、一緒に前に進めてくれるエンジニアを絶賛募集中です。

カジュアル面談もやっておりますので、少しでもご興味のある方は、ぜひご連絡ください!!!

career.up-sider.com

herp.careers