SionGames 技術Blog

C#やUnityを触っているサーバープログラマーが色々と書いていく所

C#の単体テストの実装方法

バグは必ず出てしまう物である以上、バグを見つけるための手法が必要です。
その手法の一つとして単体テストがあります。
今回は単体テストのメリットとデメリットの説明と、
C#でNUnitを使ったテストコードの実装方法まで紹介したいと思います。

単体テストとは


そもそも単体テストについて馴染みが無い方向けに説明します。
単体テストは作成したプログラムで最も小さい機能毎に検証コードを書いて、小さい機能毎にバグが潜んでいないか確認する事です。
小さい機能を組み合わせた後に行うテストは結合テストと言います。
小さい機能とはC#だと一つのクラスだったり、一つの関数だったりします。

単体テストを行うメリット


メリットはバグを早期に発見しやすくなる事にあります。
後から周辺機能の修正を行った際にも、バグを生み出してしまっていないかある程度指針になります。
大規模で継続的に修正があるプロジェクトであるほど、このメリットは大きなものになります。
プログラムのバグは少なければ少ないほど、顧客の評価を落としにくく出来るのでとても重要なメリットです。
最近のソーシャルゲームの開発状況とピッタリ合います。

単体テストを行うデメリット


デメリットはテストコードを書く工数が増える事と、テストコードの維持にもコストがかかることです。
きっちりとテストコードを書く場合、体感的には1.5倍から2倍程工数が増えます。
そしてテストコードの記述自体に間違いがあった場合やテストが甘かった場合にはバグを発見できない事があります。

疎結合を維持しよう


テストコードを書きやすいコードと書きにくいコードが存在します。

例えば通信や入力などが絡んでいる処理系を密結合で作成している場合です。
通信を行うクラスを直接ロジック側でstaticに参照すると、通信の初期化処理が無いとエラーが出たり 通信相手がいないとエラーが出るのでテストが出来ないという事が起きます。

この場合ロジック側からは通信インターフェースだけを利用する形にするのが良いです。
インターフェースを通すことで通信の実装クラスを切り替えることが出来るので、
テスト用通信実装にも切り替えることが出来るようになります。

こうするとテストコードが書きやすくなるだけでなく、ゲーム制作だと負荷テスト用のBot作成時などにも処理を流用しやすく出来ます。
static変数を使用する事も密結合となり、コードの依存性が高くなるので避けるべきでしょう。
static変数が存在するとテストの並列実行が難しくなります。
これら疎結合を維持する事は保守性の高さや拡張性の高さに繋がりますのでテストコード関係なく意識したい所だと思います。

テストコード実装(NUnit)


では実際にテストコードの実装方法を説明します。 言語はC#です。

NUnit初期導入

まずは適当なソリューションを作成します。
ソリューションを用意した後はテストコード記述用のプロジェクトを立ち上げます。

f:id:SionGames:20190507011718p:plain
テストコード用プロジェクト立ち上げ
もしここでNUnitが存在しない場合はNuGetパッケージからNUnitのインストールを試してみて下さい。
f:id:SionGames:20190507011722p:plain
NuGet
テストコード用のプロジェクトを立ち上げた後、テスト対象プロジェクトを参照に追加して下さい。
f:id:SionGames:20190507011715p:plain
f:id:SionGames:20190507011713p:plain
テスト対象プロジェクトの参照追加

ひとまずこれでNUnitを利用する土台は整いました。

NUnit利用方法

テストコードを追加する度に毎回書くことになるコードの説明です。 実装側の処理を例として書いてみました。

/// <summary>
/// ダメージ計算用ステータス
/// </summary>
public class BattleStatus
{
    public int Attack { get; set; }
    public int Defence { get; set; }
}

/// <summary>
/// ダメージ計算処理
/// </summary>
public class BattleCalc
{
    public int CalcDamage(BattleStatus atk, BattleStatus def)
    {
        int damage = def.Defence = atk.Attack;
        return damage;
    }
}

既にお気付きの方がいるかもしれませんが、ダメージ計算式が間違っています。

テストコード側も載せます。

using NUnit.Framework;

/// <summary>
/// ダメージ計算のテストコード
/// </summary>
public class BattleTest
{
    [SetUp]
    public void Setup()
    {
        //各テストメソッドの開始直前に呼ばれる
    }
    [TearDown]
    public void TearDown()
    {
        //各テストメソッドの終了後に呼ばれる
    }

    [Test]
    public void Test1()
    {
        var calculator = new BattleCalc();
        var atkStatus = new BattleStatus();
        var defStatus = new BattleStatus();

        atkStatus.Attack = 5;

        var damage = calculator.CalcDamage(atkStatus, defStatus);

        Assert.AreEqual(5, damage);
    }

    [TestCase(5, 0, 5)]
    [TestCase(5, 3, 2)]
    public void TestCase1(int attack, int defence, int anserDamage)
    {
        var calculator = new BattleCalc();
        var atkStatus = new BattleStatus();
        var defStatus = new BattleStatus();

        atkStatus.Attack = attack;
        defStatus.Defence = defence;

        var damage = calculator.CalcDamage(atkStatus, defStatus);

        Assert.AreEqual(anserDamage, damage);
    }
}

[SetUp]や[TearDown]のアトリビュートはソース上に書いてある通り、テストの実行直前や直後に呼ばれる関数です。
[Test]や[TestCase]のアトリビュートを付けた関数がテストとして実際に実行されます。
TestCaseを利用するとテストコード側関数の引数に値を入れられるので複数パターンでテストしたい時にとても重宝します。

テストの実行を選択するとテストエクスプローラーに結果が表示されます。

f:id:SionGames:20190507014122p:plain
テストの実行
f:id:SionGames:20190507014455p:plain
テストの結果
結果は御覧の通りで一部のテストが失敗しているのが確認出来ます。
失敗したテストをクリックすると、失敗した場所や理由がStackTrace付きで確認出来ます
また、TestCaseを使用していないテストコードでは検証パターンが不足していて甘いテストコードになっています。
検証パターンが不足している為、今回埋め込まれたバグが検出できずにいる状態です。
出来るだけ考えうる全てのパターンを網羅できると良いと思います。

今回検証チェックにはAssert.AreEqual()関数を使用していますが、これは同じ値でなければ例外を出してテストを失敗させる機能です。
他にもAreNotEqual()関数やGreater()関数など様々な検証用関数があるので調べて見て下さい。

後書き


テストコードは作ったら終わりではなく、継続的に実行と維持を行う事が重要です。
Gitでマージされたあとのタイミングなどで自動テスト実行を行い、失敗時に通知させる環境を整えてしまえばバグが早期に発見できて効果的です。
余裕があれば自動テスト実行を行う環境構築なども記事にしてみたいと思います。