🏃‍♂️

自動テストの実行時間を大幅短縮!分析と最適化の実践法

2024/07/12に公開

Thinkings 株式会社では、sonar ATS の開発で自動テストを導入しています。過去に CI の実行時間を大幅に削減したことで全体の実行時間は短くなりました。自動テストの速度改善は手が回っていなかったので、CI 実行時間のボトルネックになっていました。今回は自動テストの実行時間を短縮するためにどうやって分析を行ってテストコードを改善したかについて説明します。

開発環境

開発環境は次の通りです。今回はバックエンドの改善内容について説明します。

  • Visual Studio 2022
  • .NET Framework 4.6.2
  • C#
  • xUnit.net

実行時間の分析方法について

まずは、自動テストのボトルネックを分析する方法について説明します。前回もお話しましたが、弊社では CI/CD ツールに Jenkins を使用しています。自動テストは1日に数回実行しており、その実行結果をアップロード、BI ツールで収集することで分析を行っています。ざっくり、次のようなイメージです。


自動テスト結果を分析するまでの流れ

BI ツールは、Power BI を使用しています。Power BI は Azure Blob Storage にアップロードされたテスト結果ファイル (.trx) を収集します。テスト結果ファイルは Jenkins で自動テストを実行すると生成されるように設定しているため、CI 終了後に Azure Blog Storage へアップロードしています。テスト結果ファイルにはテストケースごとの実行時間などが記録されているため、これを解析してボトルネックを分析しています。

テストケースの直列実行と並列実行

ボトルネックの対策について説明する前に、前提知識としてテストケースの直列実行と並列実行について説明します。

近年のテスティングライブラリでは、テストは標準で並列実行されます。並列実行とは、以下の図のようにスレッドごとにテストが実行されることを言います。


テストケースの並列実行イメージ

古くから存在するテスティングライブラリ(e.g. MSTestV1)だと、直列実行されます。


テストケースの直列実行イメージ

並列実行は、主にテスト実行環境の CPU の論理プロセッサの数だけテストを並列に実行することを意味します。厳密にはほかアプリケーションとの兼ね合いがあるので異なりますが、イメージとしてはこのように認識しておけば問題ありません。直列実行は全てのテストケースを順番に実行します。そのため、CPU コア数が複数が当たり前の現代では、並列実行のほうが結果的に全体のテスト実行時間が短くなる傾向にあります。

ただし、この並列実行については どの単位で並列化するのか? が重要になります。

xUnit.netの並列実行単位

xUnit.net では、標準ではテストクラス単位で並列実行されます。この並列実行の単位をTest Collectionsと呼び、必要に応じてカスタマイズすることもできます。そのため、「1つのテストクラスに多数のテストケースを書く」よりも「複数のテストクラスに少量のテストケースを書く」ほうが全体の実行時間短縮に繋がる可能性が高いです。

ほかにも並列実行を無効化したり、コマンドライン引数でオプションを変更する方法も記載しています。詳しい説明は下記を参照してください。
cf. Running Tests in Parallel > xUnit.net

ボトルネックの対策と改善結果

テスト実行時間のボトルネックと実際のテストコードを分析した結果、原因はいくつかのパターンに分類できました。今回はその中でも特に効果が高かった次の3つについて、紹介したいと思います。

  • テストケースを複数クラスに分割する
  • インスタンスの生成回数を減らす
  • LINQ を即時実行する

テストケースを複数クラスに分割する

問題点

テストクラス内に数十~数百のテストケースが定義されているため、ひとつのテストクラスが直列実行されてしまう。

原因

xUnit.net では、テストクラス単位で並列実行されているため、テストクラス内に膨大な数のテストケースが定義されていると直列実行されます。ひとつひとつのテスト実行時間が短くても直列実行されることで全体のテスト実行時間のボトルネックとなっていました。

namespace Example.Tests
{
    public class ManyTestCaseTest
    {
        [Fact]
        public void TestCase001()
        {
            ....
        }

