Edited at

Dredd で OpenAPI (旧Swagger) をテストするときのテクニック

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 で、 objectarray をたどりながら、第二引数の patch で必要なワークアラウンドを実行しています。 bodySchema$ref が解決されたスキーマが来るからたぶん大丈夫なはず・・・。今回の場合は UUIDv4 の正規表現に置き換えています。残念ながら正規表現で済まない場合は、 patchx- から始まるマーカーを付けて 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 が必ずあるとは限らないからなんだろうけど、それでもまともなコードを自動生成したい場合は皆入れてるはず(だよね?)。


回避策

beforeEachtransaction の値から 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 と、ステータスコードの番号順に考えていけばほとんどの場合はカバーできる。