Dredd を用いると、実際にリクエストを発生させて OpenAPI の仕様通りにレスポンスが返されているかを確認することができます。ところが、 Dredd は元々 API Blueprint 向けに作られたという事情から、 OpenAPI の扱いがイマイチな面があるので、ワークアラウンドが必要になるケースがでてきます。本格的に使うと(自分で書くよりマシとはいえ)時々ちょっと苦労します。
サポートされていない format
で fail する問題
format: uuid
など、他のツールではサポートされているデータ型がサポートされていない場合があります。このとき、理想的にはそこは無視して可能な限りのバリデーションだけを行ってほしいところですが、Dreddはバリデーションエラーを発生させてしまうようです。また、 x-
のようなベンダープレフィックスを利用して、より厳密にチェックしたい場合もあると思います。
追記:解決されました
回避策
一旦 pattern
で正規表現に置き換えてテストする方法が考えられます。
function patchSchema(v, patch) {
if (v.type === 'object') {
const props = Object.values(v.properties);
for (let i = 0; i < props.length; i += 1) {
patchSchema(props[i], patch);
}
} else if (v.type === 'array') {
patchSchema(v.items, patch);
} else {
patch(v, patch);
}
}
let stash = null;
hooks.beforeEach((transaction, done) => {
stash = {};
});
hooks.beforeEachValidation((transaction) => {
const { bodySchema } = transaction.expected;
stash.originalSchema = bodySchema;
const bodySchemaObject = JSON.parse(bodySchema);
patchSchema(bodySchemaObject, (v) => {
if (v.type === 'string' && v.format === 'uuid') {
const hex = '[0-9a-f]';
v.pattern = `${hex}{8}-${hex}{4}-4${hex}{3}-[89ab]${hex}{3}-${hex}{12}`;
delete v.format;
}
});
transaction.expected.bodySchema = JSON.stringify(bodySchemaObject);
});
hooks.afterEach((transaction) => {
transaction.expected.bodySchema = stash.originalSchema;
});
patchSchema
で、 object
と array
をたどりながら、第二引数の patch
で必要なワークアラウンドを実行しています。 bodySchema
は $ref
が解決されたスキーマが来るからたぶん大丈夫なはず・・・。今回の場合は UUIDv4 の正規表現に置き換えています。残念ながら正規表現で済まない場合は、 patch
で x-
から始まるマーカーを付けて afterEach
で同様にたどりながら検証しましょう。
operationId
使いたい問題
summary
は後で変えたくなりそうだし、 Hook の before
とかは operationId
で指定したいよねという問題。
Dredd のテストケースは filename > apiName > resourceGroupName > resourceName > actionName > exampleName
で特定されます。これは OpenAPI の場合は filename > info.title > Operationのtagsの最初 > path > Operationのsummary > responsesのステータスコード > produces
で対応づけられているようです(省略の条件とかがありそうだけどよくわからない)。たぶん summary
が必須で operationId
が必ずあるとは限らないからなんだろうけど、それでもまともなコードを自動生成したい場合は皆入れてるはず(だよね?)。
回避策
beforeEach
で transaction
の値から operationId
特定して、定義した関数を呼ぶ。
const fs = require('fs');
const yaml = require('js-yaml');
class OpenAPIDocument {
constructor(filename) {
const content = fs.readFileSync(filename, { encoding: 'utf-8' });
this.schema = filename.endsWith('.json') ? JSON.parse(content) : yaml.safeLoad(content);
}
removeBasePath(path) {
return path.substr((this.schema.basePath || '').length);
}
findOperation(method, path) {
return this.schema.paths[path][method.toLowerCase()];
}
findOperationByTransaction(transaction) {
const path = this.removeBasePath(transaction.origin.resourceName);
return this.findOperation(transaction.request.method, path);
}
}
let doc = null;
const testcases = new TestCases();
hooks.beforeEach((transaction, done) => {
if (!doc) {
doc = new OpenAPIDocument(transaction.origin.filename);
}
const operation = doc.findOperationByTransaction(transaction);
const handler = testcases.beforeHandler(operation.operationId, transaction.expected.statusCode);
if (handler) {
transaction.skip = false;
handler(transaction).then(done);
} else {
transaction.skip = true;
done();
}
});
としておいて、別な場所で TestCases
を作る。
class TestCases {
constructor(baseURL) {
this.testcases = {
signIn: {
async 200(transaction) {
transaction.request.body = JSON.stringify({
username: 'foo',
password: 'bar',
});
},
async 401(transaction) {
transaction.request.body = JSON.stringify({
username: 'foo',
password: 'WRONG PASSWORD',
});
},
},
};
}
find(operationId, statusCode) {
const testcases = this.testcases[operationId];
return testcases ? testcases[statusCode] : null;
}
beforeHandler(operationId, statusCode) {
const handler = this.find(operationId, statusCode);
if (!handler) {
return null;
}
if ('before' in handler) {
return handler.before;
}
return handler;
}
afterHandler(operationId, statusCode) {
const handler = this.find(operationId, statusCode);
return handler ? handler.after : null;
}
};
this.testcases.[operationId]
には async
関数か {before: async関数, after: async関数}
が入る。 produces
が複数ある場合以外はカバーできる。テストケースが多くなってきた場合には、複数のファイルに分割して スプレッド構文 で統合すると良い。
ちなみに testcases
に定義されていない場合はスキップするようにしている。なだらかに導入したいときに便利。
実は正常系のリクエストを生成するのが一番大変で、空のリクエストで 400 、不正なアクセストークン追加して 401 、権限の無いアクセストークンで 403 、正常なアクセストークンで存在しない ID(←Dreddが生成してる!)にすると 404 、存在する ID で 200 、それを作成しようとして 409 と、ステータスコードの番号順に考えていけばほとんどの場合はカバーできる。