まだプログラマーですが何か?

プログラマーネタ中心。たまに作成したウェブサービス関連の話も https://twitter.com/dotnsf

タグ:express

PayPay for Developers が公開され、アプリケーション開発者が自分のアプリケーション内に決済機能をもたせることが比較的容易にできるようになりました。これまでにも同様の機能はありましたが、PayPay アプリを利用した決済のため、クレジットカード情報など個人情報管理を意識することもなく、かつ日本円での決済ができるというメリット、加えて後述するようにその実装も非常にシンプルにできる点が画期的でした。

以下、実際に自分が Node.js で作成したサンプルを公開して紹介します。どの程度のシンプルさなのかも実際に見ていただきたいです。なお以下で紹介する内容は sandbox モードという、実際の決済は行わないテストモードで利用する想定で紹介しているので、安心して使ってみてください(アプリケーションはそのままで、加盟店登録を行って、モードを sandbox モードからプロダクションモードに変更することで本当の決済が行えるようになります)。

また API を細かく説明する前に、まずは実際に PayPay API を使ったサンプルアプリケーションを動かして決済を行い、その後でどのような実装になっているのかを説明します。

なお本稿は 2020/11/13 時点での動作を確認したアプリケーションに基づいて記載しています。


【準備】
サンプルを実際に動かすためにはいくつかの事前準備が必要です。まず何はともあれスマートフォンに PayPay アプリをインストールしておきましょう:
2020111301


次にローカル環境に Node.js をインストールしておきます。サンプルアプリケーションは Node.js で記述されているため、その実行環境としての Node.js を導入します。

そして PayPay for Developers に ID とサービス(実店舗に相当するアプリケーション)を登録する必要があります:
https://developer.paypay.ne.jp/account/signup

まずはメールアドレスとパスワードを指定して、ID を登録します:
2020111301


最初に指定したメールアドレスに確認のためのコードが送信されるので、メールを受信したらそこに書かれているコードを入力して「確認する」ボタンを押します。これでメールアドレスの存在を証明できました:
2020111301


次にサービスの内容を入力します。ここで入力した内容が決済時の画面などに表示されるものです。右上で skip することもできるようですが、ここでは登録する方法を紹介します:
2020111302


といってもここで指定するのはロゴの画像と、サービスの名称です。この例では Qdle という名前でサービスを登録しています(深い意味はありません、後で出てきます)。このあたりは適当に:
2020111303


次の画面では利用する決済手段を選ぶことができます。今回の例に限っては「ウェブペイメント」と「動的ユーザースキャン」なのですが、今後どういう使い方をするかわからないのでとりあえず全部チェックしました(最初からされています)。で「登録する」をクリック:
2020111304


無事にアカウントとサービスが PayPay for Developers に登録できました。「開始する」ボタンでダッシュボード画面に移動します:
2020111305


なお登録後はこちらのページから ID とパスワードでログインできます:
https://developer.paypay.ne.jp/account/signin

2020111302


ログイン直後のダッシュボード画面ではこのようなページが表示されます。本番モードではなく「テストモード」で稼働している点を確認してください(テストモード中は架空ユーザーでの決済になり、実際のお金の決済は行われません):
2020111302


画面下部に API キーシークレットが表示されています。また画面右上の DO と書かれた箇所をクリックすると MERCHANT ID を確認することができます。これら3つの値はこの後で使うことになるのでメモしておくか、コピー&ペーストできるようにしておきます:
2020111303


また後ほど sandbox モードで決済を行うのですが、その時に決済を行う架空ユーザー(3名)とそのパスワード、架空残高を画面下部の「テストユーザー」タブから確認することができます(パスワードの値はパスワード横のアイコンをクリックすることで見ることができます)。今はまだ使いませんが、この場所でユーザー情報を確認できることを覚えておきます:
2020111304


以上でサンプルアプリケーションを動かす準備はできました。


【ソースコードのダウンロードと編集】
次にサンプルソースコードをダウンロードします。git clone するか、ソースをまとめて zip ダウンロードして展開するなどしてソースコード一式を手元に用意します:
https://github.com/dotnsf/paypayapi

2020111305


展開されたファイルのうち、settings.js ファイルだけは動かす前に値の編集が必要です。テキストエディタでこのファイルを開き、以下の3行(テストモードでなく、本番モードで動かす場合は4行)を変更して保存します:
exports.apikey = '(API キーの値)';
exports.apisecret = '(シークレットの値)';
exports.merchantid = '(MERCHANT ID の値)';
exports.productionMode = false;   //. 本番モードの場合のみ true に変更

上3つは上述のダッシュボード画面で確認した値に変更します。また4行目はテストモードであれば false のままで、加盟店登録後に本番モードで稼働させる場合のみ true に変更します。

最後にこの状態で依存ライブラリをまとめてインストールします。コマンドラインやターミナルで paypayapi ソースコードフォルダに移動し、次のコマンドを実行します(実行完了までしばらく時間がかかります):
$ npm install

これでサンプルアプリケーションを動かすための準備ができました。

【アプリケーション起動】
まずはサンプルアプリケーションを動かします:
$ node app

そしてウェブブラウザで以下のアドレスを指定して開きます:
http://localhost:8080/

こんな感じのシンプルなページが表示されれば、アプリケーションの起動に成功です:
2020111306


画面には3つの機能しかなく、1つは支払額(円)を指定するフィールド、1つは支払い明細を記述するフィールド、もう1つは支払いを実行するボタンです。適当に入力してみます:

※下の例では支払額を 123 円、支払明細には「コーヒー代」と入力しました。
2020111307


