Automation Spec

既存の自動化テスト フレームワークに追加された「Spec」と呼ばれる新しいタイプの自動化テストの概要です。

Choose your operating system:

Windows

macOS

Linux

既存の自動化テスト フレームワークに新しいタイプの自動化テストを追加しました。この新しいタイプは Spec と呼ばれています。「Spec」とは、 ビヘイビア駆動開発 (BDD) 手法に従って構築されたテストを表す用語です。これは Web 開発のテストで非常によく使用される手法であり、Unreal Engine の C++ フレームワークに導入しました。

Spec での記述をお勧めする理由には、次のようなものがあります。

  • 自己文書化コードを作成できる

  • 流れるように (fluent) 記述でき、より DRY なコードになることが多い

    DRY (Don't Repeat Yourself の略、繰り返しを避けること)

  • スレッド化されたテスト コードや潜在的なテスト コードを記述するのが格段に容易である

  • エクスペクテーション (テスト) を分離できる

  • ほとんどの種類のテスト (機能、統合、ユニット) で使用できる

Spec の設定方法

Spec のヘッダを定義する方法は 2 通りあり、どちらもテスト タイプの定義に従来使用している方法に似ています。

最も簡単な方法は、その他すべてのテスト定義マクロと全く同じパラメータを取る DEFINE_SPEC マクロを使用することです。

DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter| EAutomationTestFlags::ApplicationContextMask)
void MyCustomSpec::Define()
{
    //@todo write my expectations here
}

その他は、BEGIN_DEFINE_SPEC マクロと END_DEFINE_SPEC マクロを使用する方法以外にありません。これらのマクロを使用すると、テストの一部として独自のメンバーを定義できます。次のセクションで説明しますが、このポインターを使って相対的に内容を指定すると便利です。

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomSpec", EAutomationTestFlags::ProductFilter| EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyAwesomeClass> AwesomeClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    //@todo write my expectations here
}

その他に注意が必要なことは、他のテスト タイプのように RunTests() メンバーを使用するのではなく、Spec クラスの Define() メンバーの実装を記述する必要があるということだけです。

Spec を定義するファイルは拡張子を .spec.cpp にし、名前には「Test」という語を含めません。例えば、FItemCatalogService クラスの場合、ItemCatalogService.hItemCatalogService.cppItemCatalogService.spec.cpp のようなファイルを用意します。

これは推奨されるガイドラインであり、技術的な制限ではありません。

エクスペクテーションを定義する方法

BDD の大半は、個々の実装のテストではなく、パブリック API のエクスペクテーションのテストを行います。これにより、テストの堅牢性が大幅に向上するため、保守が容易になります。また、同じ API の複数の実装が作成されても、問題なく動作する可能性が高まります。

Spec では、2 つの主要な関数、Describe()It() を使用して、エクスペクテーションを定義します。

Describe

Describe() は、より読みやすく、より DRY なものになるように、複雑なエクスペクテーションの内容を指定する手段として使用されます。後述しますが、Describe() を使用すると、BeforeEach()AfterEach() などの他の補助関数との処理を基に、より DRY なコードを作成できます。

void Describe(const FString& Description, TFunction<void()> DoWork)

Describe() は、テストに含まれるエクスペクテーションの内容を記述する文字列と、そのエクスペクテーションを定義するラムダを取ります。

Describe() は、Describe() の中に他の Describe() をネストしてカスケードできます。

Describe() はテストではなく、実際のテスト中には実行されないことに注意してください。Spec で最初にエクスペクテーション (テスト) を定義するときに一度だけ実行されます。

It

It() は、Spec で実際のエクスペクテーションを定義するコードになります。It() は、ルートの Define() メソッドから呼び出すことも、任意の Describe() のラムダ内から呼び出すこともできます。It() は、エクスペクテーションのアサートにのみ使用することが理想的ですが、テストするシナリオの最後の設定にも使用できます。

一般的には、It() を呼び出す記述文字列は「should」という単語で始めるのがベスト プラクティスです。これは「it should ~ (~である必要がある)」という意味になります。

基本的なエクスペクテーションの定義

以下のコード例は、前述の関数を組み合わせて、非常にシンプルなエクスペクテーションを定義したものです。

