📮

Postmanテストでリクエスト送信とレスポンス評価をループ実行したい!!!!!!!

18 min read

この記事は、ソフトウェアテストアドベントカレンダー13日目の記事です。

https://qiita.com/advent-calendar/2021/softwaretesting

Postmanのバグで記事を書こうと思い、せっかく12月に書くならとテストアドベントカレンダーに参加させて頂きました!ありがとうございます!
色々と内容を追加するうちにかなり長いものになってしまいましたが、目次を参照するなどしつつ必要な部分だけ読んでください🙇‍♂️
なお、jsに詳しいPostman使いの方々は「実際のコードの解説」セクションの「トラップC」以降を読めばいいかと思います📮

動く保障ができるのは「実際のコードの解説」セクションのはじめに貼ってあるコードだけです!
それ以外のコードは中間生産物なので動く保障ができません!!

ここまでのあらすじ

エンジニアとしての初仕事、やってやるぜ!
任されたAPI修正に前のめりで取り組むおれは、ようやくgotestで単体テストを完了させた(瀕死)
「最後にPostmanで通しのテストも書いといてもらえますか?」
まだあったの!?!?ええ!?!?と驚きを隠せないまま、音速でPostmanを開く!!ギュイーーーーーーン!!!(溶けそうな灼熱Macbookがうなる音)

おれはいまから、「一定の時間間隔でリクエスト送信とレスポンス評価を繰り返し、条件にあったレスポンスが所定の時間内に返ってきたらテスト成功、返ってこなければテスト失敗となるPostmanテストスクリプト」をつくらなければいけない!!!!!
このときおれは自分がどハマりして地獄を見るコトになるとは知らなかったんだ……👼

この記事を読むと作れるようになっちゃうもの

下記のものを作れちゃいます🎉

  • あるAPIに対して5秒間隔でリクエスト送信とレスポンス評価を繰り返し、「条件にあったレスポンスを得る」または「条件にあったレスポンスがないまま5分経つ」のどちらかで終了するJavaScriptコード
  • 上を実行できるPostmanのテストスクリプト

物知りなあなたはPostmanのテストスクリプトがjsで書かれているから1つ目と2つ目は同じもんだろうが!と怒るかもしれません……
しかし違うのです!後述のようにPostmanにはPromiseの再帰的呼び出しに関するバグがあり、ちょっと工夫をする羽目になるのです😭

実装したい処理の確認

下が今回実装したい処理ね!一応だけど確認ってわけで……(出、出〜〜w 図描画欲望mermaid以実現奴〜〜〜wwwwwww)

flowchart LR
    A[APIへリクエストを送る] --> B{レスポンスは\n条件を満たすか};
    B --->|はい| C[テスト成功を返す]:::someclass;
    B -->|いいえ| D{開始から5分が\n経過したか};
    D -->|はい| E[テスト失敗を返す]:::someclass;
    D -->|いいえ| F[5秒だけ待機する];
    F -->|ふりだしに戻る| A;
    classDef someclass fill:#f96;

実装におけるいくつかのトラップと、その解決策

作業をしていく中で、いくつかの罠が見えてきました。
処理の各段階における罠が下記の通りです!合計5個も!!(あなたにはかわいく見えるこいつらが私にとっては恐ろしい罠だったのです)

処理の各段階における、のべ5つの罠

そして各トラップに対する対処法を下にまとめるぜ!
勘のいい皆さんにはお分かりだろうけど、ここからは下に載っている対処法の各論に入っていくZE!
このスライドの記号は、右下に表示されている目次と対応しているから、必要そうなところを探して読んでくれ🎅

それぞれの罠に対する対処法

実際のコードの解説

各論に入る前に、全体像がわかんないとやばいだろうから、とりあえず完成品を載せておくぜぇ?
もうなんでもいいからはよコピペできるコード載せろ!!!っていう血の気の荒いやつはこれをコピペしてPostman走らせてみるんだな!!!!
(動かなかったらマジですいません、、、でもおれの環境では動いたよ🥺 )

こんな感じになりましたよん
//5秒おきにリクエストを送信
//レスポンスが条件を満たせばテスト成功、ならないまま5分経過したらテスト失敗と判定

const postRequest = {
    url: /*省略!*/ ,
    method: /*省略!*/ ,
    header: {
        /*省略!*/
    }
}

const requestInterval = 5000; //リクエスト間隔[ms]
const testTime = 300000; //テスト時間[ms]

const timeoutForTest = setTimeout(() => {}, testTime);
//トラップCへの対処: Postman黒魔術を使う