この条件で PayPay による支払いを(架空ユーザーで)実行してみますが、テストモードで支払いを実行するために少し別の準備が必要になります。この状態のまま「支払い」ボタンを押さずに以下を続けます。

※ここで「支払い」ボタンを押して、以下の開発モードの切り替えに時間がかかってしまうと QR コードが無効になってしまい、スキャンできなくなってしまいます。その場合は改めてこの画面で「支払い」ボタンを押して QR コードを生成し直してください。


【PayPay 開発者モード】
支払いボタンを押す前にスマホ側の PayPay の準備をしておきます。スマホ側で PayPay を起動します。自分の(本物の)アカウントでログイン済みの場合はそのままでは架空ユーザーでの取引ができないので一旦ログアウトします。アカウントを選択し、アカウント情報画面の最下部からログアウトします:
2020111302

2020111303


改めて未ログイン状態で PayPay アプリを起動すると以下のようなログイン画面が表示されます。ここで PayPay アプリをテストモードに切り替えるため、画面左上の PayPay ロゴを7回タップします:
2020111304


途中からカウントダウンに・・・続けてロゴをタップします:
2020111305


すると「開発者モードでログイン」という選択肢が現れるので、これを選択します:
2020111306


開発者モードでのログイン画面が表示されます:
2020111307


ここでは自分のアカウントではなく、上述の PayPay for Developers ダッシュボード画面で確認したテストユーザーのいずれかの ID とパスワードを入力してログインします(ここでは下図一番上にいる 08075317593 ユーザーでログインしてみます):
2020111308


対象ユーザーのパスワード横にあるアイコンをクリックしてパスワードマスクを外すことで実際のパスワードを視認できるようになります。ユーザーネームとパスワードを入力して PayPay アプリに開発者モードでログインします:
2020111308


初回ログイン時のみ多要素認証が求められます。テストモードユーザーの SMS を受け取ることはできませんが、テストモードユーザーの認証コードは全て 1234 なので、1234 と入力します:
2020111309


開発者モードで、テストユーザーの ID でログインした時の画面です。上部に「開発者モードである」旨が表示されています:
2020111310



またこの状態で残高を確認してみます。ダッシュボードで確認した時と同じ残高(下図の場合は 99900 円)になっていることを確認します:
2020111311


これで PayPay をテストユーザーで使う準備ができました。普通の(本来の)モードに戻す場合はいったんログアウトし、再度正規のユーザーでログインしてください。


【アプリで生成した QR コードで決済】
PayPay アプリ側でテストユーザーの準備ができたので、改めて先程のアプリケーションに戻って、決済の続きを実行します。

支払額と明細を確認して「支払い」ボタンをクリックします:
2020111301


別ウィンドウが開き、その中に決済に関する情報と QR コードが表示されているはずです:
2020111302


この QR コードを開発者モードでログイン中の PayPay アプリを使ってスキャンします:
2020111301


すると(PayPay ユーザーにはおなじみの)「ペイペイッ」という音声が流れ、決済が完了してしまいます。普通に PayPay 使った時とまったく同じです:
2020111302


"Qdle" に支払った、という画面になっていますが、この "Qdle" は PayPay for Developers に最初に登録したサービス名です:
2020111303


残高を確認すると支払った分が引かれています。実決済ではないというだけで、本当に決済が実現してしまいました。。
2020111304


ダッシュボード側にも決済が反映されており、残高が更新されているはずです:
2020111303


開発者モードで実行されているため、実際に本当のお金の動きがあるわけではないのですが、このモードを変更した上で、実際の PayPay アプリで QR コードを読みとって決済すれば本当にお金が動く決済が実現します。

以下、このアプリケーションの中身(というか、このオペレーションに関わる部分)を抜粋して紹介しますが、いかにシンプルな作りになっているのかを確認できると思っています。


【ソースコード解説】
ではこの PayPay 決済アプリがどのようなソースコードで実現されているのかを解説します。といっても本当にシンプルなので、あまり解説するポイントもないのですが・・・

まずアプリ画面の HTML 部分です。本当にこれだけ、「支払い」ボタンの onClick イベントに反応して createQRCode() 関数を実行しているだけです:
<div class="container">
  <table class="table table-bordered">
    <tbody>
      <tr>
        <td>支払額(円)</td>
        <td><input type="text" id="amount" value="100"/></td>
      </tr>
      <tr>
        <td>明細</td>
        <td><input type="text" id="orderDescription" value="" placeholder="書籍代"/></td>
      </tr>
      <tr>
        <td> </td>
        <td><button class="btn btn-primary" id="orderBtn" onClick="createQRCode();">支払い</button></td>
      </tr>
    </tbody>
  </table>
</div>


次に「支払い」ボタンをクリックした時に実行されるハンドラ(createQRCode() 関数)の JavaScript がこちらです。支払額と明細の情報を送信して POST /paypay/qrcode の REST API を実行しているだけです。成功後の処理については後述します。ここまでは特に解説するほどの内容にはなっていません:
function createQRCode(){
  var _amount = $('#amount').val();
  var orderDescription = $('#orderDescription').val();
  
  var amount = ( _amount ? parseInt( _amount ) : 0 );
  orderDescription = ( orderDescription ? orderDescription : '' );
  if( amount ){
    $.ajax({
      type: 'post',
      url: '/paypay/qrcode',
      data: { amount: amount, orderDescription: orderDescription },
      success: function( result ){
        console.log( result );
          :
          :
      },
      error: function( e0, e1, e2 ){
        console.log( e0, e1, e2 );
      }
    });
  }else{
    alert( 'amount needed.' );
  }
}

