22
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一人ゲーム開発TipsAdvent Calendar 2024

Day 1

【Unity】【C#】効率的なエラーハンドリングのためのカスタムLoggerとAssertの実装ステップ

Last updated at Posted at 2024-11-30

はじめに

本記事では、プロジェクト用にカスタマイズしたLoggerとAssertを導入するメリットと、実装ステップについて詳しく解説します。ソフトウェア開発において、効率的なエラーハンドリングは、エラーの迅速な特定と修正を可能にし、コードの信頼性と品質を向上させます。この記事が、LoggerとAssertを作成する際の参考になれば幸いです。

本記事の対象者は以下の通りです。

  • プロジェクトの初期段階で、独自のLoggerやAssertを導入し、エラーハンドリングやデバッグの効率を向上させたい方
  • 既にLoggerやAssertを導入しているが、APIの信頼性を高めるためにテストを用意したい方
  • プロジェクトの初期フェーズで実際に使える知見が欲しい方

カスタムLogger&Assertの導入メリット

プロジェクト向けにカスタムしたLoggerとAssertを導入することで、エラーやデバッグ情報を一貫して記録でき、デバッグ効率を大幅に向上させることができます。ログレベルやタグによるフィルタリング機能を活用すると、必要な情報のみを抽出できるので、素早くバグの特定と修正が可能になります。また、エラーハンドリングをしっかり行うことでコードの可読性と保守性も向上し、プロジェクト全体のコード品質も向上します。

以下にこれらを導入した際の三つのメリットを詳細に書き出してみます。

メリット1: カスタムされたログ出力によって視認性UP

カスタムLoggerを使用することで、ログメッセージのフォーマットを統一し、重要な情報を見やすく表示できます。例えば、タイムスタンプ、ログレベル、タグ、ファイル名、行番号、メソッド名をログに含めることで、エラーの発生箇所や状況を特定しやすくなります。

具体例

public static void Log(string message, 
                       LogLevel level = LogLevel.Info,
                       string filePath = "",
                       int lineNumber = 0,
                       string memberName = "")
{
    var logMessage = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{level}] {message} (at {Path.GetFileName(filePath)}:{lineNumber} in {memberName})";
    UnityEngine.Debug.Log(logMessage);
}

上記のコードでは、ログメッセージにタイムスタンプ、ログレベル、ファイル名、行番号、メソッド名を含めています。こうすることで、ログの一行目にこれらの情報が見えるようになり、視認性が大幅に向上します。

メリット2: ログレベルやタグによるログフィルタリングが可能に

カスタムLoggerを導入することで、独自のログレベルやタグを使用してログメッセージをフィルタリングできるようになります。これにより、ログを探す際に必要な情報のみを抽出し、デバッグの効率を向上させることができます。

例えば、開発期間中はDebugレベルを表示しておき、QAビルドにはWarningやErrorレベルの重要なログのみを表示するよう、制御することができます。
また、タグを使用することで、特定の機能やモジュールに関連するログメッセージを簡単にフィルタリングできます。

メリット3: コードの可読性と保守性の向上

カスタムAssertを使用することで、コードの可読性と保守性が向上します。特に、Loggerのログメッセージに自身のプロジェクト名を入れてフィルタリング可能にしたり、AssertクラスでカスタムLoggerクラスを使用したAPIを提供することで、より使い勝手の良い状態にできます。プロジェクト内では、懸念のある個所に使える独自のAssertがあるだけで、何度も同じようなチェック処理を書く必要がなくなり、コード自体もシンプルになります。

具体例

var expected = 0;
var actual = 0;

// ...code...

// もしexpectedとactualの値が処理の途中で異なる値になってしまったらErrorログを出力
CustomAssert.AreEqual(expected, actual);

Loggerの実装

今回、記事内で紹介する実装は、以下の情報を含めたログ出力となります。

  • ログレベル
  • カスタムログメッセージ
  • タグ
  • ログが発生したファイルの名前
  • ログが発生したメソッドの名前
  • ログが発生した行

名前空間は、あなたのプロジェクトの最上位階層に配置しておきましょう。YourProject.Loggerを名前空間とした場合、プロジェクト内の他の箇所でLoggerを使用する際に、Logger.Logger.Debug()となってしまいます。

この記述だと少し回りくどいため、余分な名前空間の指定を省略するためにも、最上位の階層に配置することをおすすめします。ただし、独自のLoggerコードが既に存在していて、統合をせず新しく分けて用意する場合は、適切な名前空間の設定をしましょう。

