若くない何かの悩み

何かをdisっているときは、たいていツンデレですので大目に見てやってください。

テストマン、新築住宅の平面図からおこした3Dモデルを実写と比較する

こんにちは。この記事は下記記事の振り返りです。

blog.kuniwak.com

引き渡しの際に撮影した写真と、平面図から起こした3Dモデルをレンダリングして得たシミュレーション画像を比較することで3Dモデルの精度を確認しました。3Dモデルには当時私が平面図をどう認識していたかがよくあらわれていました。この認識と実際がどう異なっていたかを観察することで、平面図の誤解しやすい場所がわかりました。

なお良かったこと悪かったこと編は別に書く予定です。

TL;DR

図面から起こした 3D モデルは細かな差異はあるもののおおむね正確です。日照や照明のシミュレーションでは十分使える精度をもっていると確認できました。この細かな差異は次のとおりです:

  • 平面図には書かれていない部分(手すり・照明スイッチ・足元照明・コンセントの寸法と位置、床の高さなど)は推測でモデリングしているがその寸法・位置が実際とは異なっていた
  • 平面図・カタログの見落としがちらほらあった(火災報知器など)
  • シミュレーション画像では青空からくる環境光が再現されていなかった

前者2つを防ぐためには、床の高さのわかる図面、スイッチ・各種モニタ・足元照明の詳細な図面を要望するとより正確な 3D モデルを作れるでしょう。またカタログスペックとカタログの写真との比較も有効です。最後はどう修正すれば良いかわからなかったので、対応方法をご存知であればご教授願います。

実写と3Dモデルのシミュレーション画像を比較する

リビング入り口

リビングから廊下の眺め(前:実写、後:3Dモデルのシミュレーション画像)

おおむねよい精度でモデリングできていたようです。ただしよく見ると次の差異があります:

  1. シミュレーション画像では右奥の洗面台入り口がドアの上枠と揃っているが実際は揃っていない(原因:平面図では正確な指示がないため推測でモデリングしたところ実際とは異なる形状になった)
  2. シミュレーション画像に天井に階段下面の出っ張りはないが実際には出っ張りがある(原因:平面図では正確な指示がないため推測でモデリングしたところ実際とは異なる形状になった)
  3. ドアホンモニタの位置が異なる(原因:平面図では正確な位置の指示がないため推測でモデリングしたところ実際の位置からずれた)
  4. 実写にはある火災報知器が3Dモデルにはついていない(原因:図面には記載があったが見落とした)
  5. 奥の小窓の高さがずれている(原因:床の高さの推測値が実際の値よりも大きかった)
  6. 洗面台裏のハンガーポールの奥行きが違う(原因:引き渡し前に職人の方から図面よりもよい配置があると提案されこれを採用したため図面とは異なる施工になった。ありがたし)
  7. 実写は上の方が暗いが3Dモデルはそうなっていない(原因:床材のマテリアルの粗さが実際よりも高かった。言い換えると床からの光の反射が推測よりも指向性を持っていた)

平面図に記載がなく推測で補った部分に差異が出ていることがわかります。

キッチン

キッチン(前:実写、後:3Dモデルのシミュレーション画像)

背面のカウンターの高さを勘違いして5cmほど高くモデリングしていたようです。VR で試した時はキッチンの調理シミュレーションをしなかったため気づきませんでした。また引き出しの取手がつかないと誤解していため3Dモデルには取手がついていません。カタログに掲載されている写真では取手があったので私の見落としが原因です。

ダウンライトの位置が違う気がしますが、3Dモデルのレンダリングに使ったカメラの位置と向きが正確でないことが原因かもしれません。

換気扇は正確な図面をもらっていないので写真からの推測でモデリングしているため差異が出ています。

階段

階段(前:実写、後:3Dモデルのシミュレーション画像)

ここは前回の記事で3Dモデルでの照明シミュレーションにより曲がり角が暗い問題を発見した場所です。実写と見比べたシミュレーション画像の照らされている範囲は忠実に見えます。つまり修正前の図面のシミュレーションで曲がり角が暗かった問題も実際に暗かった可能性が高いです。照明のシミュレーションって大事ですね。

差異もあります。シミュレーション画像は実写よりも足元照明の色温度が高めになっています。照明の色温度は照明器具メーカーのカタログを見ればわかりますから、私の確認が十分ではありませんでした。

2F廊下

2F廊下(前:実写、後:3Dモデルのシミュレーション画像)

2F階段入り口はベビーゲートを設置する予定で図面では幅を確認していました。実際には手すりが干渉してつける予定だったベビーゲートをつけられませんでした。3Dモデルでも手すりの位置は実際より高くかつ奥についており、この事態を想定していなかったことがわかります。何度もベビーゲートつけたいという話はしていたのですが、手すりによりつけられなくなる事態は私、ハウスメーカーのいずれも気づいていませんでした。運よく取り付けられるベビーゲートを見つけられたのでことなきを得ましたが、取り付けられるベビーゲートは一種類しかなくオートクローズ機能のあるものを選べなくなったので事前に気づきたかったです。以下は取り付けられたベビーゲートですがオートクローズ機能はついていません。

item.rakuten.co.jp

実写では青空からの光で室内が青く照らされていますが、シミュレーション画像はほとんど青くありません。背景テクスチャは青空のものを使っていますがうまく再現できないようです。背景テクスチャからの放射のstrengthを調整しましたが、明るくなりはするものの青空の色はうまく出ませんでした。何かうまい方法をご存知の方がいたらぜひご教授願いたいです。

外構

外構は実写を載せたくないため画像は割愛します。