次に REST API 側のコードを紹介します。REST API は paypay/paypay.js 内でまとめて実装していて、この中では最初に settings.js で指定された API キーやシークレット、MERCHANT ID の値を使ってPAYPAY オブジェクトの初期化をしています:
var settings = require( '../settings' );

var PAYPAY = require( '@paypayopa/paypayopa-sdk-node' );
PAYPAY.Configure({
  clientId: settings.apikey,
  clientSecret: settings.apisecret,
  merchantId: settings.merchantid,
  productionMode: settings.productionMode
});

そして POST /paypay/qrcode の中身がこちらです。PayPay SDK を使って PAYPAY.QRCodeCreate という関数を実行しています。実行時のパラメータ(payload)を指定していますが、決済額の情報は amount オブジェクトで指定しています( currency を "JPY" に指定していますが、現状はこの値しか使えないようです)。また merchantPaymentId をランダムに生成して指定していますが、ここで指定した値は後にキャンセルする際に必要になるので、アプリケーションによってはどこかで保持しておく必要があるかもしれません。また orderDescription に明細情報の文字列を入れておくことで決済時の画面にもコメントを表示させることが可能になります。

この PAYPAY.QRCodeCreate 関数は成功するとステータスコードが 201 になります。その値を見て成功・失敗を判断し、application/json 形式で結果を返しています:
router.post( '/qrcode', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );

  if( req.body.amount ){
    var payload = {
      merchantPaymentId: generateId( 'dotnsf-paypay' ),
      amount: { amount: req.body.amount, currency: "JPY" },
      codeType: "ORDER_QR",
      orderDescription: ( req.body.orderDescription ? req.body.orderDescription : 'orderDescription' ),
      isAuthorization: false,
      redirectUrl: "https://paypay.ne.jp/",
      redirectType: "WEB_LINK",
      userAgent: ( req.body.userAgent ? req.body.userAgent : 'My PayPay App/1.0' )
    };
    PAYPAY.QRCodeCreate( payload, function( response ){
      //console.log( response );
      if( response.STATUS && response.STATUS >= 200 && response.STATUS < 300 ){   //. 実際は 201
        res.write( JSON.stringify( { status: response.STATUS, body: JSON.parse( response.BODY ) } ) );
        res.end();
      }else{
        res.status( response.STATUS );
        res.write( JSON.stringify( { status: response.STATUS, body: JSON.parse( response.BODY ) } ) );
        res.end();
      }
    });
  }else{
    res.status( 400 );
    res.write( JSON.stringify( { status: 400, error: 'no amount info found.' } ) );
    res.end();
  }
});


ここも PayPay SDK を使っているだけで、それほど複雑な内容ではないと思っています。そして改めて1つ前の「支払い」ボタンをクリックした時のハンドラの成功後の処理がこちらです:
          :
          :
      success: function( result ){
        console.log( result );
        if( result && result.status && result.status == 201 && result.body && result.body.data ){
          var merchantPaymentId = result.body.data.merchantPaymentId; //. 支払いID(キャンセル時に必要)
          var codeId = result.body.data.codeId; //. QRコードID(QRコード削除時に必要)
          var url = result.body.data.url;  //. QRコードが表示されるページの URL

          if( url ){
            //. QRコードが表示されるページを別ウィンドウで開く
            window.open( url, 'PayPayWindow' );
          }
        }
      },
          :
          :


REST API の実行結果として返ってきたオブジェクト(result)を使い、result.body.data.url の値を取得します。この値が送信した条件で決済(より正確には決済して、自分が登録した店舗に決済額が入金される仕組みまで含めた処理を実行)するための QR コードが表示されるページの URL となっています。なので window.open() を使い、別ウィンドウで同ページを表示する、という処理を行っています。

別ウィンドウで表示されるページは上述しました。ここに表示される QR コードを PayPay アプリで読みとり、後は PayPay とそのアプリが面倒みてくれます。なのでサンプルアプリとしては何もしていません。

QR コード決済のエラー処理や例外対応については省略してしまっていますが、本質的にはたったこれだけのコードで自分のアプリケーションに日本円での PayPay 決済処理が実装できてしまう、という事実に驚いてしまいました。


【使ってみた感想】
なんといっても非常に簡単に実装できてしまうことに驚きでした。クレジットカード情報(の保管)を意識する必要もなく、日本円でのオンライン決済アプリのハードルがかなり下がったんじゃないかと感じました。これから決済アプリが流行る要因にもなりうると思っています。

このサンプリアプリケーションのソースコードを公開しているので、興味ある方は中を見ていただきたいです。実際にはキャンセル処理への対応ができるような REST API も実装済みではありますが、本サンプルアプリ内では使っていません。なので、本当にシンプルなコードだけでここまでできてしまい、かつアプリケーション側が意識すべきセキュリティ要件もかなり低く作れそうな印象を持っています。


【参考リンク】
Node.js 用 PayPay SDK
PayPay for Developers 公式ドキュメント
ダイナミック QR コード解説


【応用編】
なお、このブログエントリの続きはこちら。実運用を意識した内容の応用編です:
http://dotnsf.blog.jp/archives/1078272938.html

サブジェクトが少しわかりにくいと思ったので最初にやりたいことを補足しておきます。

ウェブサービスを公開する際に Basic 認証と呼ばれる認証機能を有効にすることがあります。アクセス時にユーザーIDとパスワードが聞かれ、正しい組み合わせを入力しないと先に進めなくなる、というものです。会員制サービスや、正式公開前のサービスを限られた人だけで使いたい場合、グーグル等の検索エンジンクローラーに見つからない状態で運用したい場合などによく使われます:
thumb_basic


