組込みソフトウェアのリファクタリングとE2Eテスト

こんにちは。  Esperna - Qiita  です。 想定読者は日々コード負債と闘うソフトウェアエンジニアです。 今回伝えたいことは以下の2点です

  • 単体テストを特定のAPIを起点としてE2Eに近い形で書くと壊れやすいテストが減る
  • 特定のAPIの呼び出しに対するHWの振る舞いをmock化して単体テストを記述できてしまえばHW無しでも組込みソフトウェアの先行開発ができる

背景

nodeJS初心者がレガシーコードのnodeJSに単体テストを入れてCIしてみた

で、Jestを使ったCIを導入して以降、単体テストを書きながら、開発を進めていました。 2つの理由で単体テストを書くのに時間がかかる状況でした。

  • そもそもあるべき仕様や振る舞いが分からない
  • テストを考慮した設計になっていないので、テストが書きにくい
    • 例:クラス間の依存関係が複雑で、テストを実行できる状態にする(依存関係を解決して必要な箇所のmockを作る)のに時間がかかる

上記のような状況において、まずは動くテスト(仕様)を増やしていきました。 しかし、結果として一つの単体テストで一つのクラスをテストすることが多く、 機能を変更していないにも関わらず設計・実装の変更により単体テストがFailしやすい状況になってしまいました。 これはアンチパターンでした。

それでどうしたか?

リファクタリングをしたいクラスのAPIに対して、大きく2つのことを行いました

(1)クラスのAPIの仕様を時間をかけて理解していった

まずソースコードを読んで、リバースエンジニアリングを行い、自分自身の理解のためにクラス図とシーケンス図を書きました。 対象のクラスは1,400行弱のクラスで、関連するクラスも含めると2,000行以上になります。

また、実際にHWを動かして様々な条件でAPIが呼ばれた時のHWの振る舞いを汎用ポートの状態を通して理解するということを行いました。 汎用ポートの状態はをbit array で管理されていました。つまり、クラスのAPIブラックボックスに見立てて、 パラメータを入力した時にどんなbit arrayが出力されるのかということを整理しました。 さらに前述のbit array出力をシステムが受けた時にどのような振る舞いを行っているかということを整理しました。

(2)理解した振る舞いを、リファクタリング対象のクラスのAPIを起点とした「E2Eに近い」テストとして記載していった

単体テストを書く際に意識したことはクラスごとに単体テストを書くのではなく、当該APIを起点としてE2Eテストに近い形で書くことでした。 「E2Eテストに近い」という表現を用いているのは 一般にE2Eといった場合、webAPI等を起点としてサーバやDBなども含めたシステム全体でのE2Eを指すと思いますが、 あくまでIoT製品内の一部のモジュールの中のクラスの特定のAPIを起点として、HW/ドライバ層をmock化したシステムでのE2Eを指しているためです。

前述の通りクラスのAPIに関して、パラメータを入力した時にどんなbit arrayが出力されるかは分かっているので、 そのbit arrayの出力をmock化(HW・ドライバのmock化)して、 さらにその出力を受けた時にシステムが行うべき振る舞い(例えばサーバに履歴を残す等)を評価するテストを書きました。

以下には実際に書いたテストを簡略化・抽象化した一例を書いています。 テスト対象のAPIをtestTarget.APIとしており、API戻り値のチェックをします。 checkBeforeNotification()で評価対象のAPIを呼んだ直後のシステムの振舞をチェックします。 mock.dummyNotify(portBitStatus)でHW(ドライバ)からの通知をmock化し、bit arrayの出力をエミュレートします。 portBitStatus.YYYには11110100のようなbit arrayの出力が入ります。 checkAfterNotification()でHW(ドライバ)からの通知が来た後のシステムの振舞をチェックします。

 describe('XXX Test', () => {
    it.each([
        [
            'API acts hoge if paramA is fuga and paramB is piyo',
            paramA,
            paramB,
        ],
    ])('%s', async (testCase, paramA, paramB) => {
        //事前条件の設定
        await setup(
            paramA,
            state.XXX,
            portBitStatus.YYY,
            paramB
        );
        const result = await testTarget.API();//評価対象のAPIをコール
        expect(result).toBe(SUCCESS);//API戻り値のチェック
        checkBeforeNotification();//評価対象のAPIを読んだ直後のシステムの振舞チェック
        mock.dummyNotify(portBitStatus);//HW(ドライバ)からの通知をmock化し、bit arrayの出力をエミュレート
        checkAfterNotification();//HW(ドライバ)からの通知が来た後のシステムの振舞チェック
    });
});

実際はもう少し複雑なテストを250ケースほど書きましたが、 当該APIそのものを変更しない限りは機能を変更していないにも関わらず設計・実装の変更によりテストが 壊れてテストを書き直すということが減りました。

なお、上記は1度に全ての仕様を理解して、テストコードを書いてリファクタリングできたわけではなく 実際は何度も実機を動かして足りないことに気づいて、テストを書き足すの繰り返しでした。 ただ、書き溜めたテストがあるので、コード変更時のデグレに対する恐怖感はほぼなかったです。 既存の振る舞いが壊れた時はテストがすぐに教えてくれるからです。 実機で想定外の挙動が起こった時はテストが足りなかったのだと思うことができました。

今後の課題

上記のリファクタリングを進めていく中で1つのテストファイルにおけるテストの数が多くなってしまいました。 こちらはテストコードのファイルを分割しましたが、まだテストコードのリファクタリングの余地はあります。 また、APIに対する自動テストはあるが、APIに対する仕様書がない(設計クラス図、シーケンス図はあるが鍵となるクラスのAPIに対する仕様書がない)のが、 属人性という観点で課題と考えています。 これがなぜ課題かというと、初めてチームに来た人がいた時にテストコードを追加しメンテし続けることがおそらく難しいと思われるからです。 今後はこの辺りの仕様書の充実やテストコードの読みやすさなどを改善し、チーム全員がテストコードをメンテナンスしやすくしていきたいと思っています。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp

Akerunにご興味のある方はこちらから akerun.com