X. 言語開発の品質コントロールにおけるセルフホスティングの重要性とテスト設計
このドキュメントを見ているような人であれば、 日々「プログラミング言語」でコーディングしている方がほとんどだと思います。 そして、誰かが仕込んだバグ潰し作業に追われている方も多いでしょう。
そんな日々お世話になっている「プログラミング言語」も 誰かが作ったソフトウェアであるので、いかにバグを出さないか、 という品質コントロールが重要になります。
私は運が良いいのか(?)、プログラミング言語を仕事で利用してきた中で 言語のバグに遭遇したことはありません。
バグの様に思える言語の規格とかはありますが。。。
プログラミング言語のバグに遭遇してしまった場合、 その対処は数あるソフトウェアバグの中でもかなり厄介な部類になると思います。
そもそもプログラミング言語のバグだということに辿り着くのが困難です。
ここでは、 私が開発している独自言語のテスト方法について紹介したいと思います。
なお、プログラミング言語は次の 2 つに分類できます。
- コンパイラ型
- インタプリタ型
私が開発している言語はコンパイラ型であるため、 以降はコンパイラ型のテストについて話をします。
ちなみに私が開発している独自言語の LuneScript については、次の記事で紹介しています。
https://qiita.com/dwarfJP/items/21d4d4099ab0feb68eaf
今後、独自言語を開発しようと考えている方のテスト設計検討に、 少しでも役にたてれば幸いです。
コンパイラは関数
コンパイラ型プログラミング言語は、 そのプログラミング言語で書かれたコードをマシン語などに変換するのが仕事です。
例えば、
- C 言語のコンパイラは Native コードに変換
- Java のコンパイラは JVM コードに変換
- C# は CIL に変換
- Clang は LLVM-IR に変換し、 LLVM が各種コードに変換
つまりコンパイラとは、 「入力を与えると、その入力に応じた出力を返す 1 つの大きな関数」と考えられます。
コンパイラを 1 つの関数と考えれば、そのテストは非常に単純です。 様々な入力を与えて、その出力と期待値とを比較すればテストが出来ます。
日頃作成している関数の UNIT TEST と考え方は全く同じです。
コンパイラのテスト
独自言語である LuneScript では、次のテストを実施しています。
- セルフホスティングしている LuneScript 自身のビルド
- 言語が対応する全構文の正常系
- 言語が対応する全構文の異常系
ここで特に重要なのがセルフホスティングしていることです。
セルフホスティングしていることで、あえてテストコードを書かなくても、 自分自身のコードがそのままテストコードになります。
ある程度の規模で、意味のあるテストコードを作成する、 というのは中々骨の折れる作業です。
特に自分以外誰も使っていないような独自言語の場合、 テストのためのコードではなく、 ちゃんとした実用的なコードというものが github を探せば簡単に出てくる、 なんてことはない ので、 ある程度の規模のテストコードというのは貴重になります。
セルフホスティングしていると、 自分自身のコードがその貴重なテストコードになるのです。
ただ、「自分自身のコードがそのままテストコードになる」と言っても、 それだけでは十分なテストにはなりません。 使用する構文やデザインパターン等に偏りが出てしまい、 網羅性という意味ではイマイチなテストになってしまいます。 また、コンパイルエラーになるような異常系コードは、 セルフホスティングしている自分自身のコードに仕込んでおけません。 よって、セルフホスティングしている自分自身のコードだけでは テストケースとして不十分であり、 網羅的に正常系を確認するテストと、 コンパイルエラーを検出する異常系のテストが別途必要になります。
この正常系と、異常系のテストは、 予め期待値を用意しておくことで、テストの成否を確認出来ます。
一方で「自分自身のコードをコンパイルした結果が正しいかどうか」、 をどのように判定すれば良いか?が問題です。
テストケースのコードは一般的に不変なので、そのコンパイル結果も不変です。 つまり、テストケースと期待値のペアを一度作成すれば、 テストケースを変更しないかぎりは同じ期待値を使い続けられます。
一方で、セルフホスティングしている自分自身のコードは当然変っていきます。 つまり、期待値も常に変わるため、期待値を事前に用意しておくことは不可能です。
では、セルフホスティングしている自分自身のコードのコンパイル結果が正しいかどうかを、 どのように判断するのかというと、 次が成り立つかどうかで判断します。
「使用中のコンパイラでのテストケースの結果」 == 「新しくコンパイルしたコンパイラでのテストケースの結果」
これは、使用中のコンパイラが正しい動作をしていることを前提に、 その正しい動作をしている使用中のコンパイラで実行したテストケースの結果と、 新しくコンパイルしたコンパイラで実行したテストケースの結果が同一であるならば、 新しくコンパイルしたコンパイラも正しい、という論理です。
さらに、新しくコンパイルしたコンパイラで、もう一度自分自身をコンパイルしています。 これは、同じコードをコンパイルしたときに、 その出力結果が全く同じ結果になることを確認するために実行しています。
まとめると、 LuneScript のテストは次を実行します。
- step1
- 現在使用中のコンパイラ A を使って、セルフホスティングしている自身のコードをコンパイルしコンパイラ B を生成
- step2
- コンパイラ B を使って、再度自身のコードをコンパイルしコンパイラ C を生成
- step3
- コンパイラ C を使って、再度自身のコードをコンパイルしコンパイラ D を生成
- step4
- コンパイラ C とコンパイラ D が同一であることを確認
- step5
- コンパイラ A の正常系、異常系のテストを実行し、テスト結果を result A に保存
- step6
- コンパイラ D の正常系、異常系のテストを実行し、テスト結果を result D に保存
- step7
- result A と result D が同一であることを確認
上記テストをパスしたら、コンパイラ D を最新のコンパイラ A として次回から利用します。 また、拡張した言語仕様の正常系、異常系のテストを随時追加します。
セルフホスティングの場合、 不具合があると自分自身のコンパイル自体が出来なくなって、 開発を進められなくなってしまう可能性があります。 このテストを行なうことで、 新しくビルドしたコンパイラが正常に動作することを確実に保証でき、 安全に言語の機能拡張を進められます。
独自言語の場合、 セルフホスティングへの移行タイミングというのは非常に重要になると思います。
コンパイラのコード規模が大きくなると移植作業に掛かる時間も大きくなってくるので、 独自言語をフルスクラッチで開発する場合、 セルフホスティングに必要な機能を優先的に実現し、 出来るだけ早い段階でセルフホスティングに移行することをオススメします。
それでもバグは残る
独自言語開発で実施しているテストについて紹介しましたが、 テストをしても残念ながらバグは残ります。
そのバグの原因を分類すると次の 2 つになります。
- 異常系が検出できないケース
- 本来正常に動作しなければならないのに動作しないケース
上記の 2 つの内、異常系が検出できないケースが圧倒的に多いです。
というのも、 正常系のパスは言語仕様通りのコードを書いて動くことを確認すれば良いのに対し、 異常系のパスは言語仕様から外れたコードを書いてエラーを検出する必要があります。
この「言語仕様から外れる」というのが結構難しく、穴が空いてしまうことが多いです。
最初から完璧なテストを求めるのではなく、 こういう「穴」を見つけ、 それを塞ぐテストケースを追加していき、 再度同じ「穴」が開いた時に検出できるように対応することが テストでは重要だと考えます。
最後に
独自言語の開発を続けてこられたのも、 次のテスト方針で進めて来たことが大きいと考えています。
-
早期にセルフホスティングに移行したこと
-
セルフホスティングに移行すると、否応なく一定以上の品質保証が必要になる
- 品質が悪ければセルフホスティングに支障が出るため、自ずと品質が保たれる
-
-
始めから 100% のテストを目指さないこと
- 目的は独自言語の開発であって、テストの開発ではない。
- 独自言語に集中できる。
-
言語の仕様拡充とテストコード拡充を同期して行なって来たこと
- テストの抜け漏れ、デグレードを防止できる
-
関数レベルのテストではなく、コンパイラ入出力レベルでのテストを行なったこと
- 関数レベルのテストだと、設計変更の度にテストケース変更が必要だが、 コンパイラ入出力レベルでのテストならば、 コンパイラの仕様変更がない限りはテストケースの変更が不要
独自言語の開発を行なう場合は、品質を確保するという意味でも、 まずはセルフホスティングを目指すのが効率的だと思います。
以上。