HARファイル+axios-mock-adapterで固有環境の不具合を再現する

こんにちは。

二度目の登場のフロントエンド担当川口です。

この記事は HRBrainAdventCalendar 13日目の記事です。

今回も前回と同じくaxiosに絡んだ記事を書いていきます。

ユーザー環境の不具合が再現できない

サービスで不具合報告があったが固有の環境でしか発生せず開発環境で再現できない。

一般に開放しているようなサービスであればそんな場合にもURL一つで本番環境を開いて再現できたりしますが、会員制サービスやBtoBサービスではそういうことも難しい。

そんな場面でもHARファイルとaxios-mock-adapterを使って開発環境で不具合再現する方法を紹介します。

HARファイルとは

HARファイル(HTTP Archive)とはそのページのネットワークのやり取り全てを記録したファイルで、そのページの描画に利用されたデータが全て詰まったファイルになっています。

詳しくはこちらのページなどを見ていただければ。

qiita.com

Chromeにおいてはデベロッパーツールのネットワークタブでいずれかのリクエスト上で右クリックメニューを開き、 Save all has HAR with content を選択することによって出力できます。

今回使用するHARファイルは

  • 不具合報告してくれたユーザー
  • もしくは不具合が再現するクローズドな環境へのアクセス権限がある方

に作成してもらう形になりますが、HARファイルにはユーザーの詳細な個人情報が含まれていることが多々ありますので取り扱いには十分ご注意ください。

axios-mock-adapterとは

axios-mock-adapterはaxiosをmock化するライブラリでして、こちらを使用することによってフロントのみでAPIモックを設定することが可能になります。

www.npmjs.com

今回はこのaxios-mock-adapterとHARファイル内のAPIログデータとを組み合わせることによって、固有環境の不具合を開発環境で再現していきます。

確認した環境

  • React
  • TypeScript
  • Axios
  • axios-mock-adapter
  • Webpack

axios関連のコード

基本的にはこちらと同じように設定していきます。

qiita.com

同じようなコードも出てきますがそこは上の記事と合わせて見ていただければ。

./src/main.ts

import axios from 'axios';
import mock from 'mock';

const axiosInstance = axios.create();
if (mock) {
  mock(axiosInstance);
}

./src/mock/index.ts

/* eslint no-console: 0 */
// @ts-nocheck
/* eslint-disable security/detect-non-literal-regexp */

import axios, { AxiosRequestConfig } from 'axios'
import MockAdapter from 'axios-mock-adapter'

import responses from 'mock-response'

const originalAxios = axios.create()

export interface ResponseData {
  status: number
  data: any
  jsonPath?: string
}

export interface Response {
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
  url: string
  response?: (config: AxiosRequestConfig) => ResponseData | ResponseData
  then?: (status: number, data: any) => [number, any]
}

function match(method, url, response) {
  if (method.toLowerCase() !== response.method.toLowerCase()) {
    return false
  }
  return url.match(new RegExp(response.url))
}

function respondMock(response, config): Promise<any[]> {
  let res = response.response
  if (typeof res === 'function') {
    res = res(config)
  }
  const logStyle = 'color: green; font-weight: bold;'
  const url = config.url.replace(config.baseURL, '')

  console.groupCollapsed(
    `Mock API Request [${new Date().toLocaleString()}] ${config.method.toUpperCase()} ${url}`
  )

  console.log('%cMethod:', logStyle, config.method.toUpperCase())
  console.log('%cStatus:', logStyle, res.status)
  console.log('%cUrl:', logStyle, url)

  if (config.params) {
    console.log('%cParams:', logStyle, JSON.stringify(config.params, null, '  '))
  }
  if (config.data) {
    let data = {}
    if (typeof config.data === 'string') {
      data = JSON.parse(config.data)
    } else if (config.data instanceof FormData) {
      config.data.forEach((value, key) => {
        data[key] = value
      })
    }
    console.log('%cParamData:', logStyle, data)
  }
  console.log('%cHeaders:', logStyle, JSON.stringify(config.headers, null, '  '))
  console.log('%cData:', logStyle, res.data)

  console.groupEnd()

  // random wait
  return new Promise((resolve) => {
    const rnd = Math.floor(Math.random() * 100) + 1
    const time = 1000 * (rnd / 100)
    setTimeout(() => {
      resolve([res.status, res.data])
    }, time)
  })
}

export default (instance) => {
  const mock = new MockAdapter(instance)

  mock.onAny().reply((config) => {
    const { method, url } = config
    const targetUrl = url.replace(config.baseURL, '')

    const matchResponse = responses.find((response) => match(method, targetUrl, response))

    if (!matchResponse || matchResponse.then || matchResponse.response.jsonPath) {
      if (matchResponse.response.jsonPath) {
        config.method = 'get'
        config.baseURL = `${location.origin}/harJson`
        config.url = matchResponse.response.jsonPath
      }

      // pass through
      return new Promise((resolve) =>
        originalAxios(config)
          .then((res) => {
            if (matchResponse && matchResponse.then) {
              resolve(matchResponse.then(res.status, res.data))
            } else {
              resolve([res.status, res.data])
            }
          })
          .catch((res) => {
            resolve([res.status, res.data])
          })
      )
    }

    return respondMock(matchResponse, config)
  })
}

./src/mock/empty.ts

export default null;

読み込ませるモックデータをHARファイルから生成するスクリプト

