スキーマ定義に対するテストとそのメトリクス

  • 9
    いいね
  • 0
    コメント

近年、swagger(Open API)やswaggerの参考になったJSON Schemaにより、Webサービスに対してmachine readableなスキーマを定義し、そこからのツールチェインでAPIドキュメンテーション・クライアント・APIスタブ・リクエストバリデータ等を自動生成可能になり、これにより、品質が高く効率的な開発ができるようになりました。

しかし、実際のところJSON Schemaやswaggerで書かれたスキーマをきちんと書くことは非常に難しく、意図したスキーマになってないことが多くあります。一般に欠陥の発見が後になればなるほどその修正コストは非常に高くなります。特に、スキーマからツールチェインを用いていろいろな箇所で利用する場合にはより大変になります。

そこで、スキーマが意図通りに定義できているかということを確認するために、スキーマに対して実データを当てはめてみるようなテストを書くということと、そのテストに対してメトリクスを取得することについて今回は紹介します。

なお、今回はJSON Schemaを例にあげて説明します。

JSON Schemaに対するテストを書く

スキーマに対するテストの書き方ですが、JSON Schema Test Suite を参考にします。
ちなみに、JSON Schemaは最新のdraftでも、その仕様を読んでも具体的にこのバリデーションはどういう挙動になるかがわからない(仕様の記述が不十分)ところがあるのですが、このTest Suiteを読むとどうなるべきかがわかるので、仕様の理解には非常に重宝します :cry:

JSON Schema Test Suiteの構造

以下がJSON Schema Test Suiteで利用されているテストの構造です。
1ファイルの中に、配列で複数のschemaを列挙し、testsにそのschemaに対してデータを列挙することができます。