ログレベルは以下のように準備しておきます。

namespace YourProject
{
    internal enum LogLevel
    {
        // 開発中の Debug 用ログ表示に使用
        Debug,
        // 通常のログ表示に使用
        Info,
        // 警告表示に使用
        Warning,
        // エラー表示に使用
        Error,
    }
}

ログ用のタグも用意しておきましょう。

namespace YourProject
{
    /// <summary>
    /// Logger 用のタグ種類。
    /// 更に必要なものがあれば、随時追加してください。
    /// </summary>
    public enum LoggerTag
    {
        GENERAL,
        AUDIO,
        ANIMATION,
    }
}

それでは実際のLoggerクラスを用意します。
各コードのポイントは後程解説します。

using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using Debug = UnityEngine.Debug;

namespace YourProject
{
    public static class Logger
    {
        private static bool s_isLoggingEnabled = true;

        [DebuggerStepThrough]
        public static void DebugLog(string message,
                                 LoggerTag tag = LoggerTag.GENERAL,
                                 [CallerFilePath] string filePath = "",
                                 [CallerLineNumber] int lineNumber = 0,
                                 [CallerMemberName] string memberName = "")
        {
            InternalLog(LogLevel.Debug, message, tag, filePath, lineNumber, memberName);
        }

        [DebuggerStepThrough]
        public static void Info(string message,
                                LoggerTag tag = LoggerTag.GENERAL,
                                [CallerFilePath] string filePath = "",
                                [CallerLineNumber] int lineNumber = 0,
                                [CallerMemberName] string memberName = "")
        {
            InternalLog(LogLevel.Info, message, tag, filePath, lineNumber, memberName);
        }

        [DebuggerStepThrough]
        public static void Warning(string message,
                                   LoggerTag tag = LoggerTag.GENERAL,
                                   [CallerFilePath] string filePath = "",
                                   [CallerLineNumber] int lineNumber = 0,
                                   [CallerMemberName] string memberName = "")
        {
            InternalLog(LogLevel.Warning, message, tag, filePath, lineNumber, memberName);
        }

        [DebuggerStepThrough]
        public static void Error(string message,
                                 LoggerTag tag = LoggerTag.GENERAL,
                                 [CallerFilePath] string filePath = "",
                                 [CallerLineNumber] int lineNumber = 0,
                                 [CallerMemberName] string memberName = "")
        {
            InternalLog(LogLevel.Error, message, tag, filePath, lineNumber, memberName);
        }

        public static void EnableLogging(bool enable)
        {
            s_isLoggingEnabled = enable;
        }

        [DebuggerStepThrough]
        private static void InternalLog(LogLevel logLevel,
                                string message,
                                LoggerTag tag,
                                string filePath = "",
                                int lineNumber = 0,
                                string memberName = "")
        {
            if (!s_isLoggingEnabled)
            {
                UnityEngine.Debug.Log("YourProject: Logging is disabled.");
                return;
            }

            CurrentLogLevel = logLevel;
            var logMessage = FormatLogMessage(logLevel, tag, message, filePath, lineNumber, memberName);
            OutputLogMessage(logLevel, logMessage);
        }

        private static string FormatLogMessage(LogLevel logLevel,
                                               LoggerTag tag,
                                               string message,
                                               string filePath,
                                               int lineNumber,
                                               string memberName)
        {
            return
                $"YourProject: [{logLevel}] [{tag}] {message} (at {Path.GetFileName(filePath)}:{lineNumber} in {memberName})";
        }

        private static void OutputLogMessage(LogLevel logLevel, string logMessage)
        {
            switch (logLevel)
            {
                case LogLevel.Error:
                    Debug.Log($"<color=red>{logMessage}</color>");
                    break;
                case LogLevel.Warning:
                    Debug.LogWarning($"<color=yellow>{logMessage}</color>");
                    break;
                case LogLevel.Debug:
                case LogLevel.Info:
                default:
                    Debug.Log(logMessage);
                    break;
            }
        }
    }
}

Loggerクラスのポイント

static クラスを選んだ理由

Loggerクラスはシングルトンパターンではなく、シンプルさと利便性のために、static クラスとして実装しました。Loggerはプロジェクト全体で一貫して使用されるべきユーティリティクラスであり、基本的に状態を保持する必要がありません。
static クラスとして実装することで、インスタンスの生成を不要にし、直接的にアクセスできるようにすることで、コードの記述が簡潔になります。