BEGIN_DEFINE_SPEC(MyCustomSpec, "MyGame.MyCustomClass", EAutomationTestFlags::ProductFilter| EAutomationTestFlags::ApplicationContextMask)
    TSharedPtr<FMyCustomClass> CustomClass;
END_DEFINE_SPEC(MyCustomSpec)
void MyCustomSpec::Define()
{
    Describe("Execute()", [this]()
    {
        It("should return true when successful", [this]()
        {
            TestTrue("Execute", CustomClass->Execute());
        });

        It("should return false when unsuccessful", [this]()
        {
            TestFalse("Execute", CustomClass->Execute());
        });
    });
}

特に、複数のエクスペクテーションを結合することなく、プログラマーがエクスペクテーションを正しく記述する場合は、ご覧のように、自己文書化されたテストを作成できます。これは、すべての Describe() 呼び出しと It() 呼び出しを組み合わせると、概ね人間が読める文になることを想定しています。例えば、以下のようになります。

Execute() should return true when successful
Execute() should return false when unsuccessful

下の図は、現在のオートメーション テストの UI に表示された、完成したより複雑な Spec の例です。

AutomationSpec_MatureExample.png

この例では、DriverElementClick が、それぞれ Describe() 呼び出しであり、It() 呼び出しによって様々な「should...」メッセージが定義されています。

It() 呼び出しが実行する個々のテストになります。そのため、テストが 1 つだけ失敗するような場合、切り離して実行できます。これにより、テストをデバッグする手間が軽減されるため、テストの保守が容易になります。また、各テストは自己文書化され、独立しているため、テストの 1 つが失敗した場合にテスト レポートを参照すれば、単に Core という非常に大きなバケットが失敗したというのではなく、より具体的に何に問題があるのかを把握することもできます。そのため、適切な担当者に問題を速やかにアサインでき、問題の調査にかかる時間が短縮されます。

上記のテストのいずれかをクリックすると、そのテストを定義している It() ステートメントに直接移動します。

Spec のエクスペクテーションがテストに変換されるしくみ

ここでは詳しく説明します。説明は細かくなりますが、Spec テスト タイプの基本的な動作を理解しておくと、この後で紹介する複雑な機能の一部については理解しやすくなるでしょう。

Spec テストでは、ルートの Define() 関数が、必要になったときにはじめて、一度だけ実行されます。これが実行されると、全ての Describe ではないラムダを収集します。Define() が終了すると、収集したラムダやコード ブロックをすべて調べて、それぞれの It() に対して潜在的なコマンドの配列を生成します。

したがって、すべての BeforeEach()It() と、AfterEach() ラムダ コード ブロックが、単一のテストの実行チェーンにまとめられます。特定のテストを実行するように求められると、Spec テスト タイプは、その特定のテストのすべてのコマンドをキューに入れて実行に備えます。キューに入れられると、各ブロックは、前のブロックから実行終了の通知があるまで待機します。

その他の機能

Spec テスト タイプには、その他にも複雑なテストの記述を容易にする機能があります。具体的には、強力でありながら扱いづらい、自動化テスト フレームワークの Latent コマンド システムを直接使用する必要がなくなります。

Spec テスト タイプでサポートされている、より複雑なシナリオで役立つ機能のリストを次に示します。

BeforeEach と AfterEach

BeforeEach()AfterEach() は、ごく基本的な Spec 以外のコードを記述するためのコア関数です。BeforeEach() を使用すると、後続の It() コードが実行される前にコードを実行できます。同様に、AfterEach()It() コードの実行後にコードを実行します。

各「テスト」は、単一の It() 呼び出しのみで構成されていることに注意してください。

例:

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter| EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("A spec using BeforeEach and AfterEach", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
        {
            TestEqual("RunOrder", RunOrder, TEXT("A"));
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");
            TestEqual("RunOrder", RunOrder, TEXT("AZ"));
        });
    });
}

この例では、BeforeEach()It()AfterEach() の順に定義されているため、コードブロックは上から下へ実行されます。必須ではありませんが、呼び出しのこの論理的な順序を維持することをお勧めします。ただし、上記の 3 つの呼び出しの順序を変えても、結果としては常に同じテストを生成します。