[
    {
        "description": "maximum validation",
        "schema": {"maximum": 3.0},
        "tests": [
            {
                "description": "below the maximum is valid",
                "data": 2.6,
                "valid": true
            },
            {
                "description": "above the maximum is invalid",
                "data": 3.5,
                "valid": false
            },
            {
                "description": "ignores non-numbers",
                "data": "x",
                "valid": true
            }
        ]
    },

スキーマに対してテストを書いてみる

例えば、以下のようなJSON Schemaによるリソースとエンドポイントの定義があるとします。(JSON Schemaをあまりよく知らない人はテストもあわせてみてなんとなく悟ってください)

{
    "id": "http://example.com/resources.json",
    "$schema": "http://json-schema.org/draft-04/hyper-schema#",
    "definitions": {
        "postCreate": {
            "definitions": {
                "requestBody": {
                    "allOf": [
                        { "$ref": "#" },
                        { 
                            "not": {
                                "required": [ "id" ]
                            },
                            "required": [ "name" ],
                            "additionalProperties": false
                        }
                    ]
                }
            }
        }
    },
    "properties": {
        "id":   { "type": "integer", "minimum": 1 },
        "name": { "type": "string" }
    },
    "patternProperties": {
        "extra[0-9]": { "type": [ "string", "null" ] }
    },
    "links": [
        {
            "method": "POST",
            "rel": "create",
            "href": "/resources",
            "targetSchema": { "$ref": "#/definitions/postCreate/definitions/requestBody" },
            "schema": { "$ref": "#" }
        }   
    ]
}

これに対して例えば以下のようにテストを書きます。

[
    {
        "description": "resource",
        "schema": { "$ref": "http://example.com/resources.json#" },
         "tests": [
            {
                "description": "least resource",
                "data": {
                    "id": 1,
                    "name": "a resource"
                },
                "valid": true
            },
            {
                "description": "resource with extra properties",
                "data": {
                    "id": 1,
                    "name": "a resource",
                    "extra0": "hoge"
                },
                "valid": true
            },
            {
                "description": "invalid extra type",
                "data": {
                    "id": 1,
                    "name": "a resource",
                    "extra0": 0
                },
                "valid": false
            }
         ]
    },
    {
        "description": "post request body",
        "schema": { "$ref": "http://example.com/resources.json#/links/0/targetSchema" },
        "tests": [
            {
                "description": "least request body",
                "data": {
                    "name": "a resource"
                },
                "valid": true
            },
            {
                "description": "request body with extra",
                "data": {
                    "name": "a resource",
                    "extra0": "hoge"
                },
                "valid": true
            },
            {
                "description": "request body with id",
                "data": {
                    "id": 1,
                    "name": "a resource"
                },
                "valid": false
            }
        ]
    }
]

1つ目のオブジェクトは、schemaそのものに対するテスト、2つ目のオブジェクトは、リクエストボディに対するテストを書いています(swaggerの場合swagger specを書くことでテストにもなりつつ、ドキュメントになる感じでしょうか。でもそれでは細かいテストまではかけないですね。。)
今回の例は、単純なスキーマなのでこのような程度で問題ないのですが、複雑な要素がある場合はもっと細かい粒度で書くことが多いです。

ちなみに、上記の例でさえ、このpatternPropertiesの書き方ではhoge_extra0_fugaみたいな要素が入力としてOKになってしまうという欠陥が混入されているのですが、気づいたでしょうか?

スキーマ自体の欠陥を単純なレビューで指摘することは難しいですが、テストを作り、テストを作る過程でスキーマの記述が十分化をしっかり考え、またレビューにおいてもテストの不十分な点はないかということも含めて行っていくことで、こういった欠陥の指摘が可能になっていきます。

JSON Schemaのテストのメトリクスを測定する

このように、スキーマに対してテストを書くことで、よく練られた設計になりまたそのレビューも精度高くできるようになり、より品質の高いスキーマを作成することができるようになるのですが、実際にそのような活動が十分に行われているかというプロジェクトの状態を測定するために、テストが十分かどうかのメトリクスが取りたくなります。

そこで、JSON Schemaとそのテストに対してカバレッジを取ることはできないかと考えてみました。
(過去にそういう先行事例がないかと調べてみましたが良さげなものを発見できなかったのもあります)

カバレッジ

一般的なコードのカバレッジとして、以下のような分類があります。

  • C0 ステートメントカバレッジ
    • テストで実行された命令の割合
  • C1 ブランチカバレッジ
    • テストで実行された判定条件の真偽の割合
  • C2 単純条件カバレッジ
    • C1では判断文の真か偽だったが、C2では判断分を構成する個々の文の真偽のそれぞれ取れている割合になる

これを参考にして、スキーマのカバレッジを以下のように定義してみました

  • C0 プロパティカバレッジ
    • スキーマに記述されているプロパティに対するテストがある割合
  • C1 単純キーワードカバレッジ
    • スキーマのvalidationに関するキーワードの判定条件の真偽の割合
  • C2 複合キーワードカバレッジ
    • スキーマのvalidationの取りうる状態を複合的に判定した真偽の割合

なんのこっちゃわからないと思うので、前述したスキーマとそのテストを例にして詳しく説明します。

C0 プロパティカバレッジ

スキーマに定義されている、propertiespatternPropertiesといったようなスキーマのリソースプロパティがどれほどテスト内で使われているかを測定するテストになります。
前述したテストはpropertiespatternPropertiesをきちんと網羅しているので100%となります。やったね!
基本的にはフルリソースを記述する正常系のテストを書くだけで100%になるはずです

C1 単純キーワードカバレッジ

これはスキーマのvalidationに関するキーワードがどれほどテスト内で使われたかというような指標になります。それらのキーワードが真になる場合と偽になる場合それぞれのテストが必要になります。この場合、スキーマの子要素にスキーマを取るような場合(例えばallOfのように、複数のvalidation条件を組み合わせるようなやつ)がありますが、親要素のキーワードだけを考慮します

C0が100%になるレベルだと、基本的にはC1が50%異常になっているはずです。
上記のテストだと、propertiesの"name"が偽になるようなパターンがないので、惜しくも100%には達しませんでした。

C2 複合キーワードカバレッジ

C1では親要素のキーワードだけの真偽値をチェックしているだけで、C2では子要素のキーワードの真偽も組み合わせで必要になってきます。コードカバレッジでいうところのC2に近いですね。

上記のテストの例でいうと以下のキーワードに対して偽になるようなテストが書かれていないことになります。やはりこういうようなところを網羅するのは大変だと思いますし、それを満たしたところで何か生まれるわけではない感というのは共通なのですね

  • #/definitions/postCreate/definitions/requestBody/allOf/1/required
  • #/definitions/postCreate/definitions/requestBody/allOf/1/additionalProperties
  • #/properties/id/minimum
  • #/patternProperties/extra[0-9]/type/1

カバレッジ計測ツールの実装と実際のプロジェクトのメトリクス

以上のように定義したカバレッジ測定ツールを実装し、実際にJSON Schemaのテストを書いているプロジェクトについて測定しました(キリッ といえればよかったんですが、
C0カバレッジまでを測定するくらいしか実装ができていません。

結構いろいろぐだぐだですが、おいておきます。多分バグがたくさんある → https://github.com/okitan/json_schema-coverage

ちなみに、実際のプロジェクトに当てはめた場合にどのような数値になったかというと、
プロジェクトの途中からテストを書き始めたものは、だいたい30%くらいで、
その知見を活かし、プロジェクトの最初からテストを書いていたものは、だいたい80%くらいでした。

これを始めようとした当初は、フルリソースに対するテストを買いていれば当然100%になるものだと思っていたのですが、
リソースに対して直接テストを書かず、上記のスキーマのrequestBodyのように、allOf中からリソースを参照するような書き方が多様されており、
現状のカバレッジ測定スクリプトではそれが計測されないことがわかったからです。
なので、もうちょい頑張る必要がありますね。。。

この投稿は ソフトウェアテスト Advent Calendar 201619日目の記事です。