        [Fact]
        public void TestCase002()
        {
            ....
        }

....
        [Fact]
        public void TestCase200()
        {
            ....
        }
}

解決策

ここでの解決策はテストクラスを分割することです。ただ、分割方法もいくつか手段があります。

  1. 内部クラスに分割する
  2. ファイル単位でクラスを分割する

内部クラスに分割する場合、既存のテストクラス内部に細分化したテストクラスを作成し、そこにテストケースを移行させます。ファイル単位でクラスを分割する場合、別のテストクラスを作成する必要があるため手間はかかりますが、効果としては内部クラスに分割した場合と同じです。また、ファイルが分かれる分、1ファイルあたりの可読性が高まる効果もあります。今回は変更が容易な1.を選択しました。

以下は、実際に内部クラスに移行することによりテスト実行時間の短縮に成功したテストクラスの差分コードです。

namespace Example.Tests
{
    public class ManyTestCaseTest
    {
+        public class ManyTestCasePattern001Test
+        {
            [Fact]
            public void TestCase001()
            {
               ....
            }

            [Fact]
            public void TestCase002()
            {
               ....
            }
+        }

+        public class ManyTestCasePattern002Test
+        {
            [Fact]
            public void TestCase003()
            {
               ....
            }
+        }
....
}

結果

この対策を実施したことでテスト実行時間は大幅に短縮されました👍👍👍
対策を実施した前後のテスト実行時間の変化は次のようになりました。

  • 対策前の実行時間: 306秒
  • 対策後の実行時間: 180秒
  • 差異: -126秒


テスト実行時間の変遷

インスタンスの生成回数を減らす

問題点

多くのテストケースから毎回インスタンスを生成していました。ここで生成しているインスタンスの機能は、呼び出し元に関わらず同じです。

原因

テストケースそのものには問題がなくても、使用する変数やメソッドの戻り値に使用しているインスタンスが毎回生成されることにより、無駄に時間がかかっていました。インスタンスの生成は処理速度のボトルネックになりがちです。

実際にテストクラスでは次のような初期化処理があり、各テストケースはCreateContextメソッドを呼び出すようにしていました(200ケース以上)。CreateContextメソッドでは、Moqを使ってモックオブジェクトの生成を行っていますが、モックのセットアップコードが数百行ありました。

public class ManyTestCaseTest
{
    private static IDatabaseConnection CreateContext()
    {
        var connectionMock = new Mock<IDatabaseConnection>();
        connectionMock.Setup(...).Returns(...);
        connectionMock.Setup(...).Returns(...);
        connectionMock.Setup(...).Returns(...);
        // これが数百行続く。

        return connectionMock.Object;
    }

    [Fact]
    public void TestCase001()
    {
        var connection = CreateContext();

        .... 省略
    }

    [Fact]
    public void TestCase002()
    {
        var connection = CreateContext();

        .... 省略
    }
}

解決策

ここでは、モック変数をキャッシュすることで解消しました。ほぼすべてのテストケースでCreateContextが呼び出されていましたが、毎回生成されるモックは同一であるため一度インスタンス化してしまえば使い回すことができます。差分は次のようになります。

public class ManyTestCaseTest
{
+   private static Mock<IDatabaseConnection> _connectionMock;

    private static IDatabaseConnection createContext()
    {
+        if (_connectionMock != null) return _connectionMock.Object;

-        var connectionMock = new Mock<IDatabaseConnection>();
-        connectionMock.Setup(...).Returns(...);
-        connectionMock.Setup(...).Returns(...);
-        connectionMock.Setup(...).Returns(...);
+        _connectionMock = new Mock<IDatabaseConnection>();
+        _connectionMock.Setup(...).Returns(...);
+        _connectionMock.Setup(...).Returns(...);
+        _connectionMock.Setup(...).Returns(...);
        ...
        
-        return connectionMock.Object;
+        return _connectionMock.Object;
    }
}

結果

この対策を実施したことでテスト実行時間は大幅に短縮されました👍👍👍
対策を実施した前後のテスト実行時間の変化は次のようになりました。