これまでの実写とシミュレーション画像との比較で最も差異が大きかったのが外構です。玄関ドアの材質の光の反射率やブロック塀の高さ、隣家との間のフェンスの高さなど図面には載っていないものが差異を生みました。いずれもハウスメーカーに要望しても返答の望みの薄い数値ですので、自分で測るほかありません。自分で測れない場合は差異が出るリスクを受け入れられるよう考慮しておくとよいでしょう。

脱線:シミュレーション画像と実写画像が重なるようにすると、実際のカメラとレンズの仕様と Blender のカメラ設定がなぜか一致しない

今回使ったカメラは SIGMA fp と 35mm F1.4 DG DN | Art です。

amzn.to amzn.to

Blender の設定

仕様によると SIGMA fp のセンサーの大きさは 36mmx24mm で 35mm F1.4 DG DN | Art の焦点距離は 35mm ですから、Blender のカメラもそのように設定すれば実写画像とシミュレーション画像が揃うはずです。実際にはセンサーサイズを揃えると焦点距離が 55mm ほどでないと画像が重なりませんでした。この差異の原因について心当たりがある方はご教授願います。

まとめ

図面から起こした 3D モデルはおおむね正確です。日照や照明のシミュレーションでは十分使える精度をもっていると確認できました。細かな差異を防ぐためには、床の高さのわかる図面、スイッチ・各種モニタ・足元照明の詳細な図面を要望するとよいでしょう。またカタログスペックとカタログの写真との比較も有効です。

ソフトウェアエンジニアでテストマンな私が家を買う際にやったこと

はじめに

ソフトウェアエンジニアでテストマンを生業とする Kuniwak です。今回は家を買うためにやったことを紹介します。 というのも、家を買うためにやったことを知人に話してみたら面白がられたため、誰かの役に立つかもしれないと思ったからです。

なおこの記事はソフトウェアに関する技術の記事ではありません(随所に検証の基本的な考え方などが散りばめられていますが…)。また、この記事で紹介する意見・手法は多分に cocopon 氏の影響を受けています。cocopon 氏の家購入エントリもこの記事と同時に公開されているはずです。

また、この記事はとても長いので先にポイントを説明しておきます。この記事ではライフプランシミュレーションに始まり次のような3Dモデルを作って日照や照明の検証をしていきます。また、3Dモデルを作るだけでは漏れが出るのでさまざまな検証を組み合わせています:

検証のために作った3Dモデルのレンダリング結果

目次

  • はじめに
  • 目次
  • 家を買うきっかけ(ライフプラン編)
    • ライフプランを考えはじめたきっかけ
    • ライフプランをシミュレーションするサイクルが長すぎる
    • ライフプランシミュレータを自作しよう
    • 自作ライフプランシミュレータのリスク
    • 自作ライフプランシミュレータから見えてきたこと
  • 家購入編
    • 中古・建売・注文どれにするか
      • 立地を比較するために評価方法を考える
        • 立地評価の項目を洗い出す
      • 間取りを比較するために評価方法を考える
        • 今後のシミュレーションのために間取り図を模写する
        • 家具の配置シミュレーションをする
        • 生活の変化のシミュレーションをする
        • 収納のシミュレーションをする
        • 第三者にレビューしてもらう
        • レビューの代わりに家事のシミュレーションをする
    • 物件の比較の結論
    • 注文住宅を購入する
      • 予算を確認する
      • 土地を検証する
      • 間取りと窓を検証する
        • 間取りを自分でも考えてみる
          • 外壁はプロに作ってもらう
          • 305mm グリッドで間取り図を作成する
          • 閑話:間取りの自動生成
        • 要求リストによる検証と収納シミュレーションによる検証をする
        • 壁厚を考慮したより現実の建物に近い図を作成する
        • 家具配置シミュレーションと生活の変化のシミュレーションをする
        • 3D に起こして間取りの見え方を検証する
          • 下準備
            • 高さの目安の準備
            • Blender に jlampel/extra_lights を入れておく
          • 間取りの見え方を確認する
        • 3Dモデルで窓を検証する
          • 隣接する建物を再現する
          • Sun Position プラグインで日照をシミュレーションする
      • 詳細図面を検証する
        • コンセントを検証する
        • 照明を検証する
          • スイッチ位置を評価する
        • 窓の 3D モデルで評価できてないない部分を検証する
        • VRゴーグルで中を歩く
      • 外構を検証する
      • 見つかった問題を振り返る
  • おわりに
  • 後日談
続きを読む

Isabelle ゆるリファレンス(apply-scriptスタイル)

最近は本業の自動テストマンより定理証明支援系に力を入れている Kuniwak です(経緯は過去記事参照)。

orgachem.hatenablog.com

定理証明支援系はいいぞ、ということで本記事では 私がよく使う Isabelle の便利機能の紹介とごく個人的に感じている使い所などを書いています

そのため全機能を網羅するものではなくかなり漏れがあります。もし「これめっちゃ便利だけど書いてない!」みたいな気づきがありましたらご指摘いただけると嬉しいです!

本記事の対象読者

対象読者は Isabelle の apply-script スタイルを学びたい方で、かつ「Tutorial on Isabelle/HOL 2020 の 1, 2, 3, 5 章を読んでなんとなくわかったけど…」な方です。もし定理証明支援系を使ったことがなく、Tutorial もまだ読んでいない方は 1, 2, 3, 5章を先に読むことをお勧めします(これ以外の章は重要度が低いかあるいはこの記事を読むのに必要なレベルを超えます):

  • 1章: 基礎(The Basics)
  • 2章: HOL の関数型プログラミング(Functional Programming in HOL)
  • 3章: さらなる関数型プログラミング(More Functional Programming)
  • 5章: このゲームのルール(The Rules of the Game)

またかなり前の Tutorial の日本語訳であれば caeruiro さんによる邦訳があります