今回やりたかったのは、この Basic 認証を例えば以下の条件で実現するような Node.js アプリケーションを作ることです:
・パス /hello 以下にアクセスした際に Basic 認証が必要
・/hello にアクセスするには URL パラメータ id が必要(つまり GET /hello だけではエラーとなり、GET /hello?id=XXX というフォーマットでアクセスする必要がある)
/hello?id=XXX の時と /hello?id=YYY の時とでは Basic 認証のユーザーIDやパスワードが異なる


最後のが今回の肝となる条件です。パラメータ id の値ごとに Basic 認証のユーザーIDやパスワードが変わり(データベース等に格納されているものを id をキーに取り出して比較するイメージ)、これを Node.js + Express 環境でどのように実現するか、というのが挑戦内容です。


やりたいことが明確になったところで、改めて Node.js + Express 環境で Basic 認証をかける方法をググってみると、basic-auth-connect モジュールを使う方法がメジャーな方法の1つとして見つかります。これは簡単にいうと以下のような感じで Basic 認証をかけるものことができるものです:
var express = require( 'express' ),
    basicAuth = require( 'basic-auth-connect' ),
    app = express();

app.all( '/hello*', basicAuth( function( user, pass ){
  return ( 'username' === user && 'password' === pass );
}));

  :
  :

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  res.write( JSON.stringify( { status: true }, 2, null ) );
  res.end();
});

  :
  :

GET /hello リクエストに対しては単に { status: true } という JSON を返すだけの定義がされていますが、その前に Basic 認証を有効にする部分が記述されています。この例では(/hello に何らかの URL パラメータが付属する場合も含めた) /hello* というパスに GET リクエストが行われた場合に Basic 認証が必要になり、ユーザーID 'username' 、パスワード 'password' が入力された場合のみ true(認証成功)で実際の GET /hello の処理が行われ、それ以外の場合は false(認証失敗)という扱いとなって再度入力が求められたり、何度か間違えると認証エラー扱いとなる、というものです。とても便利で、よく使っています。


さて、今回は上述の条件で Basic 認証を有効にする必要があり、少し異なる処理が必要です。正しいユーザーIDとパスワードは URL パラメータ id によって変わるのですが、この URL パラメータは req オブジェクから取り出す必要があり、今の形のままでは(認証判断時に req オブジェクトが取得できないので)取得が難しそうです。自分もこの basic-auth-connect モジュールを使う前提で実装を考えていたので詰まってしまいました。。

結論としては basic-auth-connect モジュールを使うことを諦め、自分で認証判断してエラー時にエラーコードを返す、という地味な処理に切り替えて実装できました:
var express = require( 'express' ),
    //basicAuth = require( 'basic-auth-connect' ),  //. basic-auth-connect は使わない
    app = express();

//. パラメータ id 毎に必要なユーザーIDとパスワード(本当はデータベース等から取得するイメージ)
var db = {
  "000" : { user: 'a', pass: 'x' },
  "001" : { user: 'b', pass: 'y' },
  "002" : { user: 'c', pass: 'z' }
};

/* URL パラメータ毎に認証情報を変えたい */
app.use( function( req, res, next ){
  //. hello* へのリクエスト時かどうかを判断
  var originalUrl = req.originalUrl;
  if( originalUrl.startsWith( '/hello' ) ){
    //. URL パラメータ ID を取り出す
    var id = req.query.id;
    //. 指定された ID のユーザー ID とパスワードが存在しているかどうかを調べる
    if( db[id] ){
      //. ヘッダから入力されたユーザーIDとパスワードを取り出す
      var b64auth = ( req.headers.authorization || '' ).split( ' ' )[1] || '';
      var [ user, pass ] = Buffer.from( b64auth, 'base64' ).toString().split( ':' );

      //. 入力内容が正しい場合のみ next() を返して本来の処理へ
      if( db[id].user == user && db[id].pass == pass ){
        return next();
      }else{
        //. 入力内容が間違っていた場合は認証エラー扱いとする
        res.set( 'WWW-Authenticate', 'Basic realm="MyApp"' );
        res.status(401).send( 'Authentication required.' );
      }
    }else{
      //. 指定された ID が存在していなかった場合も認証エラー扱いとする
      res.set( 'WWW-Authenticate', 'Basic realm="MyApp"' );
      res.status(401).send( 'Authentication required.' );
    }
  }else{
    return next();
  }
});

  :
  :

app.get( '/hello', function( req, res ){
  res.contentType( 'application/json; charset=utf-8' );
  res.write( JSON.stringify( { status: true }, 2, null ) );
  res.end();
});

  :
  :


赤字部分が今回作成した処理です。req オブジェクトからリクエスト先のパスや Basic 認証で指定された情報を取り出して正しい情報かどうかを判断し、正しい場合は本来の処理へ、間違っていた場合は HTTP の認証エラー結果を返すような内容を記述しています。basic-auth-connect モジュールを使うとこのあたりの細かな記述をする必要がなかったのですが、自分で判断する場合はこのあたりも自分の責任範囲で用意する必要があります。

上述の例では URL パラメータ id は "000", "001", "002" のいずれかである必要があり、それぞれの場合の Basic 認証情報(ユーザーID : パスワード)はそれぞれ "a":"x", "b":"y", "c":"z" としています。この正しい組み合わせが指定された場合のみ GET /hello が実行されて結果が返される、という処理が実行されるようになります。


(参照)
https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4


Node.js + Express 環境でウェブアプリケーションを動かす際のリクエストタイムアウト値(デフォルトは2分)を変更する方法を調べました。

Node.js はシングルスレッドなので、あまりタイムアウト値を長くしすぎるとそれはそれで支障がでることも考慮する必要があります。その上でタイムアウト値を変更するには以下のようにします:
var express = require( "express" ),
    app = express();

  :
  :

