土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

rubyゲーム開発にユニットテスト/テスティングフレームワークを導入する(2)【実践編】

 Ruby Game Developing Advent Calendar 2016(http://www.adventar.org/calendars/1647) の11日目の記事です。
 9日目はあおいたくさんの「Chipmunk を使いやすくするためのちょっとした工夫」でした(まだ上がってないけど)。「物理演算ライブラリを使って、(物理演算的な挙動を特徴としない)アクションゲームが作れるのか?」というのは長年の疑問の一つです。土屋自身は否定的なのですが、同じように否定的だったユニットテストについても考えが変わったので、来年くらいにはChipmunkをバリバリ使ってるかもしれません。

 今回はゲーム開発におけるユニットテストの導入方法についてサンプルコードでの実例を示します。

前回のおさらい

 前回のをまだ読んでいない形は先にこちらをどうぞ。
rubyゲーム開発にユニットテスト/テスティングフレームワークを導入する【説明編】
http://d.hatena.ne.jp/t_tutiya/20161208/1481209321
 前回は「ユニットテスト/テスティングフレームワークとはなにか」と「ゲームプログラミングとユニットテストの相性の悪さ」について説明しました。
 ユニットテストは開発中に発生する潜在バグを発見する強力なツールですが、ゲームプログラミングの特徴である「メソッドの返し値の情報量が少ない」「メインループの中で複数フレームに渡って処理が行われる」と非常に相性がよくありません。
 今回は、司エンジン+DXRubyでの実コードを示しながら、ゲームプログラミングにおけるユニットテストの導入方法を考えていきます。

ユニットテストでやる事とやらない事を明確化する

 まず、導入するユニットテストが、テスト全体のどの範囲をカバーするのかを明確化しましょう。
1・ユニットテスト〜モジュールテストをカバーする
2・「司エンジンの内部状態の更新が正しく行えていればI/Oへの出力結果も正しくなる」と仮定し、内部状態の更新のみをテストする
3・従来のゲームデバッグは、IT用語で言うところのシステムテストおよびモンキーテストに相当するとして、本テストには含まない(ユニットテストなので)
4・プレイアビリティのテストは含まない(ユニットテストなので)

念の為一個ずつ見ていきます。

1・単体テスト〜モジュールテストをカバーする

・単体テストはメソッド単位/クラスオブジェクト単位でのテスト、モジュールテストは複数のオブジェクトが組み合わさった場合のテストのことです。ユニットテストは、基本的には単体/モジュールに対するブラックボックステスト(インターフェイス仕様を元にテストすること)のことを指します。

2・「司エンジンの内部状態の更新が正しく行えていればI/Oへの出力結果も正しくなる」と仮定し、内部状態の更新のみをテストする

・ここが今回のキモになります。
・I/Oへの出力結果をテストするのは非常にコストが高いので(方法のアイデアについては後述します)、その点については割り切って、司エンジンの内部状態のみをテストの対象とします。
・作業を進めていてわかったのですが、ユニットテストにおいては、ほとんどがこの方法でカバーできると考えています。

3・従来のゲームデバッグは、IT用語で言うところのシステムテストおよび受け入れテストに相当するとして、本テストには含まない(ユニットテストなので)

・ユニットテストは現在のゲーム業界で行われているデバッグ作業を代替する物ではありません。その前段階、日々のコーディングにおける潜在バグやデグレの発生を抑制することを目的としています。

4・プレイアビリティのテストは含まない(ユニットテストなので)

・3と同じように、ユニットテストは「作ったゲームが面白いかどうか」を判定する物ではありません。

実装方針

 司エンジンで作成したゲームをユニットテストする場合、以下の流れで行います。

1・各テストメソッドの中でメインループを回す
2・テストしたいところでループを強制的に抜ける
3・Control#find_control()でテスト対象のコントロールを取得する
4・Control#serialize()でコントロールのプロパティをダンプする
5・ダンプした配列と期待される値をassertメソッドで比較する。

余談1:ダンプ結果を比較するというアイデア

 「Control#serialize()を使う」というアイデアは、太一さんが司エンジンの実装をざっと読んだ時にくれた物で、それを聞いて土屋もようやく「あ、なるほど! それならユニットテストが可能だ!」と考えるようになりました。
 その後、司エンジンの実装自体を改修する事で、より直感的な値の取得も可能になりました。これについてはのちほど実コードで説明します。

テスト1:コントロールのダンプとの比較によるテスト

 それでは実際のコードを見て行きましょう。

 なお、テストコードは本番環境ではビルドに含まれないわけで、それをどういう手順でビルドして実行するかは環境によって異なります。今回はtest.rbを直接実行してますが、将来的にはrubyのスタンダードなスタイルを使うつもりです。

#! ruby -E utf-8
require 'minitest/test'
require '../system/Tsukasa.rb'

#このコードが動作する為には、testフォルダ配下にAyame.dllが配置されている必要がある(将来的に依存関係を辞めたいが、解消できるのか不明)

MiniTest.autorun

class TC_Foo < Minitest::Test

  #コントロールのダンプとの比較によるテスト
  def test_1
    #コントロールの生成
    control = Tsukasa::Control.new() do
      #メインループを終了する
      _EXIT_
    end

    #メインループ
    DXRuby::Window.loop() do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0) #処理
      control.render(0, 0, DXRuby::Window) #描画
      break if control.exit #メインループ終了判定
    end
    
    #比較対象
    reslut = [[:_SET_,
                {:id=>:"Tsukasa::Control",
                 :child_update=>true,
                 :script_parser=>{},
                 :exit=>true},
                {}]]

    #テスト
    assert_equal(control.serialize(), reslut)
  end