ちなみに実のところ私は Tutorial で Isabelle を学んだわけではなく、PRINCIPIA が開催していた Isabelle チュートリアル全4回で上記1-5章+6-7章の内容を学びました(有料なので遠慮なく質問しまくった)。

Isabelle ゆるリファレンス

続きを読む

自動テストに限界を感じた私がなぜ形式手法に魅了されたのか

長らく自動テストとテスト容易設計を生業としてきましたが、最近は色々な限界を感じて形式手法に取り組んでいます。

この記事では、既存の自動テストのどこに限界を感じてなぜ形式手法が必要なのか私見を説明します。なお、私もまだ完全理解には程遠いため間違いがあるかもしれません。ご指摘やご意見はぜひ Kuniwak までいただけると嬉しいです。

著者について

プログラマです。開発プロセスをよくするための自発的な自動テストを支援する仕事をしています(経歴)。ここ一年は R&D 的な位置付けで形式手法もやっています。

自動テストの限界

自動テストとは

私がここ数年悩んでいたことは、iOS や Web アプリなどのモデル層のバグを従来の自動テストで見つけられないことでした。ただ、いきなりこの話で始めると理解しづらいと思うので簡単な例から出発します。

この記事でいう自動テストとは以下のようにテスト対象を実際に実行して結果を確認する方法を意図しています:

// 自分で実装した add で 1+1 が 2 になるかを検証する自動テスト。
describe('add', () => {
  context('when given 1 and 1', () => {
    it('should return 2', () => {
      const actual = add(ONE, ONE);

      const expected = TWO;
      assert.deepStrictEqual(actual, expected);
    });
  });
});

この例では自分で実装した add 関数の挙動を確認するために add(ONE, ONE) の結果が TWO と等しくなることを検証しています。 ただ、足し算の動作確認として 1 + 1 だけを確認しても安心はできないですから、これに加えて別のテストケースを足していくことになります。

なお、先に今回の話の前半のポイントを提示しておくと、このように「テストケースの追加」を続けていくとどのように考え方を変えなければならないかということです。

テストケース増加への対応の限界

さて、1 + 1 = 2 以外のテストケースとして 1 + 0 = 1 を検証しようとすると、次のように同じような単純なテストケース(Single Condition Test)を増やすことになるでしょう:

describe('add', () => {
  context('when given 1 and 1', () => {
    it('should return 2', () => {
      const actual = add(ONE, ONE);

      const expected = TWO;
      assert.deepStrictEqual(actual, expected);
    });
  });

  // 別の入力を試したいならテストケースを分ける。
  context('when given 1 and 0', () => {
    it('should return 1', () => {
      const actual = add(ONE, ZERO);

      const expected = ONE;
      assert.deepStrictEqual(actual, expected);
    });
  });
});
ここまでの流れ
単条件のテストケース追加

これも悪くないですが、1つのテストケースを足すたびに8行ほどの記述が必要なのは少し辛いですね1。そこで、この例のように複数のテストケースが入力値だけ異なるとき次のような共通化を図ります(Parameterized TestやTable Driven Testと呼ばれる):

describe('add', () => {
  [
    {lhs: ONE, rhs: ONE, expected: TWO },
    {lhs: ONE, rhs: ZERO, expected: ONE },
  ]
  .forEach(({lhs, rhs, expected}) => {
    context(`(${lhs}, ${rhs})`, () => {
      it(`should return ${expected}`, () => {
        const actual = add(lhs, rhs);

        assert.deepStrictEqual(actual, expected);
      });
    });
  });
});

こうすると、新たなテストケースを足すのに必要な行数はたったの1行となり保守が楽になります。ここまでは自動テストでも余裕で対応できます。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test

ところで、今回のテスト対象である足し算をきちんと実装できたと安心できるテストケースの数はどれぐらいでしょうか?少なくともいま試した2つだけでは安心できないはずです。このようなときにテストマンがよく使う手法は「同値分割」と「境界値分析」です。これらはいずれも試さないといけない入力が大量にある状況を現実的な時間で対処するために入力を間引くための方法です。同値分割は出力が同じになる入力から代表値を選ぶという方法で入力を間引きますし、境界値分析では内部の条件分岐のちょうど境目付近を通過する入力はなるべく間引かない方法とも言い換えられるでしょう。

今回は厳密な同値分割はせず、ざっくり小さい値と大きな値から代表値をテストケースに加えるとしましょう。ついでに境界値になりそうな 0 も入れておきます:

describe('add', () => {
  [
    {lhs: ONE, rhs: ONE, expected: TWO }, // 小さい値と小さい値の組み合わせ
    {lhs: ONE, rhs: ZERO, expected: ONE },
    {lhs: ONE_MILLION, rhs: ONE, expected: ONE_MILLION_ONE }, // 大きい値と小さい値の組み合わせ
    {lhs: ONE, rhs: ONE_MILLION, expected: ONE_MILLION_ONE }, // 小さい値と大きい値の組み合わせ
    {lhs: ZERO, rhs: ZERO, expected: ZERO }, // 境界値になりそうな値
  ]
  .forEach(({lhs, rhs, expected}) => {
    context(`(${lhs}, ${rhs})`, () => {
      it(`should return ${expected}`, () => {
        const actual = add(lhs, rhs);

        assert.deepStrictEqual(actual, expected);
      });
    });
  });
});

このように足せました!さらに慎重な方ならば、自然数型の最大値も境界値になりそうなのでこれも加えるでしょう(この例は簡単にするために最大値を設けていない)。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析

