みなさんこんにちは。総務省が、「統計表における機械判読可能なデータの表記方法の統一ルールの策定」と題してスプレッドシート形式でデータを作成する際のルールを策定し、資料を公開しました。修正前と修正後の例を示しながらわかりやすく説明されています。スプレッドシート形式でのデータの扱いは本連載の第27回でも言及しているようにとても大事な内容なので、ぜひご覧になってください。

TEXT_痴山紘史 / Hiroshi Chiyama(日本CGサービス
EDIT_尾形美幸 / Miyuki Ogata(CGWORLD)

記事の目次

    テストを書いてみる

    前回は、初めてテストを書く方に向けてほんの触りの部分をご紹介しました。今回はもうちょっと深掘りしてみます。前回同様、Mayaを使ってファイルの変換をする処理をとっかかりにしていきましょう。

    まず、前回の "何やかんやいろいろがんばってmaファイルをabcファイルに変換する" 部分をつくっておきます。

    # -*- coding: utf-8 -*-
    import os
    import sys
    
    import maya.standalone 
    maya.standalone.initialize(name='python')
    
    import maya.cmds as cmds
    
    cmds.loadPlugin('AbcExport.mll')
    def getTops():
        ret = []
        for t in cmds.ls(type='transform'):
            p = cmds.listRelatives(t, parent=True)
            if p is not None:
                continue
            if t in ['front', 'top', 'side', 'persp']:
                continue
            ret.append(t)
    
        return ret
    def _launch_maya_and_convert_to_abc(src, dst):
        cmds.file(src, open=True, force=True)
        tops = getTops()
        cmd = '-frameRange 0 0 -uvWrite -root %s -file "%s"' % (tops[0], dst.replace(os.sep, '/'))
        cmds.AbcExport ( j = cmd )
    
        return True
    if __name__ == '__main__':
        src = os.path.abspath(sys.argv[1])
        dst = os.path.abspath(sys.argv[2])
    
        _launch_maya_and_convert_to_abc(src, dst)

    こんな感じでしょうか。早速実行してみます。Mayaの機能を使用するので、mayapyを使用します。

    >mayapy ma2abc.py Fiat1500.ma Fiat1500.abc
    AbcExport v1.0 using Alembic 1.7.5 (built Feb  6 2018 18:28:08)
    ファイルの読み取りに0.25秒かかりました。

    これで無事にファイルができました。このプログラムに対してテストを書いていきます。テストはPython標準のunittestを使って書きます。unittest.TestCaseから派生したクラスを作成し、テストとして実行したい関数はtestから始まる名前をつけます。

    また、テストの実行を楽にするためにファイルはtestsフォルダにまとめておきます。

    # -*- coding: utf-8 -*-
    import os
    import sys
    import unittest
    
    base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
    sys.path.append(base_dir)
    
    import ma2abc
    import maya.cmds as cmds
    class Test_Convert(unittest.TestCase):
        def setUp(self):
            cmds.file(newFile=True, force=True)
    
        def test_launch_maya_and_convert_to_abc(self):
            src = os.path.join(base_dir, 'Fiat1500.ma')
            dst = os.path.join(base_dir, 'Fiat1500.abc')
            # 関数を実行する
            result = ma2abc._launch_maya_and_convert_to_abc(src, dst)
        
            # 実行した結果、こうなるはずとわかっていることを書く
            self.assertEqual(result, True)
            self.assertEqual(os.path.exists(dst), True)
    
            # 作成したファイルは消しておく
            os.remove(dst)
    
    
    if __name__ == '__main__':
        unittest.main()

    setUp()は、各テストを実行する前に毎回実行される関数です。ここでシーンをクリアしています。

    できあがったファイルは以下のような構成になっています。

    +ma2abc
     +ma2abc.py
     +Fiat1500.ma
     +tests
      +test_Convert.py

    準備ができたのでテストを実行してみます。こちらも先ほどと同様mayapyを使用します。ここで、unittestの機能を使うことでtestsフォルダ以下にある全てのテストを自動的に見つけて実行することができます。

    >mayapy -m unittest discover tests
    AbcExport v1.0 using Alembic 1.7.5 (built Feb  6 2018 18:28:08)
    ファイルの読み取りに0.29秒かかりました。
    .
    ----------------------------------------------------------------------
    Ran 1 test in 0.679s
    
    OK

    ひとつのテストが問題なく実行されたことがわかります。いい感じですね。

    より詳しくテストを書く

    Mayaを使用してファイルを読み込んでabcファイルを出力する機能と、そのテストができました。ただ、これだけだと不安な点がいろいろと残ります。例えば

    ・ファイルの読み込みは正常に行われているのか
    ・作成されたabcファイルの中身は正しいのか

    といった点は、処理を自動化する際に常にトラブルの元になる部分です。これは精神衛生上良くないのでテストを書きます。テストを書きやすいように元のコードを少し整理します。

    # -*- coding: utf-8 -*-
    import os
    import sys
    
    import maya.standalone 
    maya.standalone.initialize(name='python')
    
    import maya.cmds as cmds
    
    cmds.loadPlugin('AbcExport.mll')
    def getTops():
        ret = []
        for t in cmds.ls(type='transform'):
            p = cmds.listRelatives(t, parent=True)
            if p is not None:
                continue
            if t in ['front', 'top', 'side', 'persp']:
                continue
            ret.append(t)
    
        return ret
    def load_file(src):
        cmds.file(src, open=True, force=True)
    
        return True
    
    def export_abc(top, dst):
        cmd = '-frameRange 0 0 -uvWrite -root %s -file "%s"' % (top, dst.replace(os.sep, '/'))
        cmds.AbcExport ( j = cmd )
    
        return True
    if __name__ == '__main__':
        src = os.path.abspath(sys.argv[1])
        dst = os.path.abspath(sys.argv[2])
    
        load_file(src)
        tops = getTops()
        export_abc(tops[0], dst)

    mayapyを使用しておりMayaを立ち上げる処理が必要ないので、launch_mayaという名前は適切ではありません。そのため、_launch_maya_and_convert_to_abcを分割してload_fileとexport_abcにしました。これでシーンの読み込みとabc出力のそれぞれに対してテストが書きやすくなります。

    これに合わせてテストも整理します。

    # -*- coding: utf-8 -*-
    import os
    import sys
    import unittest
    
    base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
    sys.path.append(base_dir)
    
    import ma2abc
    import maya.cmds as cmds
    class Test_Convert(unittest.TestCase):
        def setUp(self):
            cmds.file(newFile=True, force=True)
    
        def test_convert_to_abc(self):
            src = os.path.join(base_dir, 'Fiat1500.ma')
            dst = os.path.join(base_dir, 'Fiat1500.abc')
            # 関数を実行する
            result = ma2abc.load_file(src)
            self.assertTrue(result)
    
            tops = ma2abc.getTops()
            self.assertEqual(len(tops), 1)
    
            result = ma2abc.export_abc(tops[0], dst)
            self.assertTrue(result)
    
            self.assertEqual(os.path.exists(dst), True)
    
            # 作成したファイルは消しておく
            os.remove(dst)
    
    
    if __name__ == '__main__':
        unittest.main()

    処理内容に対して細かくチェックができるようになりました。特に、ファイルを読み込んだ後に階層の数をチェックできるようになった点は効果が大きいですね。今回はMayaの標準機能を使ってシーンファイルを読み込んでいるだけなのでハマることはないと思いますが、インポータを使用する場合は想定と異なる階層でファイルが読み込まれてしまう場合もあるため、読み込み後のチェックができるのは嬉しいです。

    では、テストを充実させていきます。

    シーンのチェックはどうすれば良い?

    いざテストを書こうと思ったときに困るのが、Maya内のシーンデータのチェックをどのように行えば良いか? ということです。簡単な処理であれば、何か操作した結果を逐一確認する方法で対応もできます。例えば、

    def test_create_sphere(self):
        n = cmds.sphere(name='hogehoge')
        self.assertEqual(n, 'hogehoge')
        objs = cmds.ls('hogehoge')
        self.assertEqual(len(objs), 1)

    という感じです。これくらいであればhogehogeというsphereがシーンにただひとつ存在することまでは確認できます。ただ、これだけだとプログラムを書いていた時点で想定していた、ごく狭い範囲のチェックしかできません。例えば、ある日突然Mayaの仕様が変わって、作成したsphereが必ずgroupの子供になるようなことが起きてもテストを通ってしまいます。こういう事態を想定してテストを書くのは不可能です。

    とは言っても、できるだけ簡単に高い精度でテストする方法がほしいです。このような場合、私は処理前後でシーンがどのように変わったかを比較するようにしています。処理の結果変化した部分を見るだけであれば、元のシーンの状態がどのようになっていてもテストの結果には影響しないですし、想定外の部分に変更が紛れ込んでいても検出できます。この方法を使ってtest_create_sphereを書き換えてみます。

    def dump_scene():
        ret = []
        ret.extend(cmds.ls(type='transform', long=True))
        return set(ret)
    
    def test_create_sphere(self):
        before = dump_scene()
        n = cmds.sphere(name='hogehoge')
        after = dump_scene()
        adds = after - before
        dels = before - after
        self.assertEqual({'|hogehoge'}, adds)
        self.assertEqual(set(), dels)

    set(集合) はちょっとなじみがないかもしれません。リストと似ていますが、中に含まれる要素に重複がないのと、要素間に順番がないのが特徴です。また、集合同士で足し算や引き算といった演算ができます。例えば、

    adds = after - before

    は処理後の集合から処理前の集合を引くことで、処理後に増えた要素を抽出できます。また、逆の処理をすることで消えた要素を見つけることもできます。もう少し詳しい説明は以前Blogに書いたことがあるので、そちらも参照してみてください。

    処理前後で増えたオブジェクトと減ったオブジェクトがわかれば、処理の結果が想定したものになっているかを高い精度で確認できます。また、この方法であれば文字列化してsetに放り込むだけでどのようなものでも比較対象にできるので汎用性が高いです。例えば、ノード間の接続情報も比較対象にする場合、

    def dump_scene():
        ret = []
        ret.extend(cmds.ls(type='transform', long=True))
    
        objs = cmds.ls(long=True)
        conn = cmds.listConnections(objs, connections=True, plugs=True)
        for i in range(0, len(conn), 2):
            ret.append('%s => %s' % (conn[i], conn[i+1]))
    
        return set(ret)

    という感じにすれば実現できます。ここではcmds.lsで取得できる全てのノードに対する接続情報を取得しているので、情報が多すぎて管理できないと考えるかもしれませんが、テストで確認するのは処理前後の差分だけなのでほとんどの情報は無視されます。これが差分を使用して確認するメリットです。

    テストを改良する

    以上の知識を使ってテストを改良していきます。

    # -*- coding: utf-8 -*-
    import os
    import sys
    import unittest
    
    base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
    sys.path.append(base_dir)
    
    import ma2abc
    import maya.cmds as cmds
    
    cmds.loadPlugin('AbcImport.mll')
    class Test_Convert(unittest.TestCase):
        def dump_scene(self):
            ret = []
            ret.extend(cmds.ls(type='transform', long=True))
            return set(ret)
    
        def setUp(self):
            cmds.file(newFile=True, force=True)
    
        def test_load_file(self):
            src = os.path.join(base_dir, 'Fiat1500.ma')
            # 現在のシーンの状態を取得する
            before = self.dump_scene()
            
            # 関数を実行する
            result = ma2abc.load_file(src)
            self.assertTrue(result)
    
            # 実行後のシーンの状態を取得する
            after = self.dump_scene()
    
            # 事前に手動で取得しておいたノードの一覧
            scene_nodes = {u'|ToonCar_2|Antenna', (中略), u'|ToonCar_2|wipers'}
            adds = after - before
            dels = before - after
            
            self.assertEqual(set(), dels)
            self.assertEqual(scene_nodes, adds)
    
        def test_convert_to_abc(self):
            src = os.path.join(base_dir, 'Fiat1500.ma')
            dst = os.path.join(base_dir, 'Fiat1500.abc')
    
            # 関数を実行する
            result = ma2abc.load_file(src)
            self.assertTrue(result)
            tops = ma2abc.getTops()
            self.assertEqual(len(tops), 1)
    
            result = ma2abc.export_abc(tops[0], dst)
            self.assertTrue(result)
    
            self.assertEqual(os.path.exists(dst), True)
           # 出力したファイルの内容をチェックする
            cmds.file(newFile=True, force=True)
    
            # 現在のシーンの状態を取得する
            before = self.dump_scene()
    
            # 出力したファイルの読み込み
            cmds.AbcImport(dst.replace(os.sep, '/'))
    
            # 実行後のシーンの状態を取得する
            after = self.dump_scene()
    
            # 事前に手動で取得しておいたノードの一覧
            scene_nodes = {u'|ToonCar_2|Antenna', (中略), u'|ToonCar_2|wipers'}
            adds = after - before
            dels = before - after
            
            self.assertEqual(set(), dels)
            self.assertEqual(scene_nodes, adds)
    
            # 作成したファイルは消しておく
            os.remove(dst)
    
    
    if __name__ == '__main__':
        unittest.main()

    チェック項目が格段に増え、シーンの状態を細かく追いかけることができるようになりました。実行してみます。

    >mayapy -m unittest discover tests
    AbcExport v1.0 using Alembic 1.7.5 (built Feb  6 2018 18:28:08)
    AbcImport v1.0 using Alembic 1.7.5 (built Feb  6 2018 18:28:08)
    ファイルの読み取りに  0.27 秒かかりました。
    .ファイルの読み取りに  0.25 秒かかりました。
    .
    ----------------------------------------------------------------------
    Ran 2 tests in 1.348s
    
    OK

    これで、

    ・ファイルの読み込みは正常に行われているのか
    ・作成されたabcファイルの中身は正しいのか

    という2つのテストをパスすることができました。単に処理が終わってファイルがつくられたかどうか、というチェック内容に比べて、格段に精密なチェックができるようになっています。ここまで精密なチェックを人間が手動で毎回実行するのは不可能です。しかし、テストを書くことで2秒もかからずに確認できるようになりました。さらにCIツールと併用することで、コードをリポジトリにpushするたびに実行することも可能になるのです。こうなると、とてもではないですが人力確認では太刀打ちできません。

    まとめ

    今回はmaファイルをabcファイルに変換するという題材を元にテストコードを書き、テストを実行してみました。やってみるとわかりますが、これくらい精度の高いテストを書くと、きちんと動くコードとテストを用意するのがけっこう大変です。そのため心が折れそうになりますが、むしろここで戸惑うということは書いたプログラムの挙動に対してきちんと理解できていない部分があるということです。理解が不十分な部分はバグの温床にもなるので、事前に潰しておくことはとても大事なことです。

    また、これだけ確認をしているコードであれば大丈夫だろうという安心感も得られます。この安心感は、本当に大きいです。

    第42回の公開は、2022年3月を予定しております。

    痴山紘史

    日本CGサービス(JCGS) 代表

    大学卒業後、株式会社IMAGICA入社。放送局向けリアルタイムCGシステムの構築・運用に携わる。その後、株式会社リンクス・デジワークスにて映画・ゲームなどの映像制作に携わる。2010年独立、現職。映像制作プロダクション向けのパイプラインの開発と提供を行なっている。新人パパ。娘かわいい。