[CallerFilePath], [CallerLineNumber], [CallerMemberName] について

これらの属性は、メソッドが呼び出された場所に関する情報を自動的に取得するために使用されます。

  • [CallerFilePath]: 呼び出し元のソースファイルの完全なパスを取得します
  • [CallerLineNumber]: 呼び出し元の行番号を取得します
  • [CallerMemberName]: 呼び出し元のメンバー名(メソッド名やプロパティ名)を取得します

これらの情報をログに含めることで、どこでエラーや警告が発生したのかを迅速に特定できるようになります。特に、デバッグ時にエラーの原因を迅速に突き止めるために非常に有用です。

さらに、これらの引数はデフォルト引数として設定されているため、呼び出し側から明示的に値を渡す必要はありません。デフォルトで、呼び出し元のファイルパス、行番号、メンバー名が自動的に挿入されます。

public static void DebugLog(string message,
                            LoggerTag tag = LoggerTag.GENERAL,
                            [CallerFilePath] string filePath = "",
                            [CallerLineNumber] int lineNumber = 0,
                            [CallerMemberName] string memberName = "")
{
    InternalLog(LogLevel.Debug, message, tag, filePath, lineNumber, memberName);
}

上記の例では、DebugLog メソッドを呼び出す際に、filePathlineNumbermemberName の引数に値を渡す必要はありません。

// こんな感じで使えばOKです!
Logger.DebugLog("meowiest");

今回は各関数へのコメント追加を省いていますが、チームで開発する際には引数に値を渡す必要がない旨を、コメントで明示的に記述しておいた方が良いでしょう。

[DebuggerStepThrough] とは

[DebuggerStepThrough] 属性は、デバッガが対象メソッドのステップ実行をスキップする、と指示する際に使用します。これにより、デバッグ時に不要な内部メソッドの詳細を省略し、問題のあるコードに集中できます。

特に、[CallerFilePath][CallerLineNumber][CallerMemberName] と組み合わせることで、デバッグ情報をより正確かつ詳細に記録しながら、デバッグ時の煩雑さを軽減できます。これらの属性を使用するメソッドが、デバッガのステップ実行の対象外となるため、開発者は関心のあるコードに集中してデバッグを行うことができます。

④各メソッドの使い分け

  • DebugLog: 開発中にのみ使用するデバッグ情報を出力します。細かい動作確認や状態チェックに適しています
  • Info: 一般的な情報を出力します。通常の動作や処理の進行状況を記録するために使用します
  • Warning: クリティカルではないが、確認すべき潜在的な問題や注意点を出力します。開発中に見逃してはいけない問題に対して使用します
  • Error: 重大なエラーや予期しない値が発生した場合に使用します。通常は発生しないはずの状況や、処理を中断する必要があるエラーを記録します

上記のように使い分けることで、Consoleタブ上のログを見ただけで何が起きているのか視認しやすくなります。特にErrorメソッドは 「本来起こるはずがない」 ことをチェックするためだけに使用しましょう。

Warmingの使うタイミングも工夫してみましょう。
例えば、「外部から入力されるデータに対して、不整合があった際に警告を出力する」という使い方が良さそうです。

具体例:

public void ValidateInput(string input)
{
    // Warning: 予想外の値が入力されたが、処理を続行できる場合
    if (string.IsNullOrEmpty(input))
    {
        Logger.Warning("入力が空です。デフォルト値を使用します。");
        input = "default";
    }
}

Warningを使用することで、外部からのデータの正しさを確認し、そのデータが内部処理で使用可能であることを保証できます。

Errorは、内部の 「本来は保証されているデータ」 が不整合を起こしている場合に使用することで、「重大なエラーが発生している」 状態を表すことができます。

具体例:

public void SetCharacterHP(int hp)
{
	if (character == null)
	{
		// この時点で内部の変数、"character"がnullなのは想定外
		Logger.Error("characterが設定されていません。")
		return;
	}
	
	// もちろん、hpに変な値が入っていないかWarningで確認するのも良い
	character.Hp = hp;
}

s_isLoggingEnabled の使いどころ

s_isLoggingEnabled フラグは、ログ出力を一時的に無効にしたい場合に使用します。例えば、特定の状況でログ出力を停止したい場合や、パフォーマンスを考慮してログを抑制する場合に役立ちます。EnableLogging メソッドを使用して、ログの有効/無効を切り替えることができます。

