ブログ@kaorun55

HoloLensやKinectなどのDepthセンサーを中心に書いています。

OpenNIをモックを使ってテストしてみる

Google Mockの解説マダー?と、先日書いたら、さっそく書いてくれました!
感謝です!

この記事で、KINECTから来ると想定されるデータをモックで実装して、以降のロジックをテストする方法を解説してもらいました。
#モックといえば、OpenNI自体にもジェネレータごとにモックがあるので、それを有効に使えないのかなぁ。


で、次にわからなかったのがコレで、akiraさんとtosikawaさんからアドバイスをもらいました

こんな感じ

お題
Player.h
#pragma once

#include <XnCppWrapper.h>

namespace openni {
    // 再生のコールバック
    class PlayerCallback
    {
        friend class Player;

    public:

        virtual ~PlayerCallback(){}

    protected:

        // ファイルの終端
        static void XN_CALLBACK_TYPE EndOfFileReached( xn::ProductionNode& node, void* pCookie)
        {
            ((PlayerCallback*)pCookie)->EndOfFileReached( node );
        }

        // ファイルの終端
        virtual void EndOfFileReached( xn::ProductionNode& node )
        {
        }
    };

    // 再生
    class Player
    {
    public:

        Player()
            : callback_( 0 )
        {
        }

        ~Player()
        {
            if ( player_.IsValid() && (callback_ != 0) ) {
                player_.UnregisterFromEndOfFileReached( callback_ );
            }
        }

        xn::Player& GetPlayer() { return player_; }

        void RegisterCallback( PlayerCallback* callback )
        {
            assert( player_.IsValid() );

            XnStatus rc = player_.RegisterToEndOfFileReached( &PlayerCallback::EndOfFileReached, callback, callback_ );
            if ( rc != XN_STATUS_OK ) {
                throw std::runtime_error( xnGetStatusString( rc ) );
            }
        }

    protected:

        xn::Player player_;
        XnCallbackHandle callback_;

    };
}
テスト
#include <gtest\gtest.h>
#include <gmock\gmock.h>

#include "openni\Player.h"

namespace {
    const std::string RECORD_FILE_NAME = "EndOfFileReached.oni";

    // アプリケーションクラス
    class App : public ::openni::PlayerCallback
    {
    public:

        App()
        {
            // プレーヤーの作成と設定
            context.Init();
            context.OpenFileRecording( RECORD_FILE_NAME.c_str() );
            context.FindExistingNode( XN_NODE_TYPE_PLAYER, player.GetPlayer() );
            player.RegisterCallback( this );
            player.GetPlayer().SetRepeat( false );
        }

        void Run()
        {
            // EOFまで回す
            while ( !player.GetPlayer().IsEOF() ) {
                context.WaitAndUpdateAll();
            }
        }

        // ファイルの終端
        virtual void EndOfFileReached( xn::ProductionNode& node )
        {
            std::cout << "ファイルの終端" << std::endl;
        }

    private:

        xn::Context context;
        openni::Player player;
    };

    // アプリケーションクラスのモック
    class MockApp : public App
    {
    public:

        MOCK_METHOD1( EndOfFileReached, void( xn::ProductionNode& node ) );
    };
}

TEST( PlayerTest, EndOfFileReached )
{
    using ::testing::_;

    MockApp app;

    // モックの設定
    EXPECT_CALL( app, EndOfFileReached(_) )
        .Times( 1 );        // ファイルの終端検出は1回呼び出される
//        .Times( 2 );      // 2回はNG

    app.Run();
}

解説

Player

モックでコールバック関数に対する設定を行うためには、コールバック部分をメンバ関数する必要があります。そのため、もともとのOpenNIにあるPlayerのコールバック部分をラップしましてそれを実現しています。
Cの関数をコールバックするには、関数のアドレスが固定(非メンバ関数)である必要があります。非メンバ関数をクラス関数にして、渡したいメンバ関数があるthisポインタをわたし、クラス関数内でメンバ関数を呼び出す方法をとります。
実際には、OpenNIに登録するCの関数をstaticのEndOfFileReachedで用意し、cookieにPlayerCallbackへのポインタを渡すことで、メンバ関数のEndOfFileReachedへ渡すことができます。