さて、ここまで検査対象の入力を広げてきましたが、これなら安心できるでしょうか?以前の私であればこのあたりを現実的な安心のラインとして手を止めていました。実はこの判断は「大抵のバグは入力空間の小さい部分を探すだけで見つかる」という小スコープ仮説2を暗黙に仮定しています。この例ではよっぽど妙な実装をしない限りは成り立つと感じますが、検査する対象によっては成り立たないこともあります(特に後述する並列システムなど)。ここでは説明を簡単に保つために、この仮説が足し算でも成り立たないとして話を進めます。

ということで、安心できるようになるまでテストケース数を増やしていきましょう。ただ、そろそろ手で追加してくるのが厳しくなってきたはずです。このような時にテストマンが使う方法は QuickCheck に代表される Property-based Testing です。Property-based Testing ではこれまでの手法とは異なり入力をプログラマが指示しません。代わりに型や値の生成器が入力を自動的に大量に生成してくれます。

なんて便利なんだ!と思うかもしれませんが、実はここで考え方の転換が必要になります。今までのコードを思い出して欲しいのですが、自動テストでは期待する出力と実際の出力の比較という形で記述してきました:

// これまでのコード
describe('add', () => {
  context('when given 1 and 1', () => {
    it('should return 2', () => {
      const actual = add(ONE, ONE); // プログラマが入力として 1, 1 を選び、

      const expected = TWO; // 期待する出力として 2 を指定する。
      assert.deepStrictEqual(actual, expected);
    });
  });
});

しかし、入力が自動生成されるのであれば期待する出力をどのように指定すればいいのでしょうか?

// Property-based Testing のコード
describe('add', () => {
  context('when given X and Y', () => {
    it('should return a result same as result of Y and X', () => {
      const [x, y] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(x, y);

      const expected = '???'; // 期待する出力として ??? を指定する。
      assert.deepStrictEqual(actual, expected);
    });
  });
});

何となく考え方を転換しないといけない部分がわかってきたでしょうか。Property-based Testing では入力に対する具体的な期待出力を指定できないのです。代わりに 入力と出力の間で成り立つ関係を利用します。例えば足し算は順序を変えても結果が同じはず(x + y = y + x)ですから、次のように引数の順序をひっくり返しても結果が同じになるといった指定をします:

describe('add', () => {
  context('when given X and Y', () => {
    it('should return a result same as result of Y and X', () => {
      const [x, y] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(x, y);

      const expected = add(y, x); // 順序を反対にしても計算結果が変わらないことを確かめる。
      assert.deepStrictEqual(actual, expected);
    });
    // 実際には valuesGen の値を変えて大量に繰り返す。
  });
});

これだけだと全く不十分ですから、次のように足し算の持つ様々な入出力の間の関係の検証も追加していきます:

describe('add', () => {
  context('when given X and Y', () => {
    it('should return a result same as result of Y and X', () => {
      const [x, y] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(x, y);

      const expected = add(y, x); // 引数の順序を反対にしても計算結果が変わらないこと(交換法則)を確かめる。
      assert.deepStrictEqual(actual, expected);
    });
  });

  context('when given (X and Y) and Z', () => {
    it('should return a result same as result of X and (Y and Z)', () => {
      const [x, y, z] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(add(x, y), z);

      const expected = add(x, add(y, z)); // 計算する順序を変えても結果が変わらないこと(結合法則)を確かめる。
      assert.deepStrictEqual(actual, expected);
    });
  });

  context('when given X and 0', () => {
    it('should return X', () => {
      const [x] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(x, ZERO);

      const expected = x; // 0 と足しても元の数字と同じになること(単位元の存在)を確かめる。
      assert.deepStrictEqual(actual, expected);
    });
  });

  context('when given X', () => {
    it('should return something not equal X itself', () => {
      const [x] = valuesGen(/* 値の生成器が自動的に何らかの自然数を選ぶので、*/);
      const actual = add(x, ONE);

      const unexpected = x; // 1を足せば結果はXとは異なることを確かめる。
      assert.deepStrictEqual(actual, expected);
    });
  });

  // 実際には valuesGen の値を変えて大量に繰り返す。

  // 前の Parameterized Test も残しておくとより安心。
});

このようにして入力を自動生成すれば手で書いた時よりもずっと多くできるわけですから、より強い安心を得られるわけです。

ただし代わりに失われたものもあります。Parameterized Testまでは具体的な期待を書けたため深く考えなくてもよかったのですが、Property-based Testing では具体的な期待を書けなくなったためにより抽象度の高い「入力と出力の間の関係」を見抜かねばなりません。これには一定の訓練が必要です。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析 → (入出力の具体性の壁) → Property-based Testing

さて、実はこれでもまだテストケースは完全ではありません。自動生成された値は手で作られたよりもずっと多いですが、それでも入力可能な値(自然数型の値すべて)よりはずっと少ないのです。ただ、自然数の例では前述の小スコープ仮説(大抵のバグは入力空間の小さい部分を探すだけで見つかる)が成り立つでしょうから、きっと入力すべての動作確認は不必要に思えるでしょう。しかし後述する並行システムでは小スコープ仮説が成り立たないことが多く、したがって網羅性は依然として重要なのです。そのためもう少し網羅性の話を続けます。

なお、これまでの手法の延長線上で入力を網羅的に扱う方法として、網羅的な入力が可能なレベルまで入力可能な値の数を減らす方法があります。自然数の例でいうと利用したい最大の自然数がそこまで大きくないのであれば、最大値を設けてそこまでの入力すべてを網羅的に検証する方法をとれます(この考え方は後述する並行システムでも重要)。ここまできて、ようやく従来の方法の延長線上でも網羅的な動作確認が可能になりました。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析 → (入出力の具体性の壁) → Property-based Testing → (網羅性の壁) → 可能なら入力空間の制限