var app_server = app.listen( 8080 );
app_server.timeout = 1000 * 60 * 3;   //. 3分

ポート番号(上例では 8080)を指定して listen() を実行した結果の返り値(app_server)を取得し、その timeout プロパティ値をミリ秒単位で指定するだけです。


くどいようですがもう一度。Node.js はシングルスレッドなので、この値を大きく設定しすぎると、リクエスト処理に待ち行列が発生しやすくなるので要注意を。


"Hash File Storage" という、(IBM Cloud を使って)無料でも運用できるウェブストレージサービスのソースコードを公開します:
https://github.com/dotnsf/hfs


もともとはマンホールマップという自作の位置情報付き画像投稿サービスの機能の一部として開発したものだったのですが、画像投稿機能部分を切り出して、かつハッシュ計算を加えた上で API を整備しました。基本ストレージとして IBM Cloudant を使いますが、IBM Cloud のライトアカウント(無料)の範囲内でランタイム含めて運用可能なので、よかったら IBM Cloud と合わせてお使いください。


機能そのものは「ファイルストレージ」です。用意されたサンプルページや API を使ってファイルをアップロードしたり、アップロードしたファイルをダウンロードしたり、というよくあるものです。各種機能を REST API や Swagger ドキュメントでも提供しており、容易に外部アプリケーションから呼び出して利用することも可能です。

最大の特徴は格納時のファイル ID をファイルバイナリのハッシュ値で管理している点です。したがって既に登録されているファイルと(ファイル名などは異なっていても)バイナリレベルで全く同じファイルを登録しようとすると、同じファイル ID が既に存在しているため「登録できない」というエラーが返ります。またファイルを登録する以外にも「このファイルと同じものが既に登録されているか?」だけを調べる API が用意されていて、一度登録した後になんらかの変更が加わっているか/いないかを ID(ハッシュ値)で調べることができる、という特徴があります。 このサービス自体には含まれていませんが、ブロックチェーンと連携することでバイナリファイルの真偽性保証や、対改ざん性の強化を実現するものです。


実際に動作を確認するにはソースコードを git clone するかダウンロード&展開し、IBM Cloudant のクレデンシャル情報を指定した上で Node.js で起動します。詳しくは README.md を参照ください。



ソフトウェア開発時の、特に機能検証テストを意識した開発手法である TDD(Test-Driven Development : テスト駆動開発)をサンプルを含めて紹介します。 一応、自分の業務で使っている手法であり、ブログという形でアウトプットすることで自分の理解を深めることも目的の1つです。向き不向きあることは理解した上で「こういう開発手法もある」と理解してもらえればと。


【TDD とは?】
文字通りの「テスト中心に進めていく開発」手法です。具体的な例は後述しますが、「開発してからテストを考える」のではなく、「テストありきで開発する」というものです。

ここでの「テスト」とは「開発したソフトウェアプログラムが意図した通りに動くことを確認・検証すること」です。作って終わり、だとバグが多く残っていて、酷い場合はまともに動かないケースもあったりするのですが、そういったことがないように開発作業の一部としてテストを行います。テスト結果の評価方法にもよりますが、許容できないと判断された場合はテスト結果を元にプログラムを修正する必要が生じます。

また多くの場合、自動化できることを意識してテストを準備/実行します。自動化されたテストを CI/CD の環境に組み込むことで「テストに通ったものだけがデプロイされる」ことを実現します。

TDD はフロントエンド(UI)/バックエンドに共通した考えですが、個人的には API を中心としたバックエンド開発に向いている手法だと感じています。理由はテスト自体をアプリケーション開発の一部のように(同じプログラミング言語で)開発でき、テストを作ること自体がプログラミングの一部となり、バージョン管理を含めたソースコード管理がしやすくなるためです。またフロントエンドのテストは同一プログラミング言語では難しかったり、別ツールを使う場合は前述のメリットを享受しにくくなると感じています。 といったこともあり、以下はバックエンド開発を前提とした TDD の説明を行います。

TDD のフレームワークはプログラミング言語ごとに多くあります。後述のサンプルではプログラミング言語環境として一般的な Node.js + Express を使う前提で、テストフレームワークには Mocha(mochajs.org) を、またテスト中のアサーションと呼ばれるメソッドを実行するための Chai(chaijs.com) を併用した例を紹介します。


【TDD をサンプルで実践】
今回、以下のような REST API を開発することになった、とします。社内で多く使われるアプリケーションの機能の一部として「ユーザーの登録、検索、更新、削除」が必要で、これらを実現するような REST API を開発することになりました(認証や認可の話は今回の対象外とします):
REST API機能説明
POST /api/userユーザーの登録POST データを使ってユーザーを新規に登録する。既存ユーザーIDが指定されている場合はエラーとする
GET /api/user/{id}ユーザーの検索{id} で示されるIDを持つユーザーを検索する。存在しないIDが指定されている場合はエラーとする
PUT /api/user/{id}ユーザーの更新{id} で示されるIDを持つユーザーの情報を POST データを使って更新する。存在しないIDが指定されている場合はエラーとする
DELETE /api/user/{id}ユーザーの削除{id} で示されるIDを持つユーザーの情報を削除する。存在しないIDが指定されている場合はエラーとする


ユーザー情報自体は id(string) と name(string) だけを持つものとします:
 例: { id: "001", name: "木村桂" }

例えば POST /api/user を実行して id="100", name="K.Kimura" のユーザーを作成する場合のポストデータは以下のような JSON オブジェクト(の文字列)となります:
 例: { id: "100", name: "K.Kimura" }