HARファイルからAPIリクエストのログを抜き出し、axios-mock-adapterに読み込ませるmockデータの形にしたものを出力するスクリプトです。

基本的にはサーバー起動前に実行して、生成されたmockデータファイルを読み込ませます。

./clientScripts/createHarMockData.js

/* eslint no-console: 0 */

const fs = require('fs')
const path = require('path')
const urlLib = require('url')
const Buffer = require('buffer').Buffer
const fetch = require('node-fetch')
const rimraf = require('rimraf')

const API_BASE = 'example.com/api'

function isBase64(str) {
  const base64Reg = /^(?:[A-Z0-9+\/]{4})*(?:[A-Z0-9+\/]{2}==|[A-Z0-9+\/]{3}=|[A-Z0-9+\/]{4})$/i
  if (!str || !str.substr) {
    return false
  }
  return base64Reg.test(str.substr(0, 100))
}

function decodeBase64(data) {
  return Buffer.from(data, 'base64').toString();
}

(async () => {
  const harFileName = process.env.HAR
  const harFilePath = harFileName.indexOf('/') > -1 ? harFileName : path.join(__dirname, '../src/mock/har', harFileName)

  const dataJson = fs.readFileSync(harFilePath)
  const data = JSON.parse(dataJson)
  const { entries } = data.log

  const xhrEntries = entries.filter(entry => {
    const { headers } = entry.request
    const jsonHeader = headers.find(({ name, value }) => (
      name === 'accept' && value.indexOf('application/json') > -1
    ))
    return jsonHeader && entry.request.url.indexOf(API_BASE) > -1
  })

  const responses = []

  for (const [_index, entry] of xhrEntries.entries()) {
    const { request, response } = entry

    const url = request.url.replace(/^.+?\/som/, '')
    let json = isBase64(response.content.text) ? decodeBase64(response.content.text) : response.content.text

    let jsonPath = null

    if (!json) {
      const fetchParams = {
        headers: {},
        body: null,
        mode: 'cors'
      }
      request.headers
        .filter(({ name }) => name.indexOf(':') === -1)
        .forEach(({ name, value }) => { fetchParams.headers[name] = value })

      if (request.headers[':method']) {
        fetchParams.method = request.headers[':method']
      }

      if (fetchParams.headers.referer) {
        fetchParams.referer = fetchParams.headers.referer
        fetchParams.refererPolicy = 'strict-origin-when-cross-origin'
      }

      let responseJson = null
      try {
        const response = await fetch(request.url, fetchParams)
        responseJson = JSON.stringify(await response.json())
      } catch(error) {
        console.log(error)
      }

      const dirPath = path.join(__dirname, '../src/mock/harJson')
      const fileName = urlLib.parse(request.url).path
        .replace(/\/som\/api\//, '')
      jsonPath = `${fileName}.json`
      const filePath = path.join(dirPath, jsonPath)
      const fileDir = filePath.match(/(^.+\/)/)[1]

      rimraf.sync(dirPath)
      fs.mkdirSync(fileDir, { recursive: true })

      fs.writeFileSync(filePath, responseJson)
    }

    responses.push({
      method: request.method,
      url: `${url.replace(/\/[0-9]+/g, '/[0-9]+?')}$`,
      response: {
        status: response.status,
        data: json ? JSON.parse(json) : null,
        jsonPath
      }
    })
  }

  const responsesCode =
  `import { Response } from '../index'
  
  const responses: Response[] = ${JSON.stringify(responses, null, '  ')}
  
  export default responses
  `

  const filePath = path.join(__dirname, '../src/mock/har-response.ts')

  fs.writeFileSync(filePath, responsesCode)

  console.log(`created: ${filePath}`)
})()

Webpack周りのコード

webpack.config.js

const path = require('path');
const webpack = require('webpack');

const isMock = Boolean(process.env.MOCK);

module.exports = {
  ...
  resolve: {
    alias: {
      mock: path.join(__dirname, './src/mock/', isMock ? 'index' : 'empty'),
      'mock-response': path.resolve(__dirname, 'src/mock/har-response.ts')
    }
  },
  ...
};

package.json

"scripts": {
  "create-mock:har": "node clientScripts/createHarMockData.js",
  "mock": "MOCK=true webpack-dev-server"
}

動かしてみる

それでは実際に動かしていきましょう。

まずはHARファイルからaxios-mock-adapterに読み込ませるデータを生成するためのスクリプトを実行します。

$ HAR="HARファイルの絶対パス" yarn create-mock:har

これによって src/mock/har-response.ts のファイルが生成されました。

har-response.ts のパスはすでにWebpackのaliasとして設定しているため、mock起動した際にはこのmockデータがそのまま読み込まれて実行されます。

ではいよいよmockモードで開発用サーバーを立ち上げます。

$ yarn mock

これでaxiosがmockされた状態の開発用サーバーが立ち上がり、mockデータとしてHARファイル内のリクエストログのデータがそのまま使われるため、見事に固有環境の不具合を再現することができるようになりました。

まとめ

一般向けのSNSサービス等では不具合がオープンな環境で発生することも多いため再現が容易であったりしますが、BtoBサービスのようなクローズドなサービスでは中々そういうことも難しいです。

ましてや詳細な個人情報が記録されているようなサービスでは本番データへのアクセス権限がかなり絞られた状況というのも珍しくありません。

そのような場面でぜひこのような方法を試していただければ嬉しいです。