こんにちは。
二度目の登場のフロントエンド担当川口です。
この記事は HRBrainAdventCalendar 13日目の記事です。
今回も前回と同じくaxiosに絡んだ記事を書いていきます。
ユーザー環境の不具合が再現できない
サービスで不具合報告があったが固有の環境でしか発生せず開発環境で再現できない。
一般に開放しているようなサービスであればそんな場合にもURL一つで本番環境を開いて再現できたりしますが、会員制サービスやBtoBサービスではそういうことも難しい。
そんな場面でもHARファイルとaxios-mock-adapterを使って開発環境で不具合再現する方法を紹介します。
HARファイルとは
HARファイル(HTTP Archive)とはそのページのネットワークのやり取り全てを記録したファイルで、そのページの描画に利用されたデータが全て詰まったファイルになっています。
詳しくはこちらのページなどを見ていただければ。
Chromeにおいてはデベロッパーツールのネットワークタブでいずれかのリクエスト上で右クリックメニューを開き、 Save all has HAR with content
を選択することによって出力できます。
今回使用するHARファイルは
- 不具合報告してくれたユーザー
- もしくは不具合が再現するクローズドな環境へのアクセス権限がある方
に作成してもらう形になりますが、HARファイルにはユーザーの詳細な個人情報が含まれていることが多々ありますので取り扱いには十分ご注意ください。
axios-mock-adapterとは
axios-mock-adapterはaxiosをmock化するライブラリでして、こちらを使用することによってフロントのみでAPIモックを設定することが可能になります。
今回はこのaxios-mock-adapterとHARファイル内のAPIログデータとを組み合わせることによって、固有環境の不具合を開発環境で再現していきます。
確認した環境
- React
- TypeScript
- Axios
- axios-mock-adapter
- Webpack
axios関連のコード
基本的にはこちらと同じように設定していきます。
同じようなコードも出てきますがそこは上の記事と合わせて見ていただければ。
./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サービスのようなクローズドなサービスでは中々そういうことも難しいです。
ましてや詳細な個人情報が記録されているようなサービスでは本番データへのアクセス権限がかなり絞られた状況というのも珍しくありません。
そのような場面でぜひこのような方法を試していただければ嬉しいです。