同じ id="100" の既存データを name="Kei Kimura" に更新する場合は PUT /api/user/100 の REST API を実行し、以下のようなデータを送信します:
 例: { id: "100", name: "Kei Kimura" }

また全ての REST API の結果は HTTP レスポンス本文内に JSON 文字列で返され、結果の JSON オブジェクトには status 属性が付与されます。この status 値が true の場合は成功、false の場合は何らかのエラーが発生しているものとします:
 成功例: { status: true }
 失敗例: { status: false, error: "エラーの詳細・・" }


これらのような仕様の REST API を実装することが目的であるとします(本ブログでは上2つの GET および POST の API 開発に絞って紹介します)。以下では TDD 手法でこれらの API を開発する様子を紹介します。



【TDD 手法による開発手順】
では上述の REST API を TDD 手法を使って開発してみます。まずは検索機能である GET /api/user/{id} の API を作ってみることにします。

TDD では文字通り "Test-Driven" な開発を行っていくことが特徴です。具体的にはいきなり API の開発に取り掛かるのではなく、最初に「これから作るもののあるべき挙動」を考え、その内容をテストケースとしてプログラムに記述します。まずは GET /api/user/{id} のあるべき挙動を考えることにしましょう。

この API は id を指定して、その指定された id を持つユーザーが登録されていたら status=true でユーザー情報を返す、指定された id を持つユーザーが登録されていなかったらエラー扱いとして status=false でエラー情報を返す、という挙動になるはずです。

例えば既に id="001" というユーザーが登録済みで id="100" というユーザーは登録されていなかった場合のそれぞれの挙動としては、以下の (1), (2) のようになるはずです:
(1) GET /api/user/001 → 成功する(status=true の結果が返る)はず
(2) GET /api/user/100 → 失敗する(status=false の結果が返る)はず


この (1), (2) それぞれの実行とその予想される結果をまずテストとして記述します。 その次に該当の API を開発し、完成したら最初に記述したテストを実行して期待通りの結果になるかどうかを検証します。これが TDD による開発の基本的な流れとなります。


【TDD の準備】
上述の順序に従って実際の開発を行うのですが、その前に各種フレームワークの準備となる作業を紹介しておきます。

TDD による開発をはじめる直前までを実装したプロジェクトをこちらに用意しておきました:
https://github.com/dotnsf/tdd-sample.git


git clone するか、ダウンロード&展開してプロジェクトを展開します。またこの後すぐにテストが実行できるように必要なライブラリをインストールしておきます:
$ npm install


まずは package.json ファイルの説明をしておきます:
{
    "name": "tdd-sample",
    "version": "0.0.1",
    "scripts": {
        "start": "node app.js",
        "lint": "eslint",
        "test": "mocha --exit **/*.spec.js"
    },
    "dependencies": {
        "body-parser": "1.17.x",
        "cf-deployment-tracker-client": "^0.1.4",
        "express": "4.17.x"
    },
    "repository": {},
    "engines": {
        "node": "8.x"
    },
    "devDependencies": {
        "chai": "^4.2.0",
        "eslint": "^6.8.0",
        "mocha": "^7.0.0",
        "supertest": "^4.0.2"
    }
}

TDD に関わる部分を青字にしておきました。まず dependencies に cf-deployment-tracker-client モジュールを、devDependencies に chai, mocha, supertest モジュールを追加しています。これらを使ってテストを記述および実装します。 また scripts の中で "test": "mocha --exit **/*.spec.js" という要素を記述していますが、これは「**.js というファイル向けのテストが **.spec.js というファイルに記述されていて、これを実行して npm test コマンドでテストする」ことを宣言しています。

実際のソースコードに関わるファイルは app.js と routes/users.js の2つです。app.js には Express を使って HTTP サーバーとして稼働するための最小限のコードが書かれています:
//. app.js
var express = require( 'express' ),
    app = express();
var users = require( './routes/users' );

//. ユーザー関連 API を /api/user 以下にルーティング
app.use( '/api/user', users );

//. ポート番号を決めて起動
var port = process.env.port || 8080;
app.listen( port );
console.log( "server stating on " + port + " ..." );

最小限のコードに青字で書かれた部分が追加されています。青字部分は routes/users.js ファイルを読みこみ、その中に書かれている内容を使って /api/user 以下へのリクエストのルーティングを処理する、という定義になっています。端的に言うと今回テストしようとしているユーザー登録関連 API の中身が routes/users.js に書かれている、という定義をしていることになります。

そしてその routes/users.js ファイルの中身が以下のなります:
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 検索 API : GET /api/user/:id
router.get( '/:id', function( req, res ){
});

//. 新規作成 API : POST /api/user
router.post( '/', function( req, res ){
});

module.exports = router;

この中では '/:id' への GET リクエスト(GET /api/user/:id)と '/' への POST リクエスト(POST /api/user)へのハンドラが定義されています(中身がないので、このまま実行してもエラーになります)。本来は PUT や DELETE についてもここで記述するべきですが、本ブログではこの2つまでを TDD で開発する様子を紹介するので、この2つを記載しておきました。続きを自分で作る場合は是非追加してチャレンジしてみてください。


【テストケースの実装】
では実際に TDD を使って開発作業の続きを行っていきます。API そのものは routes/users.js の中身(route.get ハンドラと route.post ハンドラの中身)を書いて実装していくことになるのですが、前述のように TDD ではテストケースを先に定義します。上記の緑で書いた (1), (2) のテストケースを先に作って、その後で routes/users.js を作っていく、という順序になります。

ではまずはテストケースを用意します。users.js のテストケースは users.spec.js に作る、という宣言をしているので、routes/users.spec.js というファイルを以下の内容で新規に作成します:
//. users.spec.js
var request = require( 'supertest' ),
    chai = require( 'chai' ),
    app = require( '../app' );

