LoginSignup
5
5

More than 3 years have passed since last update.

【Unity Test Runner】テスト駆動開発でプレイヤーHPクラスを実装する

Last updated at Posted at 2020-10-11

はじめに

テスト駆動開発(TDD)に興味があったので、いろいろ調べて挑戦してみました。
今回はその忘備録として、テスト駆動開発でプレイヤーのHPクラスを実装した話を書こうと思います。


環境
Unity : 2019.4.10f1
Visual Studio : 2019 ver16.7.2

Test Framework : 1.1.16
最新は1.1.18なんですね。アップデートしてない・・・。

コード全文(Gist)
https://gist.github.com/poyoppo/904ec3acca61b0ded405563010696a7c
コード内の英語コメントが初心者すぎるけど気持ちで読んでください:bow:丸投げ

テスト駆動開発とは

めちゃくちゃざっくりいうと、先にテストコードを書いてからプログラムを実装する開発方法です。
テスト駆動で開発するとプログラムの振る舞いがテストによって担保される、バグが減る、リファクタリングしやすくなる、などいろいろ良いことづくしなようですね。
初学者のわたしがアレコレ説明するよりも分かりやすい記事がたくさんあるのでご覧ください(丸投げ

参考になる記事
なぜテストコードを書く必要があるのか?
僕たちがテスト駆動開発をする理由

テスト駆動開発する!!

それでは早速テストを書いていきたいと思います。

Unity Test Runnerの導入についてはこちらの記事が分かりやすいです。
Unityでテストを書くのが当然になる時代に今から備えよう

わたしのフォルダ構成もこの記事に準拠しています:thumbsup:
0000_フォルダ構成.jpg

テストコードに「満たすべき仕様」を書いていく

今回はゼルダのようなHPを実装することにしたので、次のような仕様が求められそうです。

  • HPはハートで表される
  • ハートのかけら4つでハートが1つ増える(最大HPアップ)
  • 最小ダメージはハート4分の1つ
  • その他、基本的なHPの仕様(回復・ダメージ・HPは最大HPを超えない・HPは-1以下にならない・etc...)

「こんなことを調べる必要があるな」というものを、とりあえず書いていきます。

テストコード
[Test]
public void A_コンストラクタテスト()
{
}

[Test]
public void A_コンストラクタの引数に0以下を渡すと例外が発生する()
{
}

[Test]
public void D_IsFullプロパティテスト()
{
}

[Test]
public void G_回復メソッドテスト()
{
}

[Test]
public void G_HPは最大HPを超えない()
{
}

[Test]
public void G_回復メソッドの引数に負の値を指定すると例外が発生する()
{
}

// 以下略

わたしの場合、テストコード内では分かりやすさ優先で日本語で書いていきます。
英語力のある方は英語でまったく問題なしです。
(メソッド名などに日本語を使えるのは、[Test]属性が付いているから?とか、テストコード内だから?とか思っていましたが、ふつうのクラスでも日本語で書けるんですね。そんなことしないけど・・・)

テストコード内のメソッドの順番と、Test Runnnerウィンドウのメソッドの順番(50音・ABC順になる?)が異なり分かりづらいので、大文字の英字_テスト名()にしています。
また、コンストラクタのテストはA_コンストラクタテスト()、プロパティのテストはD_プロパティテスト()・・・のようにテストしたい項目によって先頭の英字を変えました。
こうすることによってTest Runnerウィンドウ上の表示が何もしないよりは分かりやすくなります。
0001_TestRunnnerウィンドウ.jpg
ちなみにテストをカテゴリ分けすることもできるようですが、詳しく調べてません。
【Unity】Unity Test Runner のテストをカテゴリ分けする方法

テストコードを書く

実際にテストコードを書いていきます。
テスト対象であるPlayerHpの実装より先にテストコードを書きます。

例えば、コンストラクタのテストをするのであれば、しっかりインスタンスが生成されていること、初期状態は最大HPと現在HPが等しいことなどが分かれば良さそうです。
また、インスタンス生成時に0以下の値を渡すと例外が発生するようにしたいです。

以上の仕様をテストするコードが以下です。
※今回はPlayerHpクラスの生成時に、内部で「初期ハート数*4」という計算をすることをこの時点で決めてました。
(最初はハート4分の1つ=0.25fという扱いにしてたのですが、不便すぎたので・・・)

テストコード
[Test]
public void A_コンストラクタテスト()
{
    // 初期HPはハート3つ分
    var playerHp = new PlayerHp(3);

    // playerHp が存在する
    Assert.That(playerHp, Is.Not.Null);

    // 最大HP, 現在のHPの初期値は12(3 * 4 = 12)である
    Assert.That(playerHp.MaxHp, Is.EqualTo(12));
    Assert.That(playerHp.CurrentHp, Is.EqualTo(12));
}

[Test]
public void A_コンストラクタの引数に0以下を渡すと例外が発生する()
{
    TypeInitializationException ex = Assert.Throws<TypeInitializationException>(() => new PlayerHp(0));
    Assert.AreEqual(ex.TypeName, typeof(PlayerHp).FullName);
    Assert.That(ex.InnerException, Is.TypeOf<ArgumentOutOfRangeException>());
}

// 以下略

テストにはNunit.FrameworkのAssertクラスを使います。
基本的には次のAssert.Thatメソッドを用いて書きます。

Assert.That(テストしたい値, 期待する値);

Assert.AreEqual()という書き方もあるようですが、現在はAssert.Thatで書くのが推奨されているようです。
NUnitのAssert.ThatメソッドにIsとかHasとかを入れて柔軟なテストコードを書こう

PlayerHpクラスに最小限の実装を書く

このままだとコンパイルエラーになるので、PlayerHpクラスにプロパティやコンストラクタを定義しておきます。あとからテストする回復メソッドやダメージメソッドも定義だけしておきます。
中身の実装はしません。

PlayerHp.cs
public class PlayerHp
{
    private int maxHp;
    private int currentHp;

    public int MaxHp { get { return maxHp; } } // 最大HP
    public int CurrentHp { get { return currentHp; } } // 現在のHP

    // コンストラクタ
    public PlayerHp(int initialHeartCount)
    {
    }

    // メソッドも同様に書いていく
    public void HealHp(int healPoint)
    {
    }

    public void DamageHp(int damagePoint)
    {
    }
}

テスト実行→失敗

プロパティなどを定義するとコンパイルが通るので、Unityに戻ってテストを実行してみます。

・・・。

0002_テスト失敗.jpg
当たり前ですが、テストは失敗します。

なぜコードそのものを書く前にテストを書くのか、不思議に思う方もいらっしゃるでしょう。その理由は、コードを書いてからテストを書くようにすると、開発者はしばしばテストが通るようにテストを書いてしまうことがあるからです。まず失敗するテストを書けば、テストが失敗するのには妥当な理由(たとえば、必要な機能が正しく実装されていない)があることを確信でき、誤検知を排除することができます。

Unity Test Runner でテスト駆動開発を試す(Unity公式ブログより)

「実装してないので失敗する」ということが分かるのが重要なようです。

テスト対象スクリプトを実装する

ここまできて初めてPlayerHpの実装をしていきます。

PlayerHp.cs

// 最大HP計算用の定数
private const int PIEACE_AMOUNT = 4;

// コンストラクタ
public PlayerHp(int initialHeartCount)
{
    if (initialHeartCount <= 0)
    {
        throw new TypeInitializationException(typeof(PlayerHp).FullName,
            new ArgumentOutOfRangeException());
    }

    int initHp = initialHeartCount * PIEACE_AMOUNT;

    maxHp = initHp;
    currentHp = initHp;
}

// 以下略

改めてテスト実行

今度はテストが通るはずです。
この繰り返しで他のメソッドも実装していきます。

Ej7ii-JVoAAULQq.png

ほかのテストもすべて通るようになればPlayerHpクラスは完成です。

まとめ

やってみる前はとっつきにくいイメージでしたが、挑戦してみると面白く、テストがすべて通ったときの気持ちよさもやみつきになりそうです・・・。
ただ慣れるまでは「テストコード自体が間違っている」ということも起きかねないので、色んなクラスのテストを書いてみて経験を積んでいきたいと思います。

一度やってみたくらいではテスト駆動開発による恩恵をすべて体感することはできませんでしたが、テスト駆動を用いていなかった今までと比べて、「どう書けばいいのか分からない」が無くなる(減る)というのが自分にとっての一番のメリットに感じました。

次の挑戦

各テスト前後に実行される属性である[SetUp][TearDown]などには触れなかったので、今後使ってみようと思います。
テスト毎にPlayerHpをインスタンス化しているところも[SetUp]を用いて書き直せそうですね:thinking:

さいごに

もっと良い実装方法やテスト方法があったら教えていただけると嬉しいです。
また、誤字脱字・コードの間違いなどがあればそちらもご指摘いただけると助かります:relaxed:
最後まで読んでくださりありがとうございました!

5
5
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
5
5