LoginSignup
20
21

More than 5 years have passed since last update.

API Firstで仕様をテストする

Last updated at Posted at 2015-02-18

必要に迫られてapi-first-specというテストフレームワークを作りました。

リファレンス的な内容は(一応英語で)READMEに書いたので、こっちでは行間を補うというか作りながら考えていることを書いてみようと思います。

背景

まずはよくある話の方。現在、

  • 作っているプロダクトのドキュメントがほとんどない
  • API一覧的なものは一応あるもののメンテされてなくて、かなり間違っている
  • テストとかない

というシロモノのリファクタリングをやっているわけです。(--

まぁ自分も新卒だった頃以来一切ドキュメントとか書いてないので、そこに文句を言う気はないんですが相当ザクザクと修正しているのでテストがないのは不安です。

ので、最近気になっているワードであるAPI Firstを実現するためのライブラリとして突貫で作っちゃいました。(^^;

また、ちょっと珍しい方の話としては、Giveryではかなり積極的にインターンを取っているんですが、その報酬を成果報酬にするというかなりチャレンジングな素案があります。

これを実現するためにはなんらかのテストファーストな仕組みが必要だと思っていてそれも作ろうと思った動機の一つです。

どういうもの?

目標としては以下を設定しています。

サーバエンジニア、フロントエンジニアの双方が容易に理解できるAPIドキュメントを作ること

予備知識のないエンジニアが初めて見た場合でも、なんとなくAPI仕様が読み取れるのが理想です。

API仕様の記述自体がテストされること

仕様を記述する際にはtypoはつきものですが、API仕様の記述自体がテストになっているので、

  • ContentTypeの記述が間違っている
  • ルールから参照されているパラメータ名が存在しない
  • 存在しないルール名が使われている

などの間違いはテストを実行することで検出できます。

API仕様をテスト作成に利用できること

さらに読みやすさも重視です。

テストであると同時にドキュメントでもあるので。

拡張可能

ルールとか独自に実装できるといい感じ。

まだ全部は実装できていませんが方向性としてはそんな感じです。

サンプル

こんな感じ。特に説明がなくてもなんとなくわかると思う。

"use strict";
var 
  assert = require("chai").assert,
  spec = require("../lib/api-first-spec");

var API = spec.define({
  "name": "Login",
  "descripton": "メールアドレスとパスワードを指定してログインします",
  "endpoint": "/api/signin",
  "method": "POST",
  "request": {
    "contentType": spec.ContentType.URLENCODED,
    "params": {
      "email": "string",
      "password": "string",
      "remember_me": "bit"
    },
    "rules": {
      "email": {
        "required": true
      },
      "password": {
        "required": true
      }
    }
  },
  "response": {
    "strict": true,
    "contentType": spec.ContentType.JSON,
    "data": {
      "code": "int",
      "message": "string"
    },
    "rules": {
      "code": {
        "required": true
      },
      "message": {
        "required": true
      }
    }
  }
});

describe("ログインのテスト", function() {
  var host = spec.host("localhost:8888");

  it("ユーザ名が間違っている場合は code=500", function(done) {
    host.api(API).params({
      "email": "wrong@test.com",
      "password": "password"
    }).success(function(data, res) {
      assert.equal(data.code, 500);
      done();
    });
  });

  it("パスワードが間違っている場合は code=500", function(done) {
    host.api(API).params({
      "email": "test@test.com",
      "password": "wrong"
    }).success(function(data, res) {
      assert.equal(data.code, 500);
      done();
    });
  });
  it("ログインに成功した場合は code=200", function(done) {
    host.api(API).params({
      "email": "test@test.com",
      "password": "password"
    }).success(function(data, res) {
      assert.equal(data.code, 200);
      done();
    });
  });
});

//ログインは他のAPIのテストでも使用するのでmoduleとして公開する
module.exports = API;

ほぼ実際に使っているものそのままだけど、説明を日本語にするだけでかなりわかりやすくなりますね。

パラメータをどう表現するかは悩ましかったけど、params(data)とrulesをわけて定義することでかなりわかりやすくなったと思う。

当初は一緒に定義しようとしていたけど、階層構造の深いJSONを定義しようとするとわけが分からなくなるのでやめました。

配列やオブジェクトのあるJSONの定義は以下のような感じ。

  data: {
    code: "int",
    message: "string",
    list: [{
      id: "int",
      name: "string",
      created_at: "date",
      company: {
        id: "int",
        name: "string"
      }
    }]
  },
  rules: {
    code: {
      "required": true,
      "min": 200,
      "max": 500
    ),
    "id": {
      required: true
    },
    "list.name": {
      required: true
    },
    "list.company.name": {
      required: false
    },
    "company": {
      required: false
    },
    "list.created_at": {
      required: true,
      format: "YYYY-MM-DD"
    }
  }

オブジェクトはそのまま素直にハッシュで定義、配列は定義を[]で括るだけ。

rulesではドット区切りの名前を使用するので階層構造はなし。

上の例ではidに対するルールはlist.idとlist.company.idの両方に適用されます。

strictの話

responseの定義に「strict:true」を入れると、successメソッドでのテスト時にdataとして定義されていないキーがレスポンスのJSONに含まれていないことが検証されます。

これ、デフォルトはfalseだけどtrueにしようかとすごい迷いました。

特に理由がない限りtrueにするべきです。

一般にJSON APIにあとからキーを追加するというのはよくある話で、キーの追加だけであれば既存のクライアントは特に変更しなくても多分問題なく動きます。ていうか、動くように実装するべき。

また、テストを書く場合にも「結果にこのキーが含まれていること」というテストが書かれることはあっても、「結果にこれ以外のキーが含まれないこと」というテストが書かれることはほとんどないと思います。先にも書いた通り、そんなことしなくてもクライアントは多分問題なく動くから。

が、多分これこそがAPIドキュメントがメンテされない原因です。

trueにしておけばで、キーを追加したあとにテスト(API仕様)を修正しないとテストに失敗するので常にAPI仕様を最新に維持できます。(多分)

ちなみに、requestでも「strict:true」は指定できますが、使われることはありません。

BadRequestの話

個人的には必須パラメータがないとか文字列釣果などのValidationエラーはサーバ側では問答無用でHTTPステータスで「400 BadRequest」を返すことにしています。(パラメータの検証はクライアント側でやるべきだから)

なので、BadRequestを判定するメソッドのデフォルト実装は以下。

//responseのステータスコードが400の場合BadRequest
function isBadRequest(data, res) {
  return res.statusCode === 400;
}

が、一般的にはHTTPステータスは常に200で返して、レスポンスの中にエラーコードを埋めるケースも多いような気がします。

そういう場合にはAPI仕様の中でisBadRequestメソッドをオーバーライドすることで、BadRequestの判定方法を変えることができます。

//data(ResponseのJSON)にerrorというキーがある場合BadRequest
function isBadRequest(data, res) {
  return !!data.error;
}

いずれにせよBadRequestのテストは以下のように書くことができます。

  it("必須パラメータemailがnullの場合", function(done) {
    host.api(API).params({
      "email": null,
      "password": "password"
    }).badRequest(done);
  });

テストメソッドのsuccess, badRequest, notFound, unauthorized, clientErrorはそれぞれ判定メソッドisXXXXに対応しているので、それらをオーバーライドすることで判定方法を変えることができます。

ちなみに、success以外のメソッドではレスポンスのJSONの検証は行われません。

ゆくゆくはbadRequestsAll()のような、ベースとなる正常データから一つずつルールを破ったリクエストを片っ端から投げて全パターンのbadRequestを1メソッドで検証する、みたいなこともやりたいですがrequiredやminは簡単にinvalidなデータを生成できるのに対し、patternだけはinvalidデータを自動生成できない点をどうクリアしようか考え中です。

4/28 追記 作りました。BadRequestをまとめてテストする

ログインの話

多くのAPIは最初に認証を実行してからでないと実行できないようになっていると思いますが、その場合のテストは以下のように行います。

var 
  spec = require("api-first-spec"),
  assert = require("chai").assert,
  LoginAPI = require("./login.spec");

var API = spec.define({
  ...
  //仕様の中でログインが必要であることを明示する
  login: LoginAPI
});

describe("ログインなしでは実行できない", function() {
  var host;
  before(function() {
    host = spec.host("localhost:9000");
  });

  it("can't call without login", function(done) {
    host.api(API).params({
      p1: "aaa",
      p2: "bbb"
    }).unauthorized(done);
  });
});

describe("ログイン後は実行できる", function() {
  var host;
  before(function(done) {
    host = spec.host("localhost:9000").api(API).login({
      email: "test@test.com",
      password: "password"
    }).success(done);
    //上記は以下と同じこと
    // spec.host(...).api(LoginAPI).params(...).success(done)
  });

  it("成功", function(done) {
    host.api(API).params({
      p1: "aaa",
      p2: "bbb"
    }).success(done);
  });
});

ポイントはloginをbeforeで実行し、callbackにdoneを設定することで、その終了を待っていることです。

要はCookieを引き回しているだけなので、終了待ちしないと各テストがログイン完了する前に実行される可能性があります。

基本的には各テストはパラレルに実行されますが、各テストのcallbackの中でdoneを実行すればシリアルに実行することも可能です。(この辺はMochaの枠組みです。)

(訂正) doneを使用しない場合パラレルで各itが実行されますが、その場合レスポンスが返ってくるのを待たずにテスト全体が終了する可能性があります。このため常にコールバック内でdone()を実行(またはdoneを直接コールバックに指定)するようにしてください。

副作用として、このテストを単体で実行しようとした場合にも、Loginのテストも同時に実行されますが、それはまぁいいかと思ってます。

(通しで複数のテストを実行する場合はLoginのテストは一度しか実行されません。)

課題

とりあえず必要最低限のところは実装できたので、あとは実戦投入しながらブラッシュアップしようと思ってますが、やりたいと思っているのは以下です。

ご意見、PullReqお待ちしてます。(^^v

条件によるダイナミックなルール適用

最低限、条件によって必須という機能は欲しい。多分こんな感じ

required: function(data, res, req) {
  return data.some_flag == 1;
}

BadRequest網羅テスト

上にも書いたけど全パターンのBadRequestを検証する方法を考えたい。

patternさえなければわりと簡単にできると思うんだけど。。。(--

アップロードとダウンロードのサポート

必要なんだけど、仕様をどうするかを決めきれてないので保留

OAuthサポート

FacebookやTwitterでの認証サポート。

欲しいけど、これって自動テストできるもんなの?
やり方があるならぜひ知りたい。

HTTPヘッダの検証

callbackでHttpResponseが取れるからやろうと思えば検証はできるけど、CookieはSpecの中で定義してsuccessメソッドの中で自動で検証できるようにしたい

ベーシック認証

必要に迫られたら作る

  • Herokuにテストサーバ 気が向いたら作る
20
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
21