  • 対策前の実行時間: 191秒
  • 対策後の実行時間: 95秒
  • 差異: -100秒


テスト実行時間の変遷

LINQ を即時実行する

問題点

テスト対象メソッドの戻り値がIEnumerable<T>の場合、以降の実装次第でボトルネックになる場合があります。.NET Framework と .NET にはコレクションをクエリ構文によって制御することができる API として LINQ が提供されています。この LINQ には遅延評価と呼ばれる仕組みが存在します。詳細は次の資料を参照してください。

原因

次のようにUserService.GetUserListの戻り値を検証していますが、値をキャッシュしていないため.Count().ToList()が呼ばれるたびに式が評価されます。

テスト対象クラス

namespace ExampleProject
{
    public class UserService
    {
        public IEnumerable<User> GetUserList()
        {
            ... 省略
        }
    }
}

テストクラス

namespace Example.Tests
{
    public class UserServiceTest
    {
        [Fact]
        public void GetUserList_ユーザー情報の初期状態はユーザー氏名の昇順に並んでいること()
        {
            var userService1 = new UserService();
            var userService2 = new UserService();

            IEnumerable<User> data1 = userService1.GetUserList();
            IEnumerable<User> data2 = userService2.GetUserList();

            Assert.Equal(data1.Count(), data2.Count());

            for (int i = 0; i < data2.Count(); i++)
            {
                Assert.Equal(data1.ToList()[i].Name, data2.ToList()[i].Name);
            }
        }
    }
}

GetUserListメソッドは内部で LINQ 構文を使用していて、テストケースの以下コードで呼び出した時点では評価されません。

IEnumerable<User> data1 = userService1.GetUserList();
IEnumerable<User> data2 = userService2.GetUserList();

実際に評価されるのはAssert.Equal(data1.Count(), data2.Count());のタイミングです。また、for (int i = 0; i < data2.Count(); i++)でも式が評価されます。特に繰り返し処理では、繰り返し条件に設定していると毎回評価されてしまいます。

解決策

上記のようなテストケースは期待値を取得した時点で値を確定してしまうことで対策できます。LINQ においてこのような操作を「即時実行」と呼び、開発者が任意のタイミングで実施できます。LINQ を即時実行するには、IEnumerable<T>.ToList()IEnumerable<T>.ToArray()を呼び出します。問題点のコードの場合は次のように変更できます。

        [Fact]
        public void GetUserList_ユーザー情報の初期状態はユーザー氏名の昇順に並んでいること()
        {
            var userService1 = new UserService();
            var userService2 = new UserService();

-            IEnumerable<User> data1 = userService1.GetUserList();
-            IEnumerable<User> data2 = userService2.GetUserList();
+            User[] data1 = userService1.GetUserList().ToArray();
+            User[] data2 = userService2.GetUserList().ToArray();

+            var data1Count = data1.Count();
+            var data2Count = data2.Count();
-            Assert.Equal(data1.Count(), data2.Count());
+            Assert.Equal(data1Count, data2Count);

-            for (int i = 0; i < data2.Count(); i++)
+            for (int i = 0; i < data2Count; i++)
            {
-                Assert.Equal(data1.ToList()[i].Name, data2.ToList()[i].Name);
+                Assert.Equal(data1[i].Name, data2[i].Name);
            }
        }

結果

この対策を実施したことでテスト実行時間は大幅に短縮されました👍👍👍
対策を実施した前後のテスト実行時間の変化は次のようになりました。

  • 対策前の実行時間: 185秒
  • 対策後の実行時間: 56秒
  • 差異: -129秒


テスト実行時間の変遷

まとめ

これらの対策を実施した結果、合計で300秒ほどテスト実行時間が短くなりました🎉✨

基本的にはボトルネックの分析 → テストコードの修正 → 分析… を繰り返して、徐々に速いテストコードになるように目指しました。まだまだこれら以外にも改善の余地があるテストコードは数多くあるので、定期的に測定と改善をやっていこうと思います💪

Thinkingsテックブログ

Discussion