var count = 0;

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            count += 1;
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){ //トラップAへの対処: Promiseオブジェクトのthen()を使って、評価をレスポンス受理後にずらす
        setTimeout(() => { //トラップBへの対処: Promiseを再帰的に呼び出しつつ、setTimeout()で一定時間おいてのループを実行する
            if (result === 1) {
                pm.test("レスポンスは条件を満たしているよ!いえーい!!!!", function () {
                    pm.expect(result).to.eql(1); //トラップD,Eへの対処: pm.expect()をtest関数で包む。なお、このassertionは一度だけ呼ばれ、必ず成功する
                    clearTimeout(timeoutForTest) //postmanの黒魔術で生成された5分に及ぶsetTimeout()を滅却する、光の魔術
                });
            } else {
                if (count >= testTime/requestInterval) { //5分経ったらテストを失敗させる!
                    pm.test("5分経ってもダメでした!", function () {
                        pm.expect(result).to.eql(1); //トラップD,Eへの対処: pm.expect()をtest関数で包む。なお、このassertionは一度だけ呼ばれ、必ず失敗する
                        clearTimeout(timeoutForTest) //postmanの黒魔術で生成された5分に及ぶsetTimeout()を滅却する、光の魔術
                    });
                }
                return checkReponseWithRepeat(postRequest) //5分経ってないならもっかい呼ぶ
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

ここからは、上で説明した5つのポイントを順に説明することでコードをゼロから作っていってみるぜ!
急いでる人は興味あるとこだけ読んでね🤗

しつこいですが、動く保障ができるのは上のコードだけです!
それ以外のコードは分かりやすく説明するための途中経過でしかないので動く保障ができません!!

トラップAへの対処: Promiseオブジェクトを用いた同期的レスポンス評価

同期的とか非同期的ってなんやねんという人のために、その説明から入ろうじゃないか。
と思ったけど、これらの言葉の定義をネットで調べてみると意外とブレがある。サーバーを待っているかいないかだとか、逐次処理なのか並行処理なのかだとか。
おれは正直Postmanでしかjsを使わないから、正直思い切りすぎな部分はあるかもしれないけど、とりあえずこの記事では下記のように考えてくれ!!

  • 同期的: 複数の処理について、各処理が他処理の完了のタイミングを見計らって、意図された順番で実行される状態
  • 非同期的: 複数の処理について、各処理が他処理の完了のタイミングとは無関係に、(ほとんど)同時に実行される状態

こんな定義を振りかざされてもよく分からんだろうから下のコードを見てくれ。

これうまくいかないんですよ(jsのふりをした擬似コード!!)
sendRequest(request, (error, response) => {
    if (response === expectedResponse) {
        result = 1;
    } else {
        result = 0;
    }
}
if (result === 1) {
    console.log("実験大成功やったー!")
}

例えばこういうノリでレスポンスを評価する関数を書いたとしましょう。実際はこんな謎コード書かないとは思いますが、とにかくあえてリクエスト送信/レスポンス受理と、レスポンス評価を分けて書いたとするのです。
このとき、残念ながら2ブロック目のresultの値評価はundefined errorになってしまいます。いやいや何を言ってんねんと思うかもしれませんが、要するに1ブロック目と2ブロック目が同時に実行されてしまうのです。同時に実行しちゃったら、そりゃ2ブロック目は真顔で「resultってなんすか」って言ってきますわな
「いやおれは上から実行してほしいんだが??」と言ってもjs相手に話は通じません。粛々と同時実行されます。これが非同期処理です。逆に、2ブロック目に行く前にちゃんとレスポンスを待ってくれるなら、それは同期処理です。

同期的処理/非同期的処理の「同期」という言葉は、サーバーと同期的に処理をするのかということのようです。
同期的処理は順番を待ってくれる、すなわちレスポンスを待ってくれるから「同期的」というわけです。
このあたりの詳しい事情は是非こちらを参照してください!

https://qiita.com/kiyodori/items/da434d169755cbb20447

さて、今回の目的はレスポンスを評価する処理を実装することです。とすれば、我々は同期的に処理を実行しなければなりません!
現段階でJavaScriptで同期的に処理を実行する方法は下記3つになります。

  1. 一定時間スリープする関数(setTimeout()など)で当てずっぽうの時間だけ待たせる
  2. Promiseオブジェクトを使う
  3. async/awaitを使う

1はなるべく避けたいのが人情ですよね。特にテストの自動化なんてやっていたら、リクエストが返ってくるまでの時間を当てずっぽうで設定するなんてちょっと気が引けます。
3は残念ながら今回の記事で解説しません!気になる人は検索してみてね。
というわけで2のPromiseでやっていきます。Promiseは正常系と異常系とで処理を分岐させられる便利なヤツですが、それはつまり分岐後の処理が初めの処理の結果が出るのを待ってくれているということに他なりません

Promiseの素晴らしい解説はたくさんありますが、下記の2つの記事がめちゃ参考になりました!感謝感激😭😭

https://qiita.com/cheez921/items/41b744e4e002b966391a
https://techplay.jp/column/581

さてさて、とりあえず色々なものの見よう見まねで、同期的なレスポンス評価をPromiseに落とし込んでみます。これだけで動くわけじゃないけど、これは大きな一歩です。

ポストマン使用奴だからポストマンのモジュール使って書くけど勘弁してね
Promise((resolve) => {
    pm.sendRequest(request, (error, response) => {
        if (/*responseに関する条件が真*/) {
            resolve(1);
        } else {
            resolve(0);
        }
    });
})
.then(function confirmIfTestEnds(result){
    if (result === 1) {
        pm.expect(result).to.eql(1);
    } else {
        pm.expect(result).to.eql(1);
    }
});

こうしてpromise.then()をつかって、この世界に順序という秩序をもたらすことができました!! 秩序をもたらすなんて神にでもなった気分です。undefinedエラーなんてもう見ずに済むのです!

トラップBへの対処: Promiseの再帰的呼び出しと一定時間おいてのループ実行

では次は先ほど完成させた処理を再帰的に呼び出すこととします、しかも一定時間をおいて繰り返し!
ただ、困難は分割せよと偉い人が言っていましたが、おれだってそう思います。というわけで①Promiseの再帰的呼び出し、②一定時間おいてのループ実行の2つに分けましょう!

B-1: Promiseの再帰的呼び出し

まずは再帰的呼び出しに取り掛かりましょう!
Promiseをループさせたいときは、ループさせたいPromiseを返す関数を用意して、その関数をPromise自身の最後で呼び出しちゃうという戦術が有効です。
こちらの記事が参考になりました。

https://teratail.com/questions/89122

では早速やってみよう!

再帰呼び出し使うと強くなった気になれるよね
const postRequest = {
    /*いい感じに定義*/
}

function checkReponseWithRepeat(request) { //Promiseを関数の返り値にしてあげる
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        if (result === 1) {
            pm.expect(result).to.eql(1);
        } else {
            pm.expect(result).to.eql(1);
            return checkReponseWithRepeat(postRequest) //再帰呼び出しをする
        }
    });
}

checkReponseWithRepeat(postRequest) //ループのトリガー

再帰構造を持った関数checkReponseWithRepeatを最後に呼び出して、ループをトリガーさせています。
resultが1でない限り無限ループになりますが、このあと調整してみましょう。

B-2: 一定時間をおいてのループ実行

さて、上のループのさせ方では間髪入れずにループが回っていきます。
5秒間隔でリクエストを送るようにしたいものですな……というわけで、setTimeout()の出番です。

ループ文脈でのsetTimeout()の使い方については下記が参考になりました!本当にありがとうございます、世界の人々……😭

https://qiita.com/akyao/items/a718cc78436df68d7e15
https://dev.classmethod.jp/articles/javascript-sleep-set-timeout-to-promise/
以下、then()中の処理にsetTimeout()を噛ませてスリープを設ける作戦で修正したコードです。前半のPromise中のsendRequest()にsetTimeout()を仕込んでもよさそうですね!
一定時間をおいてループする穏便なコード
const postRequest = {
    /*いい感じに定義*/
}

const requestInterval = 5000; //リクエスト間隔[ms]を追加!

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        setTimeout(() => {  //これでスリープさせる!!
            if (result === 1) {
                pm.expect(result).to.eql(1);
            } else {
                pm.expect(result).to.eql(1);
                return checkReponseWithRepeat(postRequest)
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

B補足: 時間制限の設定

さて、このままではresultが1でない限りループが無限に続いてしまいます。
もともと作りたいのは5分経ってもリクエストが条件を満たしていないならテスト失敗として終わる処理です。さあ無限ループに終止符を打つぞ!!!
というわけで、うまくカウント処理を入れ込んであげます。これは先ほども貼った下記リンク先のコードから着想を得ています🙂

https://teratail.com/questions/89122
一定時間をおいてループし、さらに無限ループも回避できちゃう激ウレシなコード
const postRequest = {
    /*いい感じに定義*/
}

const requestInterval = 5000;
const testTime = 300000; //テスト時間[ms]を追加!

var count = 0; //カウント用変数を追加!

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            count += 1; //なんの変哲もないカウントを真顔で挿入
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        setTimeout(() => {
            if (result === 1) {
                pm.expect(result).to.eql(1);
            } else {
                pm.expect(result).to.eql(1);
                if (count >= testTime/requestInterval) { 
                    return nil     //5分経ったら再帰呼び出しせずに終わらせる
                } else {
                    return checkReponseWithRepeat(postRequest)
                }
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

トラップCへの対処: Postman黒魔術

さてさて、ここからはPostman特有の事情について話すことになります。Postmanなんて使ってねえよ!!!という人も時間があれば是非(読むわけない)

ここまでで作ったコードをPostmanで実行すると、残念ながら思った通りには動作しません。
具体的には、2回目以降のリクエストが送られないという事態が発生します。
これは本当にハマりポイントで、私もここで3時間溶かしました(さすがに溶かしすぎだというのは言われなくてもわかってます😡 言わないでください😡 )

皆さんが同じ轍を踏まないよう、Postman特有のPromiseの再帰処理に関するバグをここで紹介しておきます。まあ紹介と言っても、全てはこの2記事に書いてあります。stackoverflowの方がちょっとわかりやすいかもしれません!
要するに、PostmanでPromiseオブジェクトをthen()で連結させて、何度もsendRequest()しようとすると失敗するっぽいです。

https://community.postman.com/t/using-native-javascript-promises-in-postman/636
https://stackoverflow.com/questions/53934311/how-to-use-promises-in-postman-tests

上の2本の記事と私はいずれもsendRequest()でハマっている同士なので、他の状況でも同じコトが起きるのかは分かりません。
まあとにかくPromiseの連続召喚はPostmanにおいては上級魔法なのです。公式のサポートっぽい人("Postmanaut")が2018年1月に「取り組んでます!」と答えてくれていますが、私が確認した限りではバグはいまもご存命です😭 (v9.2.0を使っています)

さて、色々とスレッドなり関連リンクを読み解くと、どうも次のようにすると良いらしいことが分かります。

Promiseの連続召喚を可能にするPostman禁術(公式の人もこの方法を推奨しているので禁術ではありません)
const testTime = 300000;
const timeoutForTest = setTimeout(() => {}, testTime);

//Promiseとか使った処理ここにかく

//テストの最後に下記が呼ばれるようにする
clearTimeout(timeoutForTest) 

理由は全くわからないのですが、とにかく謎の長時間setTimeout()によってこの星は救われるようです。もう全然謎ですけど、実際やったら動くんだから結果オーライってことにしましょう!

ここで注意点として下記3つあります💣

  1. testTimeで指定する時間はテストコードの実行時間くらいにしてください。要はこの指定した時間中でしかsendRequestを繰り返せないのです
  2. testTimeは1,000,000,000くらいが限界らしいです。Number.MAX_SAFE_INTEGERのような定数にしたくなりますが、ダメらしいです
  3. 最後にclearTimeoutした方がいい

というわけでPromiseオブジェクトを絡めてpm.sendRequestをループさせたい人は上の上級黒魔術を覚えておいてください。
ではこれを踏まえて、テストコードを変更しましょう!

魔術的コード
const postRequest = {
    /*いい感じに定義*/
}

const requestInterval = 5000; //呪文を追加!
const testTime = 300000;

var count = 0;

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            count += 1;
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        setTimeout(() => {
            if (result === 1) {
                pm.expect(result).to.eql(1);
                clearTimeout(timeoutForTest); //呪文を追加!
            } else {
                pm.expect(result).to.eql(1);
                if (count >= testTime/requestInterval) { 
                    return nil
                    clearTimeout(timeoutForTest); //呪文を追加!
                } else {
                    return checkReponseWithRepeat(postRequest)
                }
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

トラップDへの対処: pm.expect()のラッピング

次のPostman事情はassertionの実行についてです。
Postmanではpm.expect()を中心に様々なassertion用の関数が提供されていますが、それらはpm.test()で包んであげないと、assertionの失敗が「テストの失敗」ではなく「AssertionError」として処理されてしまいます

https://github.com/postmanlabs/newman/issues/1737#issuecomment-429311661

AssertionErrorは異常系です。だから、例えばPostmanで自動テストを順次実行しているときにassertion errorが出るとテストはそこで中断されてしまいます。そんなん許せませんよね!?
というわけで下記のようにする必要があります。

優しくラッピングしてあげる
pm.expect(result).to.eql(1); //こうやって丸裸で実行すると、resultが1でない時にerrorになる

pm.test('TestExample', () => {
    pm.expect(result).to.be.equal(1) //pm.test()で包めば、resultが1でない時にテスト失敗として安全に処理される
})

これは、今回の記事のようにちょっと複雑めなネストの最後にassertionを行う時も同じです。ちょっと強引に見えても、最後のassertion部をpm.test()で包みこんでやりましょう😉

包み焼きハンバーグ
const postRequest = {
    /*いい感じに定義*/
}

const requestInterval = 5000;
const testTime = 300000;

var count = 0;

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            count += 1;
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        setTimeout(() => {
            if (result === 1) {
                pm.test("reponse meets condition", function () { //包み込んでる
                    pm.expect(result).to.eql(1);
                    clearTimeout(timeoutForTest);
                });
            } else {
                pm.test("reponse meets condition", function () { //包み込んでる
                    pm.expect(result).to.eql(1);
                    clearTimeout(timeoutForTest);
                });
                if (count >= testTime/requestInterval) { 
                    return nil
                } else {
                    return checkReponseWithRepeat(postRequest)
                }
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

トラップEへの対処: pm.expect()呼び出し回数の限定

幾多の罠をくぐり抜けて、ようやく最後の大詰めです。心して参りましょう🥷

さて、Postmanではtest()に包んでassertionを行うと、その結果が1つのテスト結果として記録されます。
これは自動化の文脈で地味に効いてきます。例えばPostmanとnewmanとを組み合わせて自動APIテストをやる際に、今回の記事のように繰り返しassertionをするテストを組み込んだとしましょう。毎回のassertionをpm.test()内で実行していると、最終的なassertionがうまくいってテストは人間の目から見たら成功しているのに、それまで繰り返されたassertionが失敗しているので最終テスト結果では失敗件数が多く出てしまいます。これはCircleCIなんかで自動化していても同じはずです。
これはなんとかしたいですね。別になんとかしたくない人も、なんとかしたいつもりになって読んでください😡
ではではこれまでのコードを一工夫しましょう。pm.test()内でassertionさせる前に、通常のif文でPostmanの文脈から切り離された形でassertionをしてしまう作戦です。

こうしてこのコードが生まれたってワケ
const postRequest = {
    /*いい感じに定義*/
}

const requestInterval = 5000;
const testTime = 300000;

var count = 0;

const timeoutForTest = setTimeout(() => {}, testTime);

var count = 0;

function checkReponseWithRepeat(request) {
    return new Promise((resolve) => {
        pm.sendRequest(request, (error, response) => {
            count += 1;
            if (/*responseに関する条件が真*/) {
                resolve(1);
            } else {
                resolve(0);
            }
        });
    })
    .then(function confirmIfTestEnds(result){
        setTimeout(() => {
            if (result === 1) {
                pm.test("レスポンスは条件を満たしているよ!いえーい!!!!", function () {
                    pm.expect(result).to.eql(1);
                    clearTimeout(timeoutForTest)
                });
            } else {
                if (count >= testTime/requestInterval) {
                    pm.test("5分経ってもダメでした!", function () {
                        pm.expect(result).to.eql(1);
                        clearTimeout(timeoutForTest)
                    });
                }
                return checkReponseWithRepeat(postRequest)
            }
        }, requestInterval);
    });
}

checkReponseWithRepeat(postRequest)

上記コードにおいてpm.test()は最後の最後のタイミング、具体的には下記のどちらかのタイミングでしか呼ばれません。

  1. responseが条件を満たし、result===1が真となり、テストが成功したとき("レスポンスは条件を満たしているよ!いえーい!!!!"が呼ばれる)
  2. 5分経ってもreponseが条件を満たさず、result===1が偽のまま、テストが失敗したとき("5分経ってもダメでした!"が呼ばれる)

テストの結果が定まった最後の瞬間しかpm.test()を呼び出さないことで、繰り返されるassertionを1つのテストに閉じ込めることができたってわけ🤗

おわりに

この記事はここまで!ようやく私たちはPostmanでリクエストをループ実行できるようになったのです!!
とにかく動くコードが欲しいんだ!!!という方は、「実際のコードの解説」セクションのはじめに貼ってあるコードをお持ち帰りください🎅
最初はPostmanのバグについてだけ書く予定だったのですが、ループありAPIテストの作り方を自分なりに網羅したらとても長くなってしまいました。
この記事でPostmanどハマり人が少しでも減れば幸いです……!(Postman使ってるってあんま聞かないけどね)

GitHubで編集を提案

Discussion

ログインするとコメントできます