ただしこの方法は入力を絞れるときのみ使える方法だったことを思い出してください。仕様によってはこれが適用できないことも多く、特に並行システムにおいてはこの方法をとってもなお入力空間が広すぎる場合が頻繁におきえます。

そして、ここから先こそが形式手法の力を借りないといけない世界なのです。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析 → (入出力の具体性の壁) → Property-based Testing → (網羅性の壁) → 可能なら入力空間の制限 → (扱える入力空間の大きさの壁) → 形式手法(???)

網羅性への挑戦

ここから先は、形式手法の1つである定理証明支援系を使って広い入力空間を検証してみましょう。今回は Isabelle という定理証明支援系のシステムを駆使して検査したい対象の入力すべてに対して性質を確認します。ただ、このように説明されてもなかなか飲み込めないと思いますので、もう少し自然数の例を続けていきます。

さて、今回の add の実装が次のようだったとします:

// add 関数の入力値は Zero または Succ のいずれか。Zero は 0 を意味し、Succ はパラメータの自然数を +1 した自然数を表現する。
// つまり、new Succ(new Zero()) は 0 + 1 なので 1 を意味し、new Succ(new Succ(new Zero())) は (0 + 1) + 1 なので 2 を意味する。
class Zero {}
class Succ {
    constructor(nat) { this.v = nat }
}


function add(a, b) {
  // もし b が 0 なら a が結果(a + 0 = a ということ)。
  if (b instanceof Zero) {
    return a;
  }

  // もし b が 0 でなければ b の Succ 前の値(b - 1)と a に対して再帰的に add を呼び出して、
  // 最後に全体として +1 する(つまり (a + (b - 1)) + 1 = a + b ということ)。
  // こうすると、最終的に b が 0 になるまで再帰的に add が繰り返され、b が 0 になった時点の
  // 結果で再帰が停止して b の自然数の回数分 +1 して値が戻ってくる。
  return new Succ(add(a, b.v));
}

そして上の JavaScript のコードは次の Isabelle での表現と対応するとしましょう(=JavaScript のコードと Isabelle のコードがまったく同じ振る舞いをするとしています3):

(* JavaScript の Zero と Succ のクラスに対応する型を nat として定義する。 *)
datatype nat = Zero | Succ nat

(* add は JavaScript の add と同じ再帰的な定義とする。 *)
primrec add :: "nat =>> nat => nat" where
  "add a Zero = a"
| "add a (Succ b) = Succ (add a b)"

Isabelle ではこのように記述された定義に対して「証明」が可能です。Isabelle で記述されたコードは、数学の証明のように論理的な推論によって結果がどうなるかを推測できるためです。なお、この方法でも Property-based Testing と同様に入出力の間で成り立つ関係を使います。

例えば、前の Property-based Testing で検証した x + 0 = x は次のように Isabelle で記述でき、しかも今回は全自動で証明できます(Isabelle は自動的な証明に優れている):

(* すべての自然数 x について x + 0 = x が成り立つことを証明する。 *)
theorem "∀x. add x Zero = x"
  by auto (* 自動での証明を指示(自動で解ける)。 *)

この証明が成功するということは、どんな自然数 x であっても x + 0 = x を満たす と確認できたことを意味します。ここで重要なのは「どんな自然数でも」という部分で、今回は自然数の定義の上限を与えていませんからとてつもなく大きな自然数であってもこれが成り立つのです4

しかしおそらくこれでは実感が湧かないと思うので、試しに add の定義をわざと間違えてどうなるかを見てみましょう:

primrec add :: "nat ⇒ nat ⇒ nat" where
  "add a Zero = Zero" (* わざと間違えてみる *)
| "add a (Succ b) = Succ (add a b)"

この状態で、先ほどの x + 0 = x を証明しようとすると証明は失敗します:

(* すべての自然数 x について x + 0 = x が成り立つことを証明する。 *)
theorem "∀x. add x Zero = x"
  by auto (* 自動での証明を指示(失敗する)。 *)

(* Isabelle の出力:
  theorem add_iden: ?x = add ?x Zero
  Failed to finish proof⌂:
  goal (1 subgoal):
   1. x = Zero
*)

よくみると subgoal: x = 0 という出力になっています。これはどんな自然数 x に対しても x = 0 が成立するなら上記の間違った定義でも証明できると Isabelle が教えてくれています。要するに x が 0, 1, 2, 3, ... のいずれでも x = 0 が成立すれば証明できるということですが、x が 0 以外については x = 0 が成立するとまずいわけですから、どこかで間違えたとわかるのです。このように Isabelle は証明したいことがらを論理的な推論によって証明できる極めて強力な手段となります。

また、次のように x + 0 = x の例以外の性質もほぼ自動で証明できます(一部は人間の手で補題を切り出してヒントを与えている):

(* 自動推論のための手がかりを与えるための補題。 *)
lemma add_iden [simp]: "x = add x Zero"
  by auto


lemma add_assoc: "add (add x y) z = add x (add y z)"
  apply(induct_tac z)
  by auto


lemma [simp]: "Succ (add x y) = add (Succ x) y"
  apply(induct_tac y)
  by auto


lemma add_comm: "add x y = add y x"
  apply(induct_tac y)
  apply(auto)
  apply(induct_tac x)
  by auto


(* 引数の順序を反対にしても計算結果が変わらないこと(交換法則)を確かめる。*)
theorem "∀ x y. add x y = add y x"
  apply(intro allI)
  by (rule add_comm)


(* 計算する順序を変えても結果が変わらないこと(結合法則)を確かめる。*)
theorem "∀ x y z. add (add x y) z = add x (add y z)"
  apply(intro allI)
  by (rule add_assoc)