また上記の例では、AfterEach() でエクスペクテーションをチェックしていますが、これは非常に変則的であり、Spec テスト タイプ自体をテストすることに伴う副作用です。そのため、クリーンアップ以外で AfterEach() を使用することはお勧めしません。

また、複数の BeforeEach()AfterEach() を呼び出すことも可能です。その場合、定義された順序で呼び出されます。最初の BeforeEach() 呼び出しは、2 番目の BeforeEach() 呼び出しの前に実行されます。同様に fterEach() も、最初の呼び出しが実行されてから、後続の呼び出しが実行されます。

BeforeEach([this]()
{
    RunOrder = TEXT("A");
});

BeforeEach([this]()
{
    RunOrder += TEXT("B");
});

It("will run code before each spec in the Describe and after each spec in the Describe", [this]()
{
    TestEqual("RunOrder", RunOrder, TEXT("AB"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Y");
    TestEqual("RunOrder", RunOrder, TEXT("ABY"));
});

AfterEach([this]()
{
    RunOrder += TEXT("Z");
    TestEqual("RunOrder", RunOrder, TEXT("ABYZ"));
});

さらに、BeforeEach()AfterEach() は、呼び出し元の Describe() のスコープによって範囲が制限されます。どちらも、呼び出し元のスコープ内にある It() 呼び出しに対してしか実行されません。

以下の複雑な例では、呼び出しの順序が推奨どおりではありませんが、すべて正しく機能します。

BEGIN_DEFINE_SPEC(AutomationSpec, "System.Automation.Spec", EAutomationTestFlags::SmokeFilter| EAutomationTestFlags::ApplicationContextMask)
    FString RunOrder; 
END_DEFINE_SPEC(AutomationSpec)
void AutomationSpec::Define()
{
    Describe("A spec using BeforeEach and AfterEach", [this]()
    {
        BeforeEach([this]()
        {
            RunOrder = TEXT("A");
        });

        AfterEach([this]()
        {
            RunOrder += TEXT("Z");

            // Can result in
            // TestEqual("RunOrder", RunOrder, TEXT("ABCYZ"));

// or this, based on which It() is being executed
            // TestEqual("RunOrder", RunOrder, TEXT("ABCDXYZ"));
        });

        BeforeEach([this]()
        {
            RunOrder += TEXT("B");
        });

        Describe("while nested inside another Describe", [this]()
        {
            AfterEach([this]()
            {
                RunOrder += TEXT("Y");
            });

It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
            {
                TestEqual("RunOrder", RunOrder, TEXT("ABC"));
            });

            BeforeEach([this]()
            {
                RunOrder += TEXT("C");
            });

            Describe("while nested inside yet another Describe", [this]()
            {
                It("will run all BeforeEach blocks and all AfterEach blocks", [this]()
                {
                    TestEqual("RunOrder", RunOrder, TEXT("ABCD"));
                });

                AfterEach([this]()
                {
                    RunOrder += TEXT("X");
                });

                BeforeEach([this]()
                {
                    RunOrder += TEXT("D");
                });
            });
        });
    });
}

AsyncExecution

Spec テスト タイプを使用すると、単一のコード ブロックの実行方法を簡単に定義できます。オーバーロードされたバージョンの BeforeEach()It()AfterEach() に適切な EAsyncExecution タイプを渡すだけです。

例:

BeforeEach(EAsyncExecution::TaskGraph, [this]()
{
// set up some stuff
));

It("should do something awesome", EAsyncExecution::ThreadPool, [this]()
{
    // do some stuff
});

AfterEach(EAsyncExecution::Thread, [this]()
{
    // tear down some stuff
));

上記のコード ブロックは、それぞれ実行される方法は異なりますが、確実に決められた順序で実行されます。BeforeEach() ブロックは TaskGraph のタスクとして実行され、It() はスレッド プール内のオープン スレッドで実行され、AfterEach() はコード ブロックを実行するためだけの専用のスレッドを作成します。

これらのオプションは、 Automation Driver を使用するような、スレッドセーフかどうかが問題になるシナリオをシミュレートしなければならない場合に非常に便利です。

AsyncExecution 機能は、Latent Completion 機能と組み合わせることができます。

潜在的な実行完了

クエリの実行時など、複数のフレームを使うアクションを実行しなければならないテストが必要になることあります。このようなシナリオでは、LatentBeforeEach()LatentIt()LatentAfterEach() メンバーのオーバーロードを使用できます。オーバーロードされたメンバーはいずれも、ラムダが Done と呼ばれる単純なデリゲートを取るという点を除けば、非潜在的なメンバーと同じです。

潜在的なメンバーを使用すると、Spec テスト タイプは、アクティブに実行中の潜在的なコード ブロックが Done デリゲートを呼び出すまで、テスト シーケンスの次のコード ブロックへの実行を保留します。

LatentIt("should return available items", [this](const FDoneDelegate& Done)
{
    BackendService->QueryItems(this, &FMyCustomSpec::HandleQueryItemComplete, Done);
});

void FMyCustomSpec::HandleQueryItemsComplete(const TArray<FItem>& Items, FDoneDelegate Done)
{
    TestEqual("Items.Num() == 5", Items.Num(), 5);
Done.Execute();
}

この例でわかるように、他のコールバックにペイロードとして Done デリゲートを渡して、潜在的なコードからのアクセスを可能にします。したがって、上記のテストを実行すると、It() コード ブロックの実行がすでに終了していても、Done デリゲートが実行されるまでは、It()AfterEach() コード ブロックは実行されません。

Latent Completion 機能は、AsyncExecution 機能と組み合わせることができます。

パラメータ化テスト

データ駆動型手法でのテストの作成が必要になることがあります。これは、ファイルから入力を読み取り、その入力からテストを生成することになる場合もあれば、コードの重複を削減する手段として理想的な手法になる場合もあります。どちらの場合でも、Spec テスト タイプでは、非常に自然な方法で、パラメータ化テストを実現できます。

Describe("Basic Math", [this]()
{
    for (int32 Index = 0; Index < 5; Index++)
    {
        It(FString::Printf(TEXT("should resolve %d + %d = %d"), Index, 2, Index + 2), [this, Index]()
        {
            TestEqual(FString::Printf(TEXT("%d + %d = %d"), Index, 2, Index + 2), Index + 2, Index + 2);
        });
    }
});

上記の例からわかるように、パラメータ化テストは、パラメータ化されたデータをラムダ ペイロードの一部として渡す別の Spec 関数を動的に呼び出し、そのテスト固有の記述を生成するだけで作成できます。

場合によっては、パラメータ化テストを使用すると、テストが肥大化することがあります。シンプルに単一のテストで、入力からすべてのシナリオを実行することが妥当である場合があります。入力の数と生成されるテスト結果を考慮してください。パラメータ化によりデータ駆動型テストを作成する主な利点は、各テストを分離して実行でき、再現が容易になることです。

Redefine

パラメータ化テストを使用する場合、実行時に、入力を制御している外部ファイルに変更を加え、テストを自動的に更新すると、便利な場合があります。Redefine() は Spec テスト タイプのメンバーであり、これを呼び出すと Define() プロセスが再実行されます。これにより、テストのすべてのコード ブロックが再収集され、特定の順序でまとめられます。

上記のことを行う最も便利な方法は、入力ファイルの変更を監視し、必要に応じてテストで Redefine() を呼び出すコードを作成することです。

テストの無効化

Spec テスト タイプのすべての Describe()BeforeEach()It()AfterEach() メンバーには、先頭に「x」の付いたバリエーションがあります。例えば、xDescribe()xBeforeEach()xIt()xAfterEach() です。これらのバリエーションを使うと、よりシンプルにコード ブロックや Describe() を無効にできます。xDescribe() を使用すると、xDescribe() 内のすべてのコードが無効になります。

これは、繰り返しを必要とするエクスペクテーションのコメントアウトよりも簡単です。

完成した例

Spec テスト タイプの完成した例は、`Engine/Source/Developer/AutomationDriver/Private/Specs/AutomationDriver.spec.cpp`を参照してください。この Spec には現在 120 以上のエクスペクテーションが含まれており、高度な機能のほとんどがどこかしらで利用されています。

Launcher チームも、Spec フレームワークの完成した使用例を複数作成しています。最も完成した使用例の 1 つは BuildPatchServices 関連の Spec です。

Unreal Engine のドキュメントを改善するために協力をお願いします!どのような改善を望んでいるかご意見をお聞かせください。
調査に参加する
キャンセル