end

・こまかく見て行きましょう。

class TC_Foo < Minitest::Test

・テストコードを記述するクラスはMinitest::Testを継承します。
・rubyでは標準でUnit::TestとMinitestの2種類のテスティングフレームワークが用意されています。DXRubyの挙動の関係でUnit::Testは使えないので、今回はMinitestを使っています(この辺りの試行錯誤の経緯についてはこちらをどうぞ>http://d.hatena.ne.jp/t_tutiya/20161127/1480241989)。

  #コントロールのダンプとの比較によるテスト
  def test_1

・Minitest::Testを継承したクラスに"test_"のプレフィクスをつけたメソッドを定義すると、そのメソッドが自動的に実行されます。

    #コントロールの生成
    control = Tsukasa::Control.new() do
      #メインループを終了する
      _EXIT_
    end

・Controlコントロールを生成します。ブロックの中にはこのテストで実行するtsukasa言語を記述します。今回はプログラムを終了する_EXIT_コマンドだけを記述しています。

    #メインループ
    DXRuby::Window.loop() do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0) #処理
      control.render(0, 0, DXRuby::Window) #描画
      break if control.exit #メインループ終了判定
    end

・DXRubyのメインループを回します。
・controlのupdateが実行されるとtsukasa言語が処理され、_EXIT_が実行されてexit()がtrueを返すようになるのでメインフレームは1フレで終了します(一瞬ウィンドウが表示されます)。

    #比較対象
    reslut = [[:_SET_,
                {:id=>:"Tsukasa::Control",
                 :child_update=>true,
                 :script_parser=>{},
                 :exit=>true},
                {}]]

    #テスト
    assert_equal(control.serialize(), reslut)

・コントロールのserialize()を実行すると、コントロールが持っているプロパティをダンプしてPODの配列を返します。
・この配列と期待される値をassert_equalで比較することで、正しくControlが生成されていることが判定できます。

・見事にDXRuby上で動作する司エンジンのテストに成功しました!

余談2:Unit::testとminitest

・rubyにはUnit::testとminitestという2つのテスティングフレームワークが標準で用意されているのですが、これには歴史的な経緯があります。詳しくはこちらを参照してください。

Rubyのテスティングフレームワークの歴史(2014年版) - ククログ
http://www.clear-code.com/blog/2014/11/6.html

テスト2:プロパティとの比較によるテスト

・ここからはゲームオブジェクトから値を取得する方法のバリエーションを見て行きます。テストメソッド以外は変わらないので省略します。

  #プロパティとの比較によるテスト
  def test_2
    #コントロールの生成
    control = Tsukasa::Control.new() do
      #動的プロパティの追加
      _DEFINE_PROPERTY_ test: 0
      #50フレームかけてtestの値を0から100まで遷移させる
      _MOVE_ 50, test:[0,100]
      #メインループを終了する
      _EXIT_
    end

    #メインループ
    DXRuby::Window.loop() do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0) #処理
      control.render(0, 0, DXRuby::Window) #描画
      break if control.exit #メインループ終了判定
    end

    #テスト
    assert_equal(control.test, 100)
  end

・コントロールのプロパティをダンプしなくても、プロパティは直接比較することが可能です。ここではontrol.testプロパティの値を比較しています。
・_DEFINE_PROPERTY_は動的にプロパティを追加するコマンドです。司エンジンではコントロールはrubyで記述されており、カスタムコントロールを記述することもできます。

テスト3:メインループを回さないテスト

  #メインループを回さないテスト
  def test_3
    #コントロールの生成
    control = Tsukasa::Control.new() do
      #動的プロパティの追加
      _DEFINE_PROPERTY_ test: 0
      #50フレームかけてtestの値を0から100まで遷移させる
      _MOVE_ 50, test:[0,100]
      #メインループを終了する
      _EXIT_
    end

    #25フレーム回したと想定
    25.times do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0)
    end

    #テスト
    assert_equal(control.test, 50)
  end