chai.should();

/* (1) のテスト */
describe( 'GET /api/user/001', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/001' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (2) のテスト */
describe( 'GET /api/user/100', function(){
  it( 'should return 404 for non-existing user', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 404 );
    result.body.status.should.equal( false );
  });
});

特に緑で記述した部分で実際にテストを行っています。ここでの記述方法についての詳細は別途参照いただきたいのですが、実際にテストしたい内容と照らし合わせてなんとなく理解できるのではないかと思っています。(1) のテストでは(id="001" というユーザーが存在している前提で) GET /api/user/001 という API を実行し、その結果はステータスコードが 200 で、実行結果の JSON に { status: true, .. } という要素が含まれていることを確認してテスト成功とみなす、という内容を記述しています。 また (2) のテストでは(id="100" というユーザーが存在していない前提で) GET /api/user/100 という API を実行し、その結果はステータスコードが 404 で、実行結果の JSON に { status: false, .. } という要素が含まれていることを確認してテスト成功とみなす、としています。GET /api/user/{id} の API が正しく動いていればこうなるはず、という仕様を JavaScript で記述した例だと思ってください。

このテストケースを用意した上で実際の API を開発します。実際の API は routes/users.js ファイルの router.get( '/:id', ... で始まるハンドラーの中身として記述していきます。今回は以下のような内容の routes/users.js にしました(元のファイルに追加した部分をで記載しています):
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 本来は RDB などに格納するべき情報だが、この例では配列変数を使って簡易的に保存する
var users = [
  { id: "001", name: "K.Kimura" },
  { id: "002", name: "dotnsf" }
];

//. 検索 : GET /api/user/:id
router.get( '/:id', function( req, res ){
  var id = req.params.id;
  if( id ){
    var user = findOne( id );
    if( !user ){
      return res.status( 404 ).json( { status: false, error: 'user with id ' + id + ' not existed.' } );
    }else{
      return res.json( { status: true, user: user } );
    }
  }else{
    return res.status( 404 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. 新規作成 : POST /api/user
router.post( '/', function( req, res ){
});

//. id からユーザーを検索して返す
function findOne( id ){
  var u = null; //. 見つからない場合は null を返す
  users.forEach( function( user ){
    if( user.id == id ){
      u = user;
    }
  });

  return u;
}

module.exports = router;

まずこの API で扱うユーザー情報ですが、本来はデータベース等に格納したり、そこから取り出して取得するものだと理解しています。もちろん実際にそのように記述してもいいのですが、今回は TDD の解説を主目的としており、直接関係ない部分でコードを複雑化することは目的ではないため、この部分を簡易的に実装することにしました。具体的には上述のようにコード内に users という配列変数を用意し、この変数の中にユーザー情報が格納されていて、必要に応じて追加・更新・削除ができるような形で API を実装しています(この仕様によりアプリケーションを再起動するたびに中身は初期化されます)。またテストは id="001" のユーザーは存在していて、id="100" のユーザーは存在していない前提で実施されるため、id="001" のユーザーははじめから作っておくことにしました(同じ理由で id="100" のユーザーは作らないようにしてください)。

その上で router.get( '/:id', ... で始まるハンドラーの中身を API の実装として記述しています。まず URL の一部として指定される id の値を取得し、その値を id として持つユーザーを(findOne 関数で)検索し、null でないオブジェクトが返ってきたら、その結果を実行結果として { status: true, user: (実行結果) } の形で返すようにしています(特に指定していない時のステータスコードは 200 です)。指定した id のユーザーが存在しなかったり、id が指定されていなかった場合はステータスコード 404 を返しつつ、{ status: false, error: (エラーの原因) } という結果を返すようにしました。これで GET /api/user/{id} の実装例としては最低限動くものが作れました。

この後は実際にテストを実行するのですが、その前に app.js にも一箇所変更の必要があります。テスト時にはアプリケーション本体である app.js が実行された上でテストケースを確認することになります。この作業を効率的に行うために app.js をテストケースから呼び出して実行できるように最後に数行コードを追加します(赤字部分を追加):
//. app.js
var express = require( 'express' ),
    app = express();
var users = require( './routes/users' );

//. ユーザー関連 API を /api/user 以下にルーティング
app.use( '/api/user', users );

//. ポート番号を決めて起動
var port = process.env.port || 8080;
app.listen( port );
console.log( "server stating on " + port + " ..." );

//. テスト用に app 全体をエクスポート
require('cf-deployment-tracker-client').track();
module.exports = app;

【テストの実行】
ではテストケースと API 両方の準備ができたので、実際にテストを実行してみます。package.json 内にテスト用の scripts が用意されているので "npm test" コマンドを実行することで *.spec.js というファイルを見つけて *.js のテストを行います(青字部分がテスト結果です。またこの段階のプロジェクトでは routes/users.spec.js というファイルを見つけて、routes/users.js ファイルのテストだけが行われますが、ファイルが増えても同様のコマンドでまとめてテストできます):
$ npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object

  GET /api/user/100
    ✓ should return 404 for non-existing user


  2 passing (53ms)

この結果にはテストファイル(routes/users.spec.js)内の describe 関数で書かれた実行内容と、it 関数で書かれた期待結果が2つずつ表示され(つまり2つのテストが実施され)、どちらも pass したことを意味しています。つまり用意したテストに耐えうる API が開発できた、ということになります。

ちなみにわざとエラーを起こすようなテストケース(実在する id="002" のユーザーが見つからない想定)を用意して実行すると次のような結果になり、テストに通らなかったことがわかります:
npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object (40ms)

  GET /api/user/002
    1) should return 404 for non-existing user


  1 passing (82ms)
  1 failing

  1) GET /api/user/002
       should return 404 for non-existing user:

      AssertionError: expected 200 to equal 404
      + expected - actual

      -200
      +404

      at Context. (routes/users.spec.js:21:30)
      at 
      at process._tickCallback (internal/process/next_tick.js:189:7)



npm ERR! Test failed.  See above for more details.

【API の拡張とテストの再実行】
一応、ここまでの手順で最小限度の TDD が体験できているのですが、少しだけプラスアルファの要素を含めてみます。先程は GET (検索)の API を作ったので、続いて POST (新規作成)の API も TDD で開発してみます。

GET の時と同様にして、まずは POST API の仕様を考え、その仕様をもとにテストケースを考えます。先程作成済みの GET API とそのテストケースを使って、今回は以下のような挙動になるべきである、と考えたとします:
((2) で id="100" のユーザーが存在していないことを確認しているので、ここに続けて)
(3) POST /api/user/100 → 成功する(status=true の結果が返る)はず
(4) GET /api/user/100 → 成功する(status=true の結果が返る)はず

つまり (2) で id="100" のユーザーが存在していないことを確認した上で、(3) で id="100" のユーザーを作成し、(4) で (2) と全く同じテストを実行して今度はユーザーが見つかる、という挙動になることをテストで確認することにします。

この仕様のため、routes/users.spec.js を以下のように変更します(書き加えた部分を赤字で示しています):
//. users.spec.js
var request = require( 'supertest' ),
    chai = require( 'chai' ),
    app = require( '../app' );

chai.should();

/* (1) のテスト */
describe( 'GET /api/user/001', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/001' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (2) のテスト */
describe( 'GET /api/user/100', function(){
  it( 'should return 404 for non-existing user', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 404 );
    result.body.status.should.equal( false );
  });
});

/* (3) のテスト */
describe( 'POST /api/user', function(){
  it( 'should create user object', async function(){
    var result = await request( app ).post( '/api/user' )
      .send( { id: '100', name: 'Linux' } );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (4) のテスト((2) と全く同じ内容で結果が異なるはず) */
describe( 'GET /api/user/100', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

このテストケースを満たすように POST の API を開発していきます(場合によっては GET API にも手を加えていきます)。今回はポストされたデータから id を取り出し、既存ユーザーが存在していないことを確認した上で users 変数に push するだけの実装にしてみました:
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 本来は RDB などに格納するべき情報だが、この例では配列変数を使って簡易的に保存する
var users = [
  { id: "001", name: "K.Kimura" },
  { id: "002", name: "dotnsf" }
];

//. 検索 : GET /api/user/:id
router.get( '/:id', function( req, res ){
  var id = req.params.id;
  if( id ){
    var user = findOne( id );
    if( !user ){
      return res.status( 404 ).json( { status: false, error: 'user with id ' + id + ' not existed.' } );
    }else{
      return res.json( { status: true, user: user } );
    }
  }else{
    return res.status( 404 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. 新規作成 : POST /api/user
router.post( '/', function( req, res ){
  var id = req.body.id;
  if( id ){
    var user = findOne( id );
    if( user ){
      return res.status( 400 ).json( { status: false, error: 'user with id ' + id + ' already existed.' } );
    }else{
      users.push( req.body );
      return res.json( { status: true } );
    }
  }else{
    return res.status( 400 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. id からユーザーを検索して返す
function findOne( id ){
  var u = null; //. 見つからない場合は null を返す
  users.forEach( function( user ){
    if( user.id == id ){
      u = user;
    }
  });

  return u;
}

module.exports = router;

そして開発したコードをテストし、全てのテストコードが期待通りに動くことを確認します:
$ npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object (49ms)

  GET /api/user/100
    ✓ should return 404 for non-existing user

  POST /api/user
    ✓ should create user object (120ms)

  GET /api/user/100
    ✓ should return user object


  4 passing (203ms)

動きました!これで POST の API も開発できました。 以降、同様にして PUT(更新)や DELETE(削除)の API も、まず仕様を決めて、その仕様が満たすべきテストケースを考えて routes/users.spec.js に記述してから routes/users.js で API を開発してテスト、、、を繰り返す形で開発を進めていきます。これが TDD の典型的な開発手順となります。


【TDD への印象】
上でも少し触れていますが、この TDD という開発手法に対する自分の印象を記載しておきます。

まず開発体制が開発者とテスターとにきっちりと別れていないような場合(開発者=テスターの場合)であっても、この方法であれば、ある程度は客観的なテストが成立すると考えています。開発したものに対して、開発者がテストケースを考えるとどうしても漏れが出てしまったり、そもそも同一者によるテストを信用していいかどうかの問題も出てきます。その点 TDD では開発する前にテストケースを考えることで、同一者であってもある程度客観性が担保されたテストが可能になります。

また開発者とテスターが別れているケースでもメリットはあります。テスターを「開発トレーニングの一環」として考える場合、テスターもプログラミングによってテストを記述することになり、開発環境の構築や開発、デバッグの経験を積むことができるようになります。

一方で、特にこの手法の場合は REST API 開発のテストには向いていると思うのですが、フロントエンド側のテストを行うことが難しくなります。フロントエンドの HTML を取得して、その中身を解析して正誤を判断する、ことができないことはないと思いますが、そこまでのテストケースを作ることが大きな開発内容となってしまい、テストケースそのものが正しく作られているかどうかの問題も出てきてしまうと考えました。どうしてもバックエンド(REST API)向けのテストだと感じています。



このページのトップヘ