Assertionの実装

次に、プロジェクト独自のAssertを実装していきましょう。

Assertは、実際にはありえない値になった場合のチェックとして使用するAPIとなるため、Logger.Errorメソッドを使ってエラーログを出力します。

今回用意するアサーションメソッドは以下の通りです。

  • IsTrue: 条件が真であることを検証します。
  • IsFalse: 条件が偽であることを検証します。
  • IsNotNull: オブジェクトがnullでないことを検証します。
  • IsNull: オブジェクトがnullであることを検証します。
  • AreEqual: 2つの値が等しいことを検証します。
  • AreNotEqual: 2つの値が等しくないことを検証します。
  • AreSame: 2つのオブジェクト参照が同一であることを検証します。
  • AreNotSame: 2つのオブジェクト参照が同一でないことを検証します。
  • Fail: 明示的にアサーションを失敗させます。
  • IsGreaterThan: 値が指定された限界より大きいことを検証します。
  • IsGreaterThanOrEqualTo: 値が指定された限界以上であることを検証します。
  • IsLessThan: 値が指定された限界より小さいことを検証します。
  • IsLessThanOrEqualTo: 値が指定された限界以下であることを検証します。
  • IsInRange: 値が指定された範囲内にあることを検証します。
  • IsNotInRange: 値が指定された範囲外にあることを検証します。

今回、このままAssertからLoggerのAPIを呼び出してしまうと、Logger側の[CallerFilePath][CallerLineNumber]属性がついた引数に、Assertの情報が入ってきてしまいます。本来ならAssertを呼び出しているアプリケーションコードの情報が欲しいはずです。なので、Assert側に[CallerFilePath][CallerLineNumber]属性がついた引数のAPIを用意し、その情報を渡すためのクッションとなるメソッドをLogger側にひとつ用意しましょう。

internal static void LogForAssert(LogLevel logLevel,
                                  string message,
                                  LoggerTag tag,
                                  string filePath,
                                  int lineNumber,
                                  string memberName)
{
    InternalLog(logLevel, message, tag, filePath, lineNumber, memberName);
}