まとめると、ここでは定理証明支援系を使えば入力空間を制限できない状況であっても網羅的な検査ができるとわかりました。なお、これ以外の例でよりパターン数が多い状況でも Isabelle は高い威力を発揮できると知られています(ケプラー予想の形式証明に Isabelle が使われた例が参考になる)。

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析 → (入出力の具体性の壁) → Property-based Testing → (網羅性の壁) → 可能なら入力空間の制限 → (扱える入力空間の大きさの壁) → 定理証明支援系による形式検証

現実的な例

ここまでの例では、説明を簡単にするため極めて単純化された非現実的な例を扱ってきました。そこでここからは現実的に形式手法が必要になる状況を説明していきます。

私がこれまで悩んできたのは、iOS アプリや Web アプリにおいて多用する協調して動作する状態機械のバグを従来の自動テストで見つけられないことでした。iOS アプリに限らず、何らか状態をもつオブジェクトは状態機械として設計しておくと状態の大局観の把握が楽になります。そのため、状態機械は頻出の設計パターンとなります(具体的にどのようなコードになるかは Kuniwak/reversi-ios を参照)。なお、これらの状態機械には状態が変わったことを監視できるイベントストリームを用意しておいて、View はここから変更を検知して見た目を変更するように構成します。

さて、これが単純な状態機械ただ1つなら話は簡単でした。問題は現実的なアプリは小さな状態機械1つでは表現できないため、小さな状態機械を複数組み合わせる必要があるという点です(この方法は一般的に Hierarchal Finite State Machine として知られている)。

一般に、このような 並列動作する状態機械の組み合わせの検証に必要なテストケース数は人間の想像を超えてきます 。例えば、次の図はとあるイベントで実装したアプリの状態機械全体の状態遷移図です(全部載り切らないのでごく一部):

f:id:devorgachem:20200524132128p:plain全体

この図を一見しただけで、Property-based Testing を含む従来のいずれの方法もほぼ無力だとわかると思います。なお、この中のごくわずかな部分にバグが潜んでいて、実際に今回のリバーシアプリでもアニメーションに一貫性のないバグが後から見つかりました。この原因はイベントストリームの購読者が2ついることに気づいておらずしかも購読者の呼び出し順序が期待と違っていたことが原因でした(この図の中のいずれかの遷移に想定外の遷移がたった1つあったのが原因)。

この話を一般化すると、よくマルチスレッドプログラミングの開発が難しいと言われるのは、この膨大な数の組み合わせのなかのごくわずかな部分にデッドロックや無限ループなどの欠陥が潜んでいることに人間が気づけないからです。そして、これまで見てきたように、膨大の入力の組み合わせ数があるとき自動テストを含む動的検査は無力です。だから形式手法が重要になるのです。

なお上記のアプリについては、今回紹介した定理証明支援系とは別の形式手法である「モデル検査5」を試みました(詳細)。モデル検査は並列システムの網羅的な検証を得意としていて、私の悩みを解決できる有力な手段の1つです。このモデル検査も巨大な状態遷移グラフを扱う際には具体的な入出力を指示できないため、これまで同様に抽象的にものごとを捉えなければならない点が共通しています。ちなみにモデル検査は次ぐらいの位置付けです:

ここまでの流れ
単条件のテストケース追加 → Parameterized Test → 同値分割・境界値分析 → (入出力の具体性の壁) → Property-based Testing → (網羅性の壁) → 可能なら入力空間の制限 → モデル検査 → (扱える入力空間の大きさの壁) → 定理証明支援系による形式検証

テストケースの増加につれ起こったこと

これまでの流れを少し振り返りましょう。自然数の足し算を例にテストケースの数が増えるにつれ次の箇条書きリストの下の方へ手法が移り変わってたことを見てきました:

  1. 単条件のテストケース追加
  2. Parameterized Test
  3. 同値分割・境界値分析
  4. ──(入出力の具体性の壁)──
  5. Property-based Testing
  6. ──(網羅性の壁)──
  7. 可能なら入力空間の制限
  8. モデル検査
  9. ──(扱える入力空間の大きさの壁)──
  10. 定理証明支援系による形式検証

要するに、下の方の手段である形式手法(8と10)は膨大な量のテストケースを現実的な記述量で表現するための方法です。そのため抽象度が上がってしまい捉えづらくなってしまうということなのでしょう。

このような事情も相まって、形式手法を上手に扱うのにはかなりの知識が必要です。実際に取り組んでみるとわかるのですが、まず人間は意図通りの論理式やモデルを書けません(必要な条件を忘れていたりそもそも意図が間違ってたりする)。当たり前の話ですが、従来のコードでさえ正しく書けないのに、より抽象度の高い論理式やモデルを最初から正しく書けるはずがないのです

したがって形式手法においても自分の記述したモデルや論理式が自分の意図通りになっているか確かめる作業は避けて通れません。だからこそ、Isabelle や他の形式手法系のツールが何を言っているのかを理解するための数理論理学の学習や基礎理論への理解がとても重要になります。さらに、このような知識とは別に TDD のように論理式やモデルをこまめに確認できるよう準備しておくといった進め方のノウハウも重要になります(ノウハウの例)。

私もまだまだ知識は足らないですが、以前とは異なり今は次のステップが見えるようになりました。あとは一歩ずつ進んでくだけだと思っていて、今も形式手法を学びつつ実践に取り組んでいます。

まとめ

この記事では、以下の3点を説明しました:

  1. テストケースの増加につれ従来の自動テストに限界が訪れる
  2. 現実の設計で頻出する状態機械の合成はこの限界を超えてくる
  3. これに対応できる手段が形式手法である

