3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UE5 でロジックだけ高速にテストしたい時の LowLevelTests

Last updated at Posted at 2025-12-01

実装を書いたはいいものの、デバッグ実行のためだけに補助コードを生やすのは本当に面倒です。
特に UObject 非依存のロジック(TSharedPtrTSharedStruct など)を動かす導線づくりは、無駄な作業が増えがちです。

そこで最近は LowLevelTests を使うようになりました。
エンジン非起動で動作するためイテレーションが非常に速く、思っていた以上に快適です。

この記事では、実際にどんなコードを書き、どうテストしていくのかを具体例とともに紹介します。

LowLevelTests とは

エンジンを起動せずに動作する、最も軽量なテストフレームワークです。
WorldEngine の初期化が行われないため、Actor Component Subsystem などのゲームフレームワークは扱えません。
レベルのロードやアセット参照も行えないため、実行環境に依存する処理のテストには不向きです。

一方で、依存の少ないロジック検証にはとても相性が良く、軽量に回せるのが大きな利点です。
「計算処理だけ確認したい」「モデルクラスの動作を検証したい」といった用途には特に向いています。

LowLevelTests を動かすための準備

まずはテストを書くための環境を整えます。
LowLevelTests には 明示的テスト暗黙的テスト の 2 種類がありますが、前者を使うことでゲームモジュールとテストコードを完全に分離でき、プロジェクト構成もクリーンに保てます。

セットアップの詳細は、以下の記事を参考にしました:

Epic Games Launcher(バイナリ版)では LowLevelTests の追加ができません。
プロジェクト更新時にエラーが発生するため、GitHub などソースビルドの Unreal Engine を使用してください。

テストモジュールを追加する

LowLevelTests を実行するには、ゲームモジュールとは別に テスト専用モジュール を用意します。
プロジェクト名が MyProject の場合は、以下の 2 ファイルを追加します。

  • MyProjectTests.Build.cs
  • MyProjectTests.Target.cs

ディレクトリ構成は次のようになります。

Source/
  MyProject/
  MyProject.Target.cs
  MyProjectEditor.Target.cs
  Programs/  <- 新規追加
    MyProject/
      MyProjectTests.Build.cs
      MyProjectTests.Target.cs

テストモジュールの Build/Target 設定

MyProjectTests.Build.cs
using UnrealBuildTool;

[SupportedPlatforms(UnrealPlatformClass.All)]
public sealed class MyProjectTestsTarget : TestTargetRules
{
	public MyProjectTestsTarget(TargetInfo Target) : base(Target)
	{
		bUsePlatformFileStub = true;
		bMockEngineDefaults = true;
	}
}

bUsePlatformFileStubbMockEngineDefaults は、後述する UObject 初期化のために必要です。
LowLevelTests ではエンジンやプラットフォーム処理が無効化されるため、最低限の動作を置換します。

MyProjectTests.Target.cs
using UnrealBuildTool;

public class MyProjectTests : TestModuleRules
{
	public MyProjectTests(ReadOnlyTargetRules Target) : base(Target)
	{
		// Public/Private を介さず、テストコードのパスを直接 Include に通す設定
		PublicIncludePaths.Add("Programs/MyProject/Tests");

		PrivateDependencyModuleNames.AddRange([
			"Core",
			"CoreUObject",

			"MyProject"
		]);
	}
}

IDE でプロジェクトを更新すると、ゲームモジュールとは別のツリーにテストモジュールが表示されます。

image.png

UObject を扱うためには

エンジンを起動しない状態では、UObject の生成に必要な内部の初期化が行われません。
そのため、テストモジュール側で最低限の初期化処理を明示的に呼び出す必要があります。

以下のファイルをテストモジュールに追加します。

Source/Programs/MyProject/Tests/TestGroupEvents.cpp
#include "TestCommon/Initialization.h"

#include <catch2/catch_test_macros.hpp>