class PlayerCallback {
    ...
    // ファイルの終端
    static void XN_CALLBACK_TYPE EndOfFileReached( xn::ProductionNode& node, void* pCookie)
    {
        ((PlayerCallback*)pCookie)->EndOfFileReached( node );
    }

    // ファイルの終端
    virtual void EndOfFileReached( xn::ProductionNode& node )
    {
    }
    ...
};


関数の登録側では、クラス関数のEndOfFileReachedにコールバックをかけてもらい、cookieにコールバックしてほしいインスタンスを指定します。

class Player {
    ...
    void RegisterCallback( PlayerCallback* callback ) {
        XnStatus rc = player_.RegisterToEndOfFileReached( &PlayerCallback::EndOfFileReached, callback, callback_ );
        ...
    }
    ...
};
App

次は、コールバックされる側の実装です。
コールバックさせるクラスAppを実装します。このクラスにコールバックしてもらうため、::openni::PlayerCallbackを継承し、EndOfFileReachedを実装します。

class App : public ::openni::PlayerCallback {
    ...
    App()
    {
        // プレーヤーの作成と設定
        ...
        player.RegisterCallback( this );
        ...
    }
    ...
    // ファイルの終端
    virtual void EndOfFileReached( xn::ProductionNode& node )
    {
        std::cout << "ファイルの終端" << std::endl;
    }
    ...
};


また、ファイルのフレームを進めるために、メインループとしてRunを実装し、playerから終端までデータを読むようにします。

class App : public ::openni::PlayerCallback {
    ...
    void Run()
    {
        // EOFまで回す
        while ( !player.GetPlayer().IsEOF() ) {
            context.WaitAndUpdateAll();
        }
    }
    ...
};
MockApp

AppのEndOfFileReachedをテストするために、Appを継承するMockAppを実装し、EndOfFileReachedをモックに指定します

// アプリケーションクラスのモック
class MockApp : public App
{
    ...
    MOCK_METHOD1( EndOfFileReached, void( xn::ProductionNode& node ) );
};
テスト

最後にテストコードです。
モックに指定した「EndOfFileReachedが1回呼び出されること」を確認するテストを書きます。
モック関数の成否判定はMockAppのデストラクト時に行われるようなので、モックの設定をしてメインループを呼び出します。
メインループでは、ファイルの終端までループする(コールバックが呼ばれるハズ)ので、MockAppのデストラクト時にはEndOfFileReachedが一回呼ばれるはずです。

TEST( PlayerTest, EndOfFileReached )
{
    using ::testing::_;

    MockApp app;

    // モックの設定
    EXPECT_CALL( app, EndOfFileReached(_) )
        .Times( 1 );        // ファイルの終端検出は1回呼び出される
//        .Times( 2 );      // 2回はNG

    app.Run();
}
実行

適当なoniファイルを用意し、RECORD_FILE_NAMEにファイル名を指定して実行します。
結果として一件のテストが成功します。


テストがなされているか確認するために、二回呼び出されていいるかのテストに変えると見事に失敗します。


これでコールバックが呼び出されているテストができました。
ユーザー研修や、ポーズの検出、骨格追跡のためのキャリブレーションなども、実データを用意することでテストが可能になるでしょう。
ただし、実データを使用するため、テスト時間が記録ファイルの記録時間に依存します。そのため、スローテストになるので、CIなどの際には分散などの工夫が必要になりそうです。

まとめ

KINECTアプリケーションのテストのレイヤーを考察してみると、こんな感じなのかなぁと思います。
ぜひともMSの方には、NUIアプリケーションのテストについての考察も、アウトプットしていただきたくお願いしたいですね。

項目 ツール 実例
KINECTに依存しないロジック部分 GoogleTest
KINECTに依存するライブラリ部分 GoogleMock + GoogleTest
KINECTの入力に依存するロジック部分 GoogleMock + GoogleTest
実際の表示に関する部分 Record & Playerで自動化 + 画像比較でテスト ×