この記事をきっかけに、私のように形式手法に興味をもつテストマンが増えるといいなあぁと思っています。


  1. 実際は1つのテストケースで複数の入力を連続して試すコードも多く見受けられますが、これは Eager Testと呼ばれるアンチパターンです。これには2つの問題があります。1つめの問題はテストケースの最初の方で assertion が失敗すると後続の検証が実行されず、テスト失敗の情報量が少なくなるというものです。2つめは、複数の入力を1つのテストケースでまとめてしまうとテストケースの名前が曖昧になります(テストの意図を推測する上で重要な情報が失われる)。これらの問題を避けるため、この記事のように1つの試したい入力に対して1つのテストーケースが対応するようにしましょう。

  2. Andoni, Alexandr; Daniliuc, Dumitru; Khurshid, Sarfraz; Marinov, Darko (2002). “Evaluating the small scope hypothesis”

  3. ここで厳密な対応への言及を避けたのは JavaScript の形式化された意味論が必要だからです。意味論が形式的に定められた処理系(例えば KJS)以外では、Isabelle での表現と本当に対応しているのかどうかを検証(正当性証明)するのはとても大変です。

  4. 元の JavaScript のコードはほとんどの処理系で 1000 程度の自然数で stack overflow でエラーになります(本当は末尾再帰最適化可能な形にしたかったんですが証明で手間取ったので諦めた…)。重要なのは JavaScript のコードと Isabelle のコードの対応関係で、このように再帰が深くなる場合に対応関係が崩れるため証明の結果と実際の JavaScript での実行結果が一致しません。もしこれを含めた検証をしたければ、このような JavaScript の振る舞いを形式的に定義した意味論に踏み込む必要がありますが、今回は説明を簡単にするためにこれを避けています。

  5. モデル検査の定義を説明するのはとても大変なので、定義を知りたい方は「モデル検査入門」などを読むといいかもしれません。

go の OSS でテストを書きまくったので、テストしやすくするために役立った工夫を紹介します

社で必要になったので、Go 言語の OSS を書きました。

github.com

この OSS がなんなのかは スライド を見ていただくとして、テストをめっちゃ書いたのでだんだんどうテストを書いたらやりやすいのかがわかってきました。この成果自体はいつか vanilla-manifesto へまとめると思いますが、簡単に説明しようと思います。

書いてて辛くないテストをするために、もっとも重要なのは テスト対象の設計 です。設計といえば、よく SOLID がよいオブジェクト指向設計の指針とされますが、テストの文脈でも同じことがあてはまります。例えば、テスト対象が SOLID をよく守っていると次のように嬉しいことがあります:

単一責任原則 (S)
これが守られると、どんな振る舞いを検証したいのかが明確になるので読みやすいテストになる
開放閉鎖原則 (O)
これが守られると、機能の追加/変更でテストの書き換えが少なくなって保守しやすくなる
リスコフの置換原則 (L)
これが守られると、同じインターフェースに準拠するコンポーネントのテストの一部で、同じヘルパ関数(xUTP で言う所の Custom Assertion)を流用できるようになる
インターフェース分離の原則 (I)
これが守られると、テストの準備部分で余計なコードを書かなくて済むようになる
依存性逆転の原則
これが守られると、間接的な入出力をテストから制御できるようになってテストできる範囲が広がる

ということで「テストしやすい工夫」の実態とは「テストしやすいテスト対象の設計の工夫」と同じ意味なので、この記事で紹介するのは後者がメインになります。

さて、今回紹介するプラクティスを大雑把にいうと「だいたいの処理を関数を受け取る関数でさばく」という過激なものです。具体的手法はあとで説明しますが、これをすると、単一責任原則・開放閉鎖原則・インターフェース分離の原則・依存性逆転の原則がかなり高まり、テストの書きやすさがとてもあがります。

簡単に例を見てみましょう。Hoge という Web API があったとして、以下の 2 つを提供しているとします:

  1. GET すると Hoge 文字列を手に入れられる
  2. Hoge 文字列を POST できる

これを過激派のアプローチで実装すると、次のようになります:

type HttpDoer func(*http.Request) (*http.Response, error)
 
type Hoge string

// Hoge を Get する関数のインターフェースのようなもの。
type HogeGetter func() (*Hoge, error)

// Hoge を Get する具体的な関数のコンストラクタのようなもの。
func CreateHogeGetter(httpDo HttpDoer) HogeGetter {
    return func() (*Hoge, error) {
        request, err := http.NewRequest("GET", "http://example.com/hoge", nil)
        if err != nil {
            return nil, err
        }

        response, err := httpDo(request)
        if err != nil {
            return nil, err
        }

        if response.StatusCode < 200 || 300 <= response.StatusCode {
            return nil, fmt.Errorf("unsuccessful http response: %s", response.Status)
        }

        bs, err := ioutil.ReadAll(response.Body)
        if err != nil {
            return nil, err
        }

        hoge := Hoge(bs)
        return &hoge, nil
    }
}

あっ、待って!ちょっと待って!まだ読むのをやめないで!

ここで、なぜ http.Client を直接使わずに HttpDoer という関数を作っているかというと、 次のようにテストがめちゃ簡単になるからです

func TestHogeGetter(t *testing.T) {
    response := AnyHttpResponse()
    response.StatusCode = 200
    response.Body = ioutil.NopCloser(strings.NewReader("HOGE"))
    httpDo := ConstHttpDoerStub(response, nil)

    getHoge := CreateHogeGetter(httpDo)

    actual, err := getHoge()

    if err != nil {
        t.Errorf("want (_, nil), got (_, %v)", err)
        return
    }

    expected := Hoge("HOGE")
    if *actual != expected {
        t.Errorf("want (%v, nil), got (%v, nil)", expected, *actual)
        return
    }
}