・コントロールのupdate()を実行すれば、内部的には1フレーム分処理されるので、必要なければDXRubyのメインループを回さなくても構いません(本番環境とは乖離が生まれますが)
・ここでは25フレーム分を仮想的に進めてプロパティの現在値を比較しています。

テスト4:複数フレームにわたる値の変化を比較するテスト

  #複数フレームにわたる値の変化を比較するテスト
  def test_4
    #コントロールの生成
    control = Tsukasa::Control.new() do
      #動的プロパティの追加
      _DEFINE_PROPERTY_ test: 0
      #10フレームかけてin_quadイージングでtestの値を0から100まで遷移させる
      _MOVE_ [10, :in_quad], test:[0,100]
      #メインループを終了する
      _EXIT_
    end

    result = []

    #10フレーム回したと想定
    10.times do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0)
      result.push(control.test)
    end

    #テスト
    #第1フレが0から始まってないのがあってるのかどうかよくわからぬ。
    assert_equal(result, [1, 4, 9, 16, 25, 36, 48, 64, 81, 100])
  end

・各フレームにおける値を配列に格納することで、複数フレームにまたがる値の遷移を調べることも可能です。
・ここでは非線形な遷移をしているプロパティの値を比較しています。

テスト5:ゲーム側で判定タイミングのトリガーを用意するテスト

  #ゲーム側で判定タイミングのトリガーを用意するテスト
  def test_5
    puts "zキーを押してください"
    #コントロールの生成
    control = Tsukasa::Control.new() do
      #キー入力管理コントロール
      _CREATE_ :Input, id: :_INPUT_

      #動的プロパティの追加
      _DEFINE_PROPERTY_ test: nil
      #無限ループ
      _LOOP_ do
        #zキーが押された場合
        _CHECK_ [:_ROOT_, :_INPUT_], equal: {key_down: [Tsukasa::K_Z} do
          #プロパティに値を設定
          _SET_ test: Tsukasa::K_Z
          #メインループを終了する
          _EXIT_
        end
        #1フレ送る
        _END_FRAME_
      end
    end

    #メインループ
    DXRuby::Window.loop() do
      control.update(DXRuby::Input.mouse_x, DXRuby::Input.mouse_y, 0) #処理
      control.render(0, 0, DXRuby::Window) #描画
      break if control.exit #メインループ終了判定
    end

    #テスト
    assert_equal(control.test, Tsukasa::K_Z)
  end

・ゲーム中の特定の状況でのテストを行いたい場合もあります。その場合は、テストしたいタイミングになった所で_EXIT_を実行すれば状態を取得できるでしょう。
・サンプルではzキーを押下するのを待っています。入力しない限りはテストが終わらないので自動テストになりませんが、ゲーム側でテストのトリガーを持てることがお分かりかと思います。

コード

・ひとまとめにしたコードはこちらになります。
https://gist.github.com/t-tutiya/0a65d2e8db7d7bd744d45c47313a3fcf

まとめと応用

・いかがでしたでしょうか? 自分で環境を構築しておいてなんですが、ここまですんなり上手くいくと思いませんでした。これだけできるなら、ユニットテストで必要なロジックはなんでも実装できそうです。
・司エンジンではシリアライズ結果から元のコントロールツリーをあるていどは復活させられるので、「テストしたい状況をデシアライズ」→「テストしたい処理を起動」→「メインループを強制終了してテストにかける」ということもできそうです。

応用1:スクリーンショットの比較

・今回は司エンジンの内部状態の更新のみをテスト対象としましたが、今後はI/Oへの出力結果もテストしていきたいところです。
・司エンジンでは_TO_IMAGE_コマンドを介してスクリーンショットを生成できます。あらかじめ保存しておいたSSとバイナリ比較する事で画面の状態もチェックが可能でしょう。
・エネミーの挙動が乱数で変化する場合、SSが一致しないと困るので、シードを統一して結果が同じになるように方針を定めなければなりません。
・また、別のアプローチとして、将来的にはI/O部にモックを設定し、I/Oの入出力もエミュレートできるようにしたいと思っています。これが実現したら、例えばあらかじめキー入力を用意して自動テストプレイなんかもできるかもしれません。

応用2:CI環境の構築

・テストの次段階はCI(continuous integration. 継続的インテグレーション)環境の構築です。これについてはACに空きがあって、かつ試す時間があればやってみたいと思っています(後者が無理ぽ)。

おわりに

・ユニットテスト(特に、メインループを絡めた物)は、ながらくゲーム業界での本格的な導入が、土屋の知る限りなされていませんでした。というか、土屋自身、有効性についてはながらく疑問視していたくらいです。
・今回の記事をきっかけに、ゲーム開発でのテスティングフレームワークの活用についての議論が深まれば嬉しいです。
・明日はmirichiさんの「Rubyの標準添付ライブラリFiddleでゲームプログラミングする」です。お楽しみに!