GROUP_BEFORE_GLOBAL( Catch::DefaultGroup )
{
	InitAll( true, true );
}

GROUP_AFTER_GLOBAL( Catch::DefaultGroup )
{
	CleanupAll();
}

InitAll() によって InitCoreUObject などが実行され、
LowLevelTests 上でも NewObject を安全に使える状態が整います。

簡単なテスト例を紹介

ここでは、実際に UObject の関数をテストするシンプルな例を紹介します。
題材として、「素材から合計価格を計算するクラス」を用意します。

Source/MyProject/MyCrafting.h
USTRUCT()
struct FMaterialRow : public FTableRowBase
{
	GENERATED_BODY()

	UPROPERTY( EditAnywhere )
	int32 BasePrice = 0;

	UPROPERTY( EditAnywhere )
	int32 UnitPrice = 0;
};

UCLASS( Blueprintable )
class UMyCrafting : public UObject
{
	GENERATED_BODY()

public:
	UFUNCTION( BlueprintCallable )
	int32 EvaluatePrice( FName MaterialId, int32 Count ) const
	{
		int32 MaterialPrice = 0;

		if ( MaterialTable )
		{
			// DataTable から素材価格を取得する
			if ( const auto* Row = MaterialTable->FindRow<FMaterialRow>( MaterialId, TEXT( "" ) ) )
			{
				MaterialPrice = Row->BasePrice + Row->UnitPrice * Count;
			}
		}

		return MaterialPrice;
	}

protected:
	UPROPERTY( EditAnywhere )
	TObjectPtr<UDataTable> MaterialTable;
};

テストコードの作成

まずは NewObject でインスタンスを生成し、
無効な入力では 0 が返る という最小限の確認からテストを書いていきます。

Source/Programs/MyProject/Tests/MyCraftingTests.cpp
#include "TestHarness.h"

namespace
{
TEST_CASE( "MyProject::MyCrafting", "[unit]" )
{
	auto* MyCrafting = NewObject<UMyCrafting>();
	REQUIRE( MyCrafting );

	SECTION( "NewObject" )
	{
		int32 PriceResult = MyCrafting->EvaluatePrice( NAME_None, 0 );
		CHECK( PriceResult == 0 );
	}
}
}

EvaluatePrice では DataTable を参照していますが、LowLevelTests ではアセットをロードできません。
そこで次の段落では、テスト用に DataTable をコード上でモック生成する方法を紹介します。

DataTable のモックを作成する

LowLevelTests ではアセットをロードできないため、UDataTable もテストコード側で生成します。
手順はシンプルで、以下の 3 つだけです。

  1. NewObject<UDataTable>() で空の DataTable を作る
  2. RowStructFMaterialRow::StaticStruct() を設定する
  3. AddRow() でテスト用データを追加する

先ほどのテストに組み込むと、次のようになります。

Source/Programs/MyProject/Tests/MyProjectTests.cpp
TEST_CASE( "MyProject::MyCrafting", "[unit]" )
{
	auto* MyCrafting = NewObject<UMyCrafting>();
	REQUIRE( MyCrafting );

	// テスト用 DataTable をモック生成
	{
		auto* NewTable = NewObject<UDataTable>();
		NewTable->RowStruct = FMaterialRow::StaticStruct();

		NewTable->AddRow( TEXT( "Iron" ), FMaterialRow{ 100, 10 } );
		NewTable->AddRow( TEXT( "Wood" ), FMaterialRow{ 50, 5 } );

		MyCrafting->MaterialTable = NewTable;
	}

	SECTION( "EvaluatePriceWithDataTable" )
	{
		CHECK( MyCrafting->EvaluatePrice( TEXT( "Iron" ), 3 ) == 130 );	// 100 + 10 * 3
		CHECK( MyCrafting->EvaluatePrice( TEXT( "Wood" ), 2 ) == 60 );	// 50 + 5 * 2
	}
}

このように、テスト側ですべてのデータを組み立てれば、実アセットなしでロジックを検証できます。

テスト用に protected メンバへアクセスする

ところで、先ほどのテストコードでは次のエラーが出てしまいます。

image.png

UMyCrafting::MaterialTableprotected 変数のため、テストコードから直接アクセスすることはできません。
とはいえ、テストのためだけに本番コードを public に変えるのは避けたいところです。

そこで、テストモジュール側に テスト専用の派生クラス を用意します。

Source/Programs/MyProject/Tests/MyCraftingTests.h
UCLASS()
class UMyCraftingTests : public UMyCrafting
{
	GENERATED_BODY()

public:
	using UMyCrafting::MaterialTable; // protected → public として再公開
};

テスト側では、このクラスをインスタンス化すれば MaterialTable にアクセスできるようになります。

-	auto* MyCrafting = NewObject<UMyCrafting>();
+	auto* MyCrafting = NewObject<UMyCraftingTests>();

この派生クラスはテストモジュール内にのみ存在し、ゲーム本体にはビルドされません。
やや手間ではありますが、本番コードを一切変更せずにテストしやすい状態を作ることができます。

テストの実行

IDE からの実行には、次の 2 種類があります。

  • ユニットテストの実行
    単純にテストを走らせるモード。

  • ユニットテストのデバッグ
    通常どおりブレイクデバッグでき、CHECKfalse になったとき停止します。

LowLevelTests では、まず通常のビルドが走り、その後テストが実行されます。
ほとんどのケースで実行はすぐに終わるため、修正 → 実行 → 結果確認のサイクルが非常に速いのが特徴です。

image.png

テストでは実行されないようにガードする

ここまでのセットアップでビルドを実行すると、次のエラーが発生してしまいます。

1>MyProject.cpp(5,56): Error C2061 : 構文エラー: 識別子 'MyProject'
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, MyProject, MyProject );
                                                       ^
1>MyProject.cpp(5,1): Error C4430 : 型指定子がありません - int と仮定しました。メモ: C++  int を既定値としてサポートしていません
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, MyProject, MyProject );

該当箇所は、ゲームモジュールのエントリポイントである IMPLEMENT_PRIMARY_GAME_MODULE です。

Source/MyProject/MyProject.cpp
#include "Modules/ModuleManager.h"

IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, MyProject, MyProject); // これ

LowLevelTests のビルドでは、このマクロが展開されず無効 になってしまうため、
そのままではコンパイルエラーになります。

ゲーム起動処理はテストには不要なので、
テストターゲット時だけこのコードをスキップするガード を追加します。

+#if !EXPLICIT_TESTS_TARGET
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, MyProject, MyProject);
+#endif

EXPLICIT_TESTS_TARGET は明示的テストターゲットでのみ有効です。
したがって、通常ビルドには影響を与えず、テスト時のみ初期化コードを除外できます。

これにより、

  • ゲーム本体の挙動は変わらない
  • LowLevelTests 実行時のコンパイルエラーだけ回避できる

という安全な構成になります。

まとめ

LowLevelTests を使うことで、エンジンを起動せずに UObject ベースのロジックを高速に検証できます。
特に今回のような計算・判定系の処理など、依存が少ないロジックとの相性がとても良いです。
ロジックが複雑になるほどモックの準備や前提条件は増えますが、それでもテストを書くメリットは大きいと感じています。

  • Raw C++ だけでなく UObject の関数も検証できる
  • テストモジュールを分離してゲームコードと混ざらない構成にできる
  • アセットはコードでモック生成でき、テストを独立して実行できる
  • 実行が軽く、修正 → 実行 → 確認のサイクルが速い

テストがあることで、要件の見直しや仕様調整が発生しても安心してリファクタリングできます。
ゲーム側の初期化に依存しない ピュアなロジック を切り出しておくことで、
後半でも安全に手を入れられる「テストしやすい設計」に寄せられる点も大きな利点です。

開発効率を着実に底上げしてくれる、よい取り組みになると思います。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?