ここでは、AnyHttpResponseHttpDoerStub という 2 つのテストダブルファクトリが登場します。それぞれの定義は次のようになっています:

// 指定したレスポンスとエラーを常に返す偽物。
func ConstHttpDoerStub(response *http.Response, err error) HttpDoer {
    return func(*http.Request) (*http.Response, error) {
        return response, err
    }
}


// よく使うテストダブルは名前をつけて定義しておくと便利。
// これは内容はともかく成功したっぽいレスポンスになるので、通信の成功だけをみているテスト対象が多ければよく使うことになる。
func AnySuccessfulHttpDoerStub() HttpDoer {
    response := AnyHttpResponse()
    response.StatusCode = 200
    return ConstHttpDoerStub(response, nil)
}


// 適当なレスポンスを作成する。
func AnyHttpResponse() *http.Response {
    // テスト側で必要な値だけを指せるようにすると、テストがどこに着目しているのかがわかりやすくなる。
    // ありえない値を指定しているのは、テスト対象に影響を与えているのにテスト側で指定し忘れるミスに気付きやすくするため。
    return &http.Response{
        Status:           "000 Dummy Response",
        StatusCode:       0,
        Proto:            "HTTP/0.0",
        ProtoMajor:       0,
        ProtoMinor:       0,
        Header:           nil,
        Body:             nil,
        ContentLength:    0,
        TransferEncoding: nil,
        Close:            false,
        Uncompressed:     false,
        Trailer:          nil,
        Request:          nil,
        TLS:              nil,
    }
}

生産性の高いテストを書く上では、(1) 間違えにくく、(2) 再利用しやすい、の 2 条件を満たすテストダブルがとても重要です。この点、上のテストダブルは極めて単純なため間違った使い方をしづらく、さらに HTTP 通信を必要とするどのテストでも再利用できます。例えば、Hoge API の POST 側のテストを次のようにできます:

func TestPost(t *testing.T) {
    response := AnyHttpResponse() // 再利用
    response.StatusCode = 201
    response.Body = ioutil.NopCloser(strings.NewReader("OK"))
    httpDo := ConstHttpDoerStub(response, nil) // 再利用

    postHoge := CreateHogePoster(httpDo)

    hoge := Hoge("")
    err := postHoge(&hoge)

    if err != nil {
        t.Errorf("want (_, nil), got (_, %v)", err)
        return
    }
}

うまく再利用できている様子がわかります。

また、このようなテストダブルファクトリを用意することで、テスト側でどのような HTTP レスポンスが帰ってくることを期待しているのかが極めて明確になっています:

func TestPost(t *testing.T) {
    response := AnyHttpResponse()
    response.StatusCode = 201
    response.Body = ioutil.NopCloser(strings.NewReader("OK"))
    httpDo := ConstHttpDoerStub(response, nil)

    // ステータスコード 201 で内容が OK なレスポンスを期待していることがすぐ読み取れる

さらに、テストダブルが関数なため柔軟性も高く、例えばリクエストの内容に応じてレスポンスを変えたくなったら次のように書けます:

func TestHogeGetter(t *testing.T) {
    httpDo := func(request *http.Request) (*http.Response, error) {
        response := AnyHttpResponse()
        if strings.HasSuffix(request.URL.Path, "hoge") {
            response.StatusCode = 200
            response.Body = ioutil.NopCloser(strings.NewReader(request.URL.Path))
        } else {
            response.StatusCode = 400
            response.Body = ioutil.NopCloser(strings.NewReader(""))
        }
        return response, nil
    }

    getHoge := CreateHogeGetter(httpDo)

どのように動くのかが一目瞭然です!(モックライブラリだとこうはいかない)

また、これにはもう一つ利点があります。上のテストダブルはそこそこ複雑なので「書きたくないなぁ」という嫌な印象をうけないでしょうか? この「書きたくないなぁ」という感覚はとても重要で、このとき偽装対象に期待する振る舞いが多い(=密結合)ことを示唆しています。つまり、偽装対象との間に抽象層を挟むなり、偽装対象を分割するなりのきっかけとできるわけです。

なお、これまではテスト面だけのメリットを強調してきましたが、やってきたことは SOLID を意識するということだったのでテスト対象側にもうれしさがあります。例えば、HTTP 通信の結果をキャッシュしたいと思ったとすると、既存の定義に影響を与えずに(=既存テストを書き換えることなく)キャッシュするバージョンをすぐに作れます:

func CreateCachedHttpDoer(httpDo HttpDoer) HttpDoer {
    cache := make(map[string]*ClonableResponse)
    return func(request *http.Request) (*http.Response, error) {
        if clonable, ok := cache[request.URL.String()]; ok {
            return clonable.Clone(), nil
        }
        response, err := httpDo(request)
        if err != nil {
            return nil, err
        }
        clonable := ClonableResponse(response)
        cache[request.URL.String()] = clonable
        return clonable.Clone(), nil
    }
}

さて、もちろん良さの裏側には悪さもあります。欠点も紹介しておきます。

欠点

この種のパターンをかなり厳密に適用すると、依存を解決する部分がモリモリになります。慣れれば型を見れば何を要求されているのかはすぐにわかって苦にならなくなるのですが、コードの見た目はかなり悪いです。

あと、関数の型定義がめちゃ増えるのですが、命名に困るようになります。上の OSS でも結構苦しかった記憶が…

まとめ

Go でテストしやすい環境を作るには、とにかく関数を使いまくってテストダブルを再利用するとよかったという話でした。

これまで説明してきたことは、DeNA/devfarm の executor まわり で実用しているので、もし気なる場合は参考にしてみてもいいかもしれません。テストダブルは *_stub.go という命名規則で分離しているので、すぐに見つけられると思います。