それでは実際のAssertクラスを用意してみます。

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace YourProject
{
    public static class YourProjectAssert
    {
        [DebuggerStepThrough]
        public static void Fail(string message = "",
                                LoggerTag tag = LoggerTag.GENERAL,
                                [CallerFilePath] string filePath = "",
                                [CallerLineNumber] int lineNumber = 0,
                                [CallerMemberName] string memberName = "")
        {
            Logger.LogForAssert(LogLevel.Error,
                                  FormatLogMessage(message,
                                                   "Assertion failed."),
                                  tag, filePath, lineNumber, memberName);
        }

#region Assertion on Range Conditions

        [DebuggerStepThrough]
        public static void IsInRange<T>(T value,
                                        T min,
                                        T max,
                                        string message = "",
                                        LoggerTag tag = LoggerTag.GENERAL,
                                        [CallerFilePath] string filePath = "",
                                        [CallerLineNumber] int lineNumber = 0,
                                        [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(min) < 0 || value.CompareTo(max) > 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value should be between {min} and {max}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void IsNotInRange<T>(T value,
                                           T min,
                                           T max,
                                           string message = "",
                                           LoggerTag tag = LoggerTag.GENERAL,
                                           [CallerFilePath] string filePath = "",
                                           [CallerLineNumber] int lineNumber = 0,
                                           [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(min) >= 0 && value.CompareTo(max) <= 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value({value}) should be NOT between {min} and {max}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assertion on Less conditions

        [DebuggerStepThrough]
        public static void IsLessThan<T>(T value,
                                         T limit,
                                         string message = "",
                                         LoggerTag tag = LoggerTag.GENERAL,
                                         [CallerFilePath] string filePath = "",
                                         [CallerLineNumber] int lineNumber = 0,
                                         [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(limit) >= 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value({value}) should be less than {limit}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void IsLessThanOrEqualTo<T>(T value,
                                                  T limit,
                                                  string message = "",
                                                  LoggerTag tag = LoggerTag.GENERAL,
                                                  [CallerFilePath] string filePath = "",
                                                  [CallerLineNumber] int lineNumber = 0,
                                                  [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(limit) > 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value({value}) should be less than or equal to {limit}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assertion on Greater conditions

        [DebuggerStepThrough]
        public static void IsGreaterThan<T>(T value,
                                            T limit,
                                            string message = "",
                                            LoggerTag tag = LoggerTag.GENERAL,
                                            [CallerFilePath] string filePath = "",
                                            [CallerLineNumber] int lineNumber = 0,
                                            [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(limit) <= 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value({value}) should be greater than {limit}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void IsGreaterThanOrEqualTo<T>(T value,
                                                     T limit,
                                                     string message = "",
                                                     LoggerTag tag = LoggerTag.GENERAL,
                                                     [CallerFilePath] string filePath = "",
                                                     [CallerLineNumber] int lineNumber = 0,
                                                     [CallerMemberName] string memberName = "")
            where T : IComparable
        {
            if (value.CompareTo(limit) < 0)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       $"Assertion failed: Value({value}) should be greater than or equal to {limit}."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assertion on Reference Conditions

        [DebuggerStepThrough]
        public static void AreSame<T>(T expected,
                                      T actual,
                                      string message = "",
                                      LoggerTag tag = LoggerTag.GENERAL,
                                      [CallerFilePath] string filePath = "",
                                      [CallerLineNumber] int lineNumber = 0,
                                      [CallerMemberName] string memberName = "")
        {
            if (!ReferenceEquals(expected, actual))
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: References should be the same."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void AreNotSame<T>(T notExpected,
                                         T actual,
                                         string message = "",
                                         LoggerTag tag = LoggerTag.GENERAL,
                                         [CallerFilePath] string filePath = "",
                                         [CallerLineNumber] int lineNumber = 0,
                                         [CallerMemberName] string memberName = "")
        {
            if (ReferenceEquals(notExpected, actual))
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: References should be NOT the same."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assetion on Equality Conditions

        [DebuggerStepThrough]
        public static void AreEqual<T>(T expected,
                                       T actual,
                                       string message = "",
                                       LoggerTag tag = LoggerTag.GENERAL,
                                       [CallerFilePath] string filePath = "",
                                       [CallerLineNumber] int lineNumber = 0,
                                       [CallerMemberName] string memberName = "")
        {
            if (!expected.Equals(actual))
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Values should be not equal."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void AreNotEqual<T>(T notExpected,
                                          T actual,
                                          string message = "",
                                          LoggerTag tag = LoggerTag.GENERAL,
                                          [CallerFilePath] string filePath = "",
                                          [CallerLineNumber] int lineNumber = 0,
                                          [CallerMemberName] string memberName = "")
        {
            if (notExpected.Equals(actual))
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Values should be NOT equal."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assertions on Null Conditions

        [DebuggerStepThrough]
        public static void IsNotNull(object obj,
                                     string message = "",
                                     LoggerTag tag = LoggerTag.GENERAL,
                                     [CallerFilePath] string filePath = "",
                                     [CallerLineNumber] int lineNumber = 0,
                                     [CallerMemberName] string memberName = "")
        {
            if (obj == null)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Object should be NOT Null."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void IsNull(object obj,
                                  string message = "",
                                  LoggerTag tag = LoggerTag.GENERAL,
                                  [CallerFilePath] string filePath = "",
                                  [CallerLineNumber] int lineNumber = 0,
                                  [CallerMemberName] string memberName = "")
        {
            if (obj != null)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Object should be Null."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

#endregion

#region Assertions on Boolean Conditions

        [DebuggerStepThrough]
        public static void IsTrue(bool condition,
                                  string message = "",
                                  LoggerTag tag = LoggerTag.GENERAL,
                                  [CallerFilePath] string filePath = "",
                                  [CallerLineNumber] int lineNumber = 0,
                                  [CallerMemberName] string memberName = "")
        {
            if (!condition)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Condition should be True."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        [DebuggerStepThrough]
        public static void IsFalse(bool condition,
                                   string message = "",
                                   LoggerTag tag = LoggerTag.GENERAL,
                                   [CallerFilePath] string filePath = "",
                                   [CallerLineNumber] int lineNumber = 0,
                                   [CallerMemberName] string memberName = "")
        {
            if (condition)
            {
                Logger.LogForAssert(LogLevel.Error,
                                      FormatLogMessage(message,
                                                       "Assertion failed: Condition should be False."),
                                      tag, filePath, lineNumber, memberName);
            }
        }

        private static string FormatLogMessage(string customMessage, string assertMessage)
        {
            return string.IsNullOrEmpty(customMessage) ? assertMessage : $"{customMessage}\n{assertMessage}";
        }

#endregion
    }
}

一通りのメソッドを用意してみました。

メソッド数がどうしても多くなってしまうため、関連のあるメソッド群は#regionで囲っています。これで多少視認性が良くなるはずです。

ここまでで、カスタムAssertの実装が完了しました。
次に、これらの実装をテストする方法について説明します。

カスタムLoggerとAssertをTestする必要性

LoggerとAssertを自前で用意するのであれば、それらが期待通りに動作することを保証しなければなりません。そのために単体テストを用意してみましょう。

単体テストとは、各メソッドの動作を個別にテストし、期待通りの結果が得られることを確認するためのものです。テストにより、エラーハンドリングやデバッグ情報の出力が正確であることを確認でき、信頼性の高いAPIを提供できます。また、将来的に内部処理に変更が発生しても、テストを行うことで既存の機能に影響を与えないことも即座に確認できます。また、予期しない動作やエッジケースに対する耐性も強化できるため、テストは今回の記事内だけではなく、プロジェクト全体として積極的に取り入れてみましょう。

(Unity開発者向け)Testの実装

Unityには、標準でテストフレームワークとしてNUnitが組み込まれています。これを活用することで、簡単に単体テストを作成し、実行することができます。また、 UnityのTest Runner を使用することで、エディタ上でテストを実行し、結果を確認することができます。

テストスクリプトを作成するにあたって、テストの成功と失敗を判断する基準が必要です。今回はLoggerクラスにstaticの現在のLogLevelを示すプロパティを追加してみましょう。

public static class Logger
{
    private static bool s_isLoggingEnabled = true;
    
    // 現在のLogLevel
    // アセンブリを分けてないのであれば、public staticで良いと思います
    internal static LogLevel CurrentLogLevel { get; private set; } = LogLevel.Debug;
    ...

また、このCurrentLogLevelを更新する処理も追加します。

  [DebuggerStepThrough]
  private static void InternalLog(LogLevel logLevel,
                                  string message,
                                  LoggerTag tag,
                                  string filePath = "",
                                  int lineNumber = 0,
                                  string memberName = "")
  {
      if (!s_isLoggingEnabled)
      {
          Debug.Log("TwiRoid: Logging is disabled.");
          return;
      }

			// LogLevelの更新
      CurrentLogLevel = logLevel;
      
      var logMessage = FormatLogMessage(logLevel, tag, message, filePath, lineNumber, memberName);
      OutputLogMessage(logLevel, logMessage);
  }
  
  ...

  internal static void ResetLogLevel()
  {
      CurrentLogLevel = LogLevel.Debug;
  }

今回はUnityプロジェクトでアセンブリを分割していると仮定し、進めてみます。
分割をしていない方は、以下のファイルを用意する必要はありません。

—-アセンブリ分割している方向け—-

それではAssemblyInfo.csを作成してアセンブリの設定を行いましょう。このファイルでは、InternalsVisibleTo属性を使用して、YourProject.TestsアセンブリからLoggerやAssertの所属するアセンブリの内部メンバーへアクセスできるようにしています。これにより、テストプロジェクトから本体プロジェクトの内部メソッドやクラスにアクセスしてテストを行うことができます。

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("YourProject.Tests.Editor")]
[assembly: InternalsVisibleTo("YourProject.Tests")]

前準備が長くなってしまいましたが、いよいよテスト用のスクリプトをAssets/Testsフォルダに作成します。スクリプトファイル名およびクラス名はAssertTestsとしましょう。

以下にAssert.cs内のメソッドに沿ったテストコードを用意しておきます。
コード内のポイント説明は後程記載します。

using NUnit.Framework;

namespace YourProject.Tests
{
    public class AssertTests
    {
        [SetUp]
        public void SetUp()
        {
            Logger.ResetLogLevel();
        }

        [Test]
        public void YourProject_Logger_Logの失敗をテスト()
        {
            YourProjectAssert.Fail();
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(5, 1, 10)]
        [TestCase(1, 1, 10)]
        [TestCase(10, 1, 10)]
        public void YourProject_Logger_値が範囲内なら成功(int value, int min, int max)
        {
            YourProjectAssert.IsInRange(value, min, max);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(0, 1, 10)]
        [TestCase(11, 1, 10)]
        public void YourProject_Logger_値が範囲外なら失敗(int value, int min, int max)
        {
            YourProjectAssert.IsInRange(value, min, max);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(0, 1, 10)]
        [TestCase(11, 1, 10)]
        public void YourProject_Logger_値が範囲外なら成功(int value, int min, int max)
        {
            YourProjectAssert.IsNotInRange(value, min, max);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(5, 1, 10)]
        [TestCase(1, 1, 10)]
        [TestCase(10, 1, 10)]
        public void YourProject_Logger_値が範囲内なら失敗(int value, int min, int max)
        {
            YourProjectAssert.IsNotInRange(value, min, max);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値がnullでないなら成功()
        {
            YourProjectAssert.IsNotNull("meow");
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値がnullなら失敗()
        {
            YourProjectAssert.IsNotNull(null);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値がnullなら成功()
        {
            YourProjectAssert.IsNull(null);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値がnullでないなら失敗()
        {
            YourProjectAssert.IsNull(new object());
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が真なら成功()
        {
            YourProjectAssert.IsTrue(true);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が偽なら失敗()
        {
            YourProjectAssert.IsTrue(false);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が偽なら成功()
        {
            YourProjectAssert.IsFalse(false);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が真なら失敗()
        {
            YourProjectAssert.IsFalse(true);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が等しいなら成功()
        {
            YourProjectAssert.AreEqual(1, 1);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が等しくないなら失敗()
        {
            YourProjectAssert.AreEqual(1, 2);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が等しくないなら成功()
        {
            YourProjectAssert.AreNotEqual(1, 2);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が等しいなら失敗()
        {
            YourProjectAssert.AreNotEqual(1, 1);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が閾値より低いなら成功()
        {
            YourProjectAssert.IsLessThan(1, 10);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が閾値より高いなら失敗()
        {
            YourProjectAssert.IsLessThan(10, 1);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が閾値より高いなら成功()
        {
            YourProjectAssert.IsGreaterThan(10, 1);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が閾値より低いなら失敗()
        {
            YourProjectAssert.IsGreaterThan(1, 10);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(1, 10)]
        [TestCase(10, 10)]
        public void YourProject_Logger_値が閾値以下なら成功(int value, int threshold)
        {
            YourProjectAssert.IsLessThanOrEqualTo(value, threshold);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(10, 1)]
        [TestCase(11, 10)]
        public void YourProject_Logger_値が閾値以下なら失敗(int value, int threshold)
        {
            YourProjectAssert.IsLessThanOrEqualTo(value, threshold);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(10, 1)]
        [TestCase(10, 10)]
        public void YourProject_Logger_値が閾値以上なら成功(int value, int threshold)
        {
            YourProjectAssert.IsGreaterThanOrEqualTo(value, threshold);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [TestCase(1, 10)]
        [TestCase(10, 11)]
        public void YourProject_Logger_値が閾値以上なら失敗(int value, int threshold)
        {
            YourProjectAssert.IsGreaterThanOrEqualTo(value, threshold);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が同じオブジェクト成功()
        {
            var str1 = "meow";
            var str2 = str1;
            YourProjectAssert.AreSame(str1, str2);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が異なるオブジェクトなら失敗()
        {
            var str1 = "meow";
            var str2 = "bow";
            YourProjectAssert.AreSame(str1, str2);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が異なるオブジェクトなら成功()
        {
            var str1 = "meow";
            var str2 = "bow";
            YourProjectAssert.AreNotSame(str1, str2);
            Assert.IsFalse(Logger.CurrentLogLevel is LogLevel.Error);
        }

        [Test]
        public void YourProject_Logger_値が同じオブジェクトなら失敗()
        {
            var str1 = "meow";
            var str2 = str1;
            YourProjectAssert.AreNotSame(str1, str2);
            Assert.IsTrue(Logger.CurrentLogLevel is LogLevel.Error);
        }
    }
}

Test属性

NUnitの[Test]属性を使用すると、メソッドがテストメソッドであることを示します。この属性が付与されたメソッドは、テストランナーによって自動的に検出され、テストとして実行されます。

Unity Test Runnerを使う日本人向けに、メソッド名を日本語にして視認性を上げているプロジェクトが多い印象です。

TestCase属性

NUnitの[TestCase]属性を使用すると、同じテストメソッドを異なる引数で複数回実行することができます。これにより、同じロジックを異なる条件でテストすることが容易になります。

[TestCase(5, 1, 10)]
[TestCase(1, 1, 10)]
[TestCase(10, 1, 10)]
public void YourProject_Logger_値が範囲内なら成功(int value, int min, int max)
{
    YourProjectAssert.IsInRange(value, min, max);
    Assert.IsTrue(Logger.CurrentLogLevel == LogLevel.Debug);
}

例えば上記のコードなら、以下のような見た目でUnity Test Runnerに表示されるはずです。
(画像は私のプロジェクトでのテストになりますが)

image.png

このようにプロパティーベースのテストを用意して、DbC( Design by Contract:入力として許可する条件と、出力が保証する内容を保持させる) を実現することができます。

Assertクラス

NUnitのAssertクラスは、テストの結果を検証するための静的メソッドを提供します。例えば、上記のコードで使用されているAssert.IsTrueメソッドは、指定された条件がtrueであることを確認します。条件がfalseの場合、テストは失敗します。

NUnitのAssertクラスには、さまざまな検証メソッドが用意されています。以下に、いくつかの便利なAPIを紹介します。

  • Assert.AreEqual(expected, actual): expectedactualが等しいことを確認します。
  • Assert.AreNotEqual(notExpected, actual): notExpectedactualが等しくないことを確認します。
  • Assert.IsNull(obj): objnullであることを確認します。
  • Assert.IsNotNull(obj): objnullでないことを確認します。
  • Assert.IsFalse(condition): conditionfalseであることを確認します。
  • Assert.Throws<T>(TestDelegate code): codeの実行中に型Tの例外がスローされることを確認します。

ここまで実装できたら以下の手順を試してみてください。

  1. テストの実行:
    • UnityエディタのメニューからWindow -> General -> Test Runnerを選択します。
    • Test Runnerウィンドウが表示されるので、Run Allをクリックしてすべてのテストを実行します。
  2. テスト結果の確認:
    • テストの結果がTest Runnerウィンドウに表示されます。成功したテストは緑色で、失敗したテストは赤色で表示されます。

image.png

更に発展させるために

カスタムLoggerとAssertをさらに発展させるためには、以下のような機能を追加することも検討してみてください。

  1. 非同期ロギング(オプション):
    • 高頻度のログ記録が必要な場合、非同期ロギングを導入することで、メインスレッドのパフォーマンスを向上させることができます。
    • 特にログをファイル保存する処理を頻繁に行うなら、必要だと思います。
  2. ログの保存と分析:
    • ログをファイルに保存し、後で分析できるようにすることで、問題の再現やトレースが容易になります。

まとめ

この記事では、カスタムLoggerとAssertの導入とそのメリットについて詳しく解説しました。プロジェクトに独自のLoggerとAssertを導入することで、以下のような多くの利点が得られます。

  1. エラーの迅速な特定と修正:
    カスタムLoggerを使用することで、ログメッセージのフォーマットを統一し、重要な情報を見やすく表示することができます。これにより、エラーの発生箇所や状況を迅速に特定し、修正することが可能になります。
  2. デバッグ効率の向上:
    ログレベルやタグによるフィルタリング機能を活用することで、必要な情報のみを抽出し、デバッグの効率を大幅に向上させることができます。これにより、開発期間中やQAビルドの際に重要な情報を見逃さずに済みます。
  3. コードの可読性と保守性の向上:
    カスタムAssertを使用すると、コードの可読性と保守性が向上します。独自のAssertクラスを導入することで、繰り返しのチェック処理を省略し、コードをシンプルかつ明確に保つことができます。
  4. 信頼性の高いテストの実施:
    NUnitを使用した単体テストを実装することで、LoggerとAssertの動作を確実に確認できます。テストによって、エラーハンドリングやデバッグ情報の出力が正確であることを保証し、将来的な変更にも柔軟に対応できるようになります。

これらのステップを実行することで、プロジェクト全体の品質を向上させるとともに、効率的なデバッグ環境を構築することができます。また、非同期ロギングやログの保存と分析など、さらなる発展を目指すことで、より高度なデバッグ機能を実現することも可能です。

カスタムLoggerとAssertの導入は、エラーハンドリングとデバッグの効率化に欠かせない重要な要素です。この記事を参考にして、あなたのプロジェクトに適したLoggerとAssertを実装し、開発効率とコード品質の向上を目指してください。

22
21
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
22
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?