Go言語でのテストの並列化 〜t.Parallel()メソッドを理解する〜

この記事は、Merpay Tech Openness Month 2020 の6日目の記事です。

メルペイでBackendエンジニアをしている柴田@yoshiki_shibata)です。この記事では、Go言語のtestingパッケージに用意されている並列化の機能について説明します。

Go言語では、テストコードを作成するためのtestingパッケージが用意されています。一般に開発するソフトウェアの規模が大きくなるに従って、作成されるテストコードの量も多くなり、すべてのテストが終了するまでの時間も長くなっていきます。特に、データベースへアクセスするようなテストでは、データベースへの通信時間がテスト時間の多く占めますので、テストコードを逐次実行するよりは並列実行することで、テスト時間を短縮できます(厳密には用語「並行」ですが、t.Parallel()メソッドの説明なので、この記事では用語「並列」を使っています)。

この記事では、*testing.TParallel()メソッドについて解説します。

複数パッケージのテストを並列に実行する

testingパッケージを使ったテストコードの実行は、デフォルトでは逐次的です。ただし、逐次的なのは、ある特定のパッケージ内のテストに対してです。

複数のパッケージのテストを指定した場合、パッケージ単位でテストが並列に実行されます。たとえば、aパッケージとbパッケージがあった場合、aパッケージ内のテストコードは逐次実行され、bパッケージ内のテストコードも逐次実行されます。しかし、aパッケージとbパッケージのテストは並列に実行されます。この場合、どのようにして並列に実行されているかを次に説明します。

複数のパッケージを指定した場合(あるいは、./...とすべてのパッケージを指定した場合)、いくつのパッケージが並列にテストが実行されるかは、go testコマンドで指定する-pフラグ(実際には、buildフラグ)で決まります。go help build-pフラグの説明を表示すると、次のように表示されます。

    -p n
        the number of programs, such as build commands or
        test binaries, that can be run in parallel.
        The default is the number of CPUs available.

並列に実行可能なbuildコマンドあるいはテストバイナリといったプログラムの数。デフォルトは利用可能なCPUの数。

つまり、テストに関しては、-pフラグで指定された値のプロセス数までテストバイナリーを複数プロセスとして(並列に)実行します。-pフラブを指定しなければ、CPUの数がその値となります。また、どのパッケージをテストするかは自動的に割り振られます。つまり、各プロセス内では、逐次的に一つのパッケージのテストが実行されていることになります。仮に-p=1と指定すると、テストを実行しているプロセスは一つだけですので、すべてのテストがパッケージごとに逐次実行されることになります。

補足:-pフラグで1より大きな値を指定して、複数のパッケージを指定(あるいは、./...)してテストを実行して、テスト実行中に別のターミナルからpsコマンドを実行すれば、パッケージごとのテスト用バイナリが作られながら、パッケージごとにテストが行われているのが分かります。

-pフラグで大きな値を指定すれば、その指定した値の個数分のテストプロセスが生成されてテストの並列性は向上します。しかし、それは複数のパッケージのテストが並列に実行されるだけであり、個々のパッケージ内のテスト群が並列に実行されることにはなりません。パッケージ内のテストできめ細かな並列性を向上を行うためには、t.Parallel()メソッドを使います。

t.Parallel()メソッド

*testing.TにはParallel()メソッドがあります。t.Parallel()メソッドの使い方は、少し分かりにくいですが、正しく使うにはきちんと理解する必要があります。

Parallel()メソッドの仕様は、次の通りです。

func (t *T) Parallel()
    Parallel signals that this test is to be run in parallel with (and only
    with) other parallel tests. When a test is run multiple times due to use of
    -test.count or -test.cpu, multiple instances of a single test never run in
    parallel with each other.

Parallelは、このテストが他の並列のテストとだけ並列に実行されることを通知します。-test.countあるいは-test.cpuを使ってテストが複数回実行される場合には、ある単一のテストがそのテストと並列に動作することはありません。

では、簡単な例から説明します。

testingパッケージを使ったテストコードには、func TestXXX(t *testing.T)のシグニチャを持つトップレベルのテスト関数と、トップレベルのテスト関数内で、t.Run()を用いて記述するサブテスト関数があります。まずは、トップレベル関数だけにt.Parallel()メソッドを呼び出した場合の動作を説明します。

次のコードを見てください。

package main

import (
    "fmt"
    "testing"
)

func trace(name string) func() {
    fmt.Printf("%s entered\n", name)
    return func() {
        fmt.Printf("%s returned\n", name)
    }

}

func Test_Func1(t *testing.T) {
    defer trace("Test_Func1")()

    // ...
}

func Test_Func2(t *testing.T) {
    defer trace("Test_Func2")()
    t.Parallel()

    // ...
}

func Test_Func3(t *testing.T) {
    defer trace("Test_Func3")()

    // ...
}

func Test_Func4(t *testing.T) {
    defer trace("Test_Func4")()
    t.Parallel()

    // ...
}

func Test_Func5(t *testing.T) {
    defer trace("Test_Func5")()

    // ...
}

五つのテスト関数があり、Test_Func1Test_Func3Test_Func5が普通のテスト関数です。Test_Func2Test_Func4t.Parallel()メソッドを呼び出しています。go testコマンドで実行すると、次のように実行されます。

  1. Test_Func1が実行され、処理が完了します。
  2. 次に、Test_Func2の実行に移りますが、t.Parallel()メソッドを呼び出したところで一時停止します。
  3. Test_Func2の実行が停止した状態で、Test_Func3が実行され、処理が完了します。
  4. 次に、Test_Func4の実行に移りますが、t.Parallel()メソッドが呼び出されたとことで一時停止します。
  5. Test_Fun4の実行が停止した状態で、Test_Func5が実行され、処理が完了します。

t.Parallel()メソッドを呼び出していないTest_Func1Test_Func3Test_Func5が順にすべて実行されると、t.Parallel()メソッドを呼び出しているTest_Func2Test_Func4の処理が並列に再開して、処理が完了します。

実行結果は次の通りです。

=== RUN   Test_Func1
Test_Func1 entered
Test_Func1 returned                <- 1 (完了)
--- PASS: Test_Func1 (0.00s)
=== RUN   Test_Func2
Test_Func2 entered
=== PAUSE Test_Func2               <- 2 (一時停止)
=== RUN   Test_Func3
Test_Func3 entered
Test_Func3 returned                <- 3 (完了)
--- PASS: Test_Func3 (0.00s)
=== RUN   Test_Func4
Test_Func4 entered
=== PAUSE Test_Func4               <- 4 (一時停止)
=== RUN   Test_Func5
Test_Func5 entered
Test_Func5 returned                <- 5 (完了)
--- PASS: Test_Func5 (0.00s)
=== CONT  Test_Func2               <- 処理が再開
Test_Func2 returned                <- 完了
=== CONT  Test_Func4               <- 処理が再開
Test_Func4 returned                <- 完了
--- PASS: Test_Func2 (0.00s)
--- PASS: Test_Func4 (0.00s)
PASS

この実行結果から分かるように、t.Parallel()メソッドの呼び出しは、「一時停止してから再開する」ことに注意してください。一時停止した場合、=== PAUSEと表示され、処理が再開した場合、=== CONTと表示されます。

t.Parallel()メソッドを呼び出して一時停止しているテストの処理が再開する条件は、次の動作1です。

動作1:t.Parallel()メソッドを呼び出していない(パッケージ内の)すべてのトップレベルのテスト関数が終了してから、t.Parallel()メソッドを呼び出しているトップレベルのテスト関数の処理が再開して並列に実行されます。

この動作1は、トップレベルのテスト関数がt.Parallel()メソッドを呼び出していないが、その中に含まれるt.Run()によるサブテスト関数でt.Parallel()メソッドを呼び出していたとしても、それらのサブテスト関数の実行が終わるまで、次のトップレベルのテスト関数の実行には移らないことを意味します。

たとえば、Test_Func1を次のように書き直したとします(コード)。

func Test_Func1(t *testing.T) {
    defer trace("Test_Func1")()

    t.Run("Func1_Sub1", func(t *testing.T) {
        defer trace("Func1_Sub1")()
        t.Parallel()

        // ...
    })

    t.Run("Func1_Sub2", func(t *testing.T) {
        defer trace("Func1_Sub2")()

        t.Parallel()
        // ...
    })

    // ...
}

二つのサブテスト関数が追加され、それぞれが、t.Parallel()メソッドを呼び出しています。

実行すると、次のようになります。

=== RUN   Test_Func1
Test_Func1 entered
=== RUN   Test_Func1/Func1_Sub1
Func1_Sub1 entered                          <- Func1_Sub1が開始
=== PAUSE Test_Func1/Func1_Sub1             <- Func1_Sub1が一時停止
=== RUN   Test_Func1/Func1_Sub2
Func1_Sub2 entered                          <- Func1_Sub2が開始
=== PAUSE Test_Func1/Func1_Sub2             <- Func1_Sub2が一時停止
Test_Func1 returned                         <- Test_Func1の呼び出し戻り(*)
=== CONT  Test_Func1/Func1_Sub1             <- Func1_Sub1が再開
Func1_Sub1 returned                         <- Func1_Sub1が完了
=== CONT  Test_Func1/Func1_Sub2             <- Func1_Sub2が再開
Func1_Sub2 returned                         <- Func1_Sub2が完了
--- PASS: Test_Func1 (0.00s)                <- Test_Func1の結果表示
    --- PASS: Test_Func1/Func1_Sub1 (0.00s)
    --- PASS: Test_Func1/Func1_Sub2 (0.00s)
=== RUN   Test_Func2                        <- ここまでTest_Func2は実行されない
Test_Func2 entered
=== PAUSE Test_Func2
=== RUN   Test_Func3
Test_Func3 entered
Test_Func3 returned
--- PASS: Test_Func3 (0.00s)
=== RUN   Test_Func4
Test_Func4 entered
=== PAUSE Test_Func4
=== RUN   Test_Func5
Test_Func5 entered
Test_Func5 returned
--- PASS: Test_Func5 (0.00s)
=== CONT  Test_Func2
Test_Func2 returned
=== CONT  Test_Func4
Test_Func4 returned
--- PASS: Test_Func4 (0.00s)
--- PASS: Test_Func2 (0.00s)
PASS

実行結果から分かるように、Test_Func1ではt.Parallel()メソッドを呼び出していないので、そこに含まれるすべてのテストが完了するまで、次のTest_Func2の処理は行われません。言い換えると、トップレベルのテスト関数が全くt.Parallel()メソッドを呼び出していないと、そのパッケージのテストは、トップレベルのテスト関数が一つずつ逐次実行されます。もちろん、トップレベルのテスト関数の中でt.Run()によるサブテスト関数がt.Parallel()メソッドを呼び出していれば、含まれているサブテスト関数は並列実行されます。

この実行結果には、さらに別の注目すべき点があります。

動作2:t.Run()によるサブテスト関数がt.Parallel()メソッドを呼び出している場合、その親のトップレベルのテスト関数が「終了して戻る」まで、サブテスト関数はt.Parallel()メソッドの呼び出しで一時停止している。 (なお、この動作は、親のトップレベルのテスト関数がt.Parallel()メソッドを呼び出していても、呼び出していなくても同じ)

言い換えると、動作2は次のようにも表現できます。

動作2(別表現):t.Run()によるサブテスト関数がt.Parallel()メソッドを呼び出している場合、t.Parallel()メソッドで一時停止しているサブテスト関数が再開されるのは、親のトップレベルのテスト関数が「終了して戻った」後である。

動作1と動作2を合わせると、「並列性を最大限に向上させるには、トップレベルのテスト関数とその中のサブテスト関数の両方で、t.Parallel()メソッドを呼び出す必要がある」ことになります。そうすることで、パッケージ内のt.Parallel()メソッドを呼び出しているすべてのサブテスト関数が、一斉に並列に動作することになります。

並列レベル

一斉に並列に動作すると述べましたが、実際には同時に動作する個数は制限されています。どれだけの個数のテスト関数が並列に動作するかは、-parallelフラグで指定します。

    -parallel n
        Allow parallel execution of test functions that call t.Parallel.
        The value of this flag is the maximum number of tests to run
        simultaneously; by default, it is set to the value of GOMAXPROCS.
        Note that -parallel only applies within a single test binary.
        The 'go test' command may run tests for different packages
        in parallel as well, according to the setting of the -p flag
        (see 'go help build').

t.Parallelを呼び出しているテスト関数の並列実行を可能にします。このフラグの値は、同時に実行されるテストの個数の最大値です。デフォルトでは、GOMAXPROCSの値に設定されます。-parallelは、単一のテストバイナリ内だけに適用されることに注意してください。go testコマンドは、-pフラグの設定に応じて、異なるパッケージに対するテストを並列に実行します。

明示的に指定されない場合には、環境変数GOMAXPROCSの値となります。GOMAXPROCSの値は、明示的に設定されていなければ、(見かけ上の)CPUの個数です。

サブテストを含むテストの多くがデータベースへアクセスしているのであれば、並列レベルは明示的にCPUの個数より大きい値を指定した方がよいです。なぜなら、通信待ちとなっていることが多いからです。逆に、テスト内容がCPUでの処理を多く必要とする計算を行っているのであれば、大きな値を指定しても性能は向上しないことなります。

defer文とt.Cleanup()メソッド

テスト終了時に後処理を行う場合、defer文を使うのか、あるいは、t.Cleanup()メソッドを使うのかについては注意が必要です。トップレベルのテスト関数に関しては、基本は次の通りです。

  • トップレベルのテスト関数が、t.Run()メソッドによるサブテスト関数を含んででいなければ、defer文あるいはt.Cleanup()メソッドのどちらで後処理を記述してもよい。
  • トップレベルのテスト関数が、t.Run()メソッドによるサブテスト関数は含んでいて、そのすべてのサブテスト関数がt.Parallel()メソッドを呼び出していない場合、defer文あるいはt.Cleanup()メソッドのどちらで後処理を記述してもよい。
  • トップレベルのテスト関数が、t.Run()メソッドによるサブテスト関数を含んでいて、少なくともその一つのサブテスト関数がt.Parallel()メソッドを呼び出している場合、t.Cleanup()メソッドで後処理を記述する。

defer文は、その文が含まれる関数が戻る際に呼び出されます。前述のコードの実行例を見直してみてください。Test_Func1関数は、Func1_Sub1Func1_Sub2のサブテスト関数が終了する前に、戻っています(動作2)。そのため、Test_Func1関数に含まれるdefer文で遅延が指定された関数は、Func1_Sub1Func1_Sub2のサブテスト関数が一時停止後に処理を再開する前に呼び出されます(上記の実行結果中で、Test_Func1 returnedと表示されている位置に注意してください)。

たとえば、サブテスト関数が作成したテーブルのレコードを削除するといった後処理の呼び出しを、トップレベルのテスト関数がdefer文で遅延させても、サブテスト関数がt.Parallel()メソッドを呼び出していると、サブテスト関数の実行に先立って、defer文で指定された後処理の関数が呼び出されてしまいます。このような場合、defer文ではなく、t.Cleanup()メソッドを使って、後処理を記述します。

t.Cleanup()メソッドの仕様は次の通りです。

func (c *T) Cleanup(f func())
    Cleanup registers a function to be called when the test and all its subtests
    complete. Cleanup functions will be called in last added, first called
    order.

Cleanupは、このテストとそのサブテストのすべてが完了したときに呼び出される関数を登録します。Cleanupで登録された関数は、最後に追加された関数から順に呼び出されます。

t.Cleanup()メソッドで登録した関数は、すべてのサブテストが終了した時点で呼び出されると記述されています。

では、t.Run()メソッドで記述するサブテスト関数内での後処理はどうなるかというと、以下の通りです(基本的には上記の3項目と同じですが主語が異なっています)。

  • サブテスト関数が、さらにネストしたt.Run()メソッドによるサブサブテスト関数を含んでいなければ、defer文あるいはt.Cleanup()メソッドのどちらで後処理を記述してもよい。
  • サブテスト関数が、さらにネストしたt.Run()メソッドによるサブサブテスト関数を含んでいて、そのすべてのサブサブテスト関数がt.Parallel()メソッドを呼び出していない場合、defer文あるいはt.Cleanup()メソッドのどちらで後処理を記述してもよい。
  • サブテスト関数が、さらにネストしたt.Run()メソッドによるサブサブテスト関数を含んでいて、少なくとも一つのサブサブテスト関数がt.Parallel()メソッドを呼び出している場合、t.Cleanup()メソッドで後処理を記述する。

トップレベルのテスト関数とサブテスト関数に関する上記の六つの項目を覚えるのが面倒であれば、プロジェクトによっては、「t.Parallel()メソッドを使っているテストコードでは、後処理はt.Cleanup()で書く」と決めてもよいかもしれません。

まとめ

t.Parallel()メソッドを呼び出せば適当に並列化されると考えてしまうかもしれませんが、最大限に正しく並列化するには、この記事で説明したことをきちんと理解しておく必要があります。

まとめると以下の通りです。

  • -pフラグによる指定は、複数のパッケージのテストを並列に異なるプロセスとして実行することを指定する。-p=1では、パッケージが一つずつ実行されることになる。
  • t.Parallel()メソッドの呼び出しで、パッケージ内のトップレベルのテスト関数やサブテスト関数が並列に実行されることになる。
  • t.Parallel()メソッドを呼び出している(トップレベルを含む)テスト関数は、その親のテスト関数の呼び出しが戻るまで、t.Parallel()メソッド呼び出しによる一時停止の状態から処理を再開しない。
  • t.Parallel()メソッドによる並列レベルは、デフォルトでGOMAXPROCSの値である。明示的に変更するには、-parallelフラグで指定するか、環境変数GOMAXPROCSで設定する。
  • テスト関数内での後処理は、t.Cleanupメソッドもしくはdefer文を使うかは、含まれるサブテスト関数がt.Parallel()メソッドを呼び出しているかいないかで使い分ける必要がある。
  • t.Parallel()メソッドを使っていても、複数のパッケージのテストが同時に一つのテストプロセス内で実行されることはない

この記事では、私自身がメルペイ内の(あるマイクロサービスの)あるパッケージのテストを最大限に並列化した経験を通して、調査した事柄をまとめてみました。実際、そのパッケージのテスト時間は10%以下となり、かなり短くなりました。今回は説明していませんが、実際に並列化するためのプログラミング上の注意点については、機会があれば別途書きたいと思います。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加