🖖

Vue + Firebase emulator + storybook + reg-suit = VRT

2022/03/12に公開約15,000字

Vue + Firebaseで すでに稼働していて、テストがないシステムにどのように テストを追加して、リファクタリングをすすめていくか検討しました。 UnitTestリファクタリングの前段階として Storybook,reg-suitを導入してVisualRegressionTestを まず実現したというところをまとめます。

WebAppのUnitTestについて一般論

下記のスライドを参考にしました。

ポイント

Web Componentのテストといったときに下記のような入力、出力があるが
そのなかで Props, Vuex,State → component → HTML/CSSをテストするのがVRTです。

  • 入力

    • Lifecycle
    • Props
    • Vuex,State
    • UserInteration
  • 出力

    • HTML/CSS
    • Event
    • VuexAction

導入作戦

これからプロジェクトをはじめる場合は WebUI要素ごとに vue componentを作成し、ひとつづつStorybookに登録して確認をとりながらすすめるのがまっとうなやり方だと思います。 
でも、すでに動いているVueプロジェクトで、かつあまりComonent化されておらず Pageにドバっと数千行のコードが書かれているとしたら、、、 リファクタリングするにしてもUnitTestもないので怖くて手がつけられない。 なので戦略としてはVueのPageをそのままStorybookに登録して まずVRTを主要ページ分確立したいと思います。 その後リファクタリングをおこない VueComponentへ切り出しをすすめるというやり方を採用していきたいです。 Karma, Jest等の導入もしたいがまず面でとらえるVRTを導入するほうが費用対効果高いと考えました。

storybook, storycap, reg-suitの組込み

シンプルにVueでstorybookを動かしたい場合、Libのインストールは、下記などを参考に普通にnpm(yarn) installするだけです。

https://storybook.js.org/tutorials/intro-to-storybook/vue/en/get-started/

https://github.com/reg-viz/storycap#managed-mode

https://github.com/reg-viz/reg-suit

https://tech.medpeer.co.jp/entry/2020/04/10/160000

storybook, storycap, reg-suit系のコマンドは storybookの起動に時間がかかります。同じsrcで何回かscreenshotを試したいことがあったので、下記のように screenshot-built を追加しました。

  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook -o dist-storybook",
    "screenshot": "storycap --serverCmd \"start-storybook -p 6006\" http://localhost:6006 --serverTimeout 600000 --delay 1000 --verbose true",
    "screenshot-built": "storycap --serverCmd \"npx http-server dist-storybook -p 6006\" http://localhost:6006 --serverTimeout 600000 --delay 1000 --verbose true",
    "shot": "storycap http://localhost:6006 --serverTimeout 600000",
    "regression": "reg-suit run"
  }

Screenshotのオプション説明

  • --serverTimeout 600000: ページ全体なのでロードに時間かかるケースがあったため
  • --delay 1000: load後、shotをとるまえに1秒ほどまってくれる
  • --verbose true: github actionなどで実行したときに情報多く出力したい場合はtrue(通信logが沢山はかれる)

Storycapをmanagedなモードでstorybookに組み込む(storycapの都合にあわせてstorybookを制御してくれる)ためにstorybookのmain.jsに下記のようにaddonとしてくみこみます。

.storybook/main.js
module.exports = {
  "stories": [
    "../*/*.stories.mdx",
    "../*/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    'storycap'
  ],

storybook において test data どう設定するか問題

対象Projectは主要データをFirebase、Firestoreに保存しており 開発に応じて随時データも更新されていく。
VRTは基本的に画面のスクショの差分を管理するテスト手法なので データが変わってしまうと差分が多くなりすぎてコードの問題なのか、データが変わっただけなのかが判定できず意味をなさなくなる。
FirebaseをMock化することも考慮したが、本家Google様が出しているFirebaseEmulatorをいれることがスマートだと考えました。

Firebase Emulator 導入

まずローカルにemulatorをInstallする

https://firebase.google.com/docs/emulator-suite/install_and_configure?hl=ja

emulatorは node, javaで動いているので事前にインストールしておく必要があります。
mac, ubuntu等ではdefaultでInstallされています。

% java -version
java version "1.8.0_91"
Java(TM) SE Runtime Environment (build 1.8.0_91-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.91-b14, mixed mode)

% node --version
v16.13.2
% npm install -g firebase-tools

% firebase login
Already logged in as {$USER}
  • 初期構築する場合は、firebase init emulatorsを実行します
  • firestore, storageのほかに、テスト用のユーザーも同じIDで使いまわしたいので Authenticationもemulateしておく必要があります
    以下のログが出力されれば成功です。Authentication,firestore, storageがLocalのそれぞれのportでアクセスできるようになります。
(base) firebase_emulator % npx firebase emulators:start -
i  emulators: Starting emulators: auth, firestore, storage
i  firestore: Firestore Emulator logging to firestore-debug.log
i  auth: Importing config from /XXX/firebase_emulator/exported20220227/auth_export/config.json
i  auth: Importing accounts from /XXX/firebase_emulator/exported20220227/auth_export/accounts.json
i  ui: Emulator UI logging to ui-debug.log

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://localhost:4000                │
└─────────────────────────────────────────────────────────────┘

┌────────────────┬────────────────┬─────────────────────────────────┐
│ Emulator       │ Host:Port      │ View in Emulator UI             │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Authentication │ localhost:9099 │ http://localhost:4000/auth      │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Firestore      │ localhost:8090 │ http://localhost:4000/firestore │
├────────────────┼────────────────┼─────────────────────────────────┤
│ Storage        │ localhost:9199 │ http://localhost:4000/storage   │
└────────────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at localhost:4400
  Other reserved ports: 4500 

データ追加、参照

Localで動作しているデータを参照したい場合は上記にかかれているように emulatorを実行後 localhost:4000 にアクセスすることで以下のようなUIでデータを確認することができます。
スクリーンショット 2022-03-05 8.58.09.png

  • 基本的にまっさらな状態からスタートするので、一度データを設定した後、2回目以降同じDBで始めたい場合は下記のように
    export, emulator起動時にimport できます。
(base) firebase_emulator % npx firebase emulators:export exported2022MMDD                          
i  Found running emulator hub for project at http://localhost:4400
i  Creating export directory /XXX/firebase_emulator/exported2022MMDD
i  Exporting data to: /XXX/firebase_emulator/exported2022MMDD
✔  Export complete
(base) firebase_emulator % npx firebase emulators:start --import=exported2022MMDD

Storybookでfirebase emulator を動かすための工夫

storybookのstoriesを追加する手順はシンプルです。テスト対象のPageを読み込んで表示するだけです。

import dashboard from '../../src/views/dashboard.vue';
import StoryRouter from 'storybook-vue-router'

// More on default export: https://storybook.js.org/docs/vue/writing-stories/introduction#default-export
export default {
  title: 'Dashboard',
  component: dashboard
 };

 /* story記述 */
// default
export const Default = () => ({    // 変数名がナビゲーションパネルでの表示名となる
    components: { dashboard },  // 対象となるコンポーネントを指定する
    template: '<dashboard />'  // レンダリングするhtmlを記述する
  });

  Default.decorators = [
    /* this is the basic setup with no params passed to the decorator */
    StoryRouter({}, { initialEntry: { name: 'dashboard' } })
  ]

ただ、このままだと 当然firebaseの接続ができずにstorybookの表示でエラーが発生します。

そこで、下記のファイルにfirebase emulator への接続を追加します

.storybook/preview.js
// Initialize Firebase
import firebase from  'firebase'
if (!firebase.apps.length) {
  firebase.initializeApp(firebaseConfig);
  console.log('preview initialized:',firebase)
  const isEmulating = window.location.hostname === "localhost";
  if (! isEmulating) {
    console.error('error not localhost')
  }
  firebase.auth().useEmulator("http://localhost:9099");
  firebase.firestore().useEmulator("localhost", 8090);
  firebase.storage().useEmulator("localhost", 9199);
}  

loaders

そして認証していることが前提のVueComponentがほとんどなので、
LocalのAuthにtest1 ユーザーが存在している前提で
Storybookを実行する前にLoginした状態をつくりたいです。そのためにstorybookに用意されているloadersの仕組みを利用します。

https://storybook.js.org/docs/react/writing-stories/loaders
.storybook/preview.js
async function setupFirebase(){
  const userCred = await firebase.auth().signInWithEmailAndPassword('test@test.com','XXXX')
  console.log(userCred)
  console.log(firebase)  
  return firebase

}

export const loaders = [
  async () => ({
    firebase: await(setupFirebase())
  }),
];

firebase 参考、Tips

  • firebaseのプロジェクトを選択時に下記のようなエラーがでることがあります

    % firebase projects:list
    ✖ Preparing the list of your Firebase projects
    
    Error: Failed to list Firebase projects. See firebase-debug.log for more info.
    

    表示されているとおりfirebase-debug.log を開いてみると下記のError表示

    Error: HTTP Error: 401, Request had invalid authentication credentials. Expected OAuth 2 access token
    

    login できているって表示されてたのに、なぜ!!と思いながら上記のエラーメッセージでググると下記のパラメータで強制的に再認証シーケンスを走らせることで対応できるとのことです。

    % firebase login --reauth --no-localhost
    
  • LocalでUIを立ち上げてっも白いページのまま表示されないことがあります。下記のコマンドでUIコンポーネントを再取得すると表示されるようになりました。

    • npx firebase setup:emulators:ui
  • Firebase 単体テスト公式情報わかりにくい

    • https://firebase.google.com/docs/rules/unit-tests?hl=ja#rut-v2-common-methods
    • 上記のサイトをみると いかにもemulatorを利用するにはinitializeTestApp, initializeTestEnvironment を呼び出さないと行けないように見えますが、実際には接続するだけであれば 追加のnpm install も必要なく 通常のfirebase libに上記で解説したとおり useEmulator を呼び出せばよいだけ。。 この情報にたどりつくまでに 大分時間かかりました。

storybookでpage 全体を動くようにするために しなければいけなかったもろもろものこと

  • storybook のwebpack build にて fs, tls, netがみつからないといわれる

  • asset の読み込みに失敗する

    • 通常のvueのパスと異なるので imageの読みこみなどに失敗する
    • https://storybook.js.org/docs/vue/configure/images-and-assets
    • 上記を参考に main.jsに下記の設定を追加する
    • module.exports = {
        ...
        staticDirs: ['../../public',{from:'../../src/assets',to:'/assets'},{from:'../../src/components',to:'/components'}],
      
  • storybook内で vue $route を使うようにする設定がわからない

    • 下記を参考にstorybook-vue-router を導入
    • ただし、このプラグインは最近更新されておらず npm install時に 不整合が発生してしまう
      • npm install --legacy-peer-deps と実行することで回避しているがvue3 にUpしてstorybook-vue3-routerに乗り換えたい
    • https://qiita.com/sawami2019/items/85feb5c1603d6b535f00
  • storybook内でvuetifyを使いたい

  • storybookのvue component 内で No Firebase App '[DEFAULT]' has been created - call Firebase App.initializeApp() 発生

    • 本体のpackage.jsonを誇大化させないように、storybook用のフォルダを分割し、storybookで必要な package.jsonはsub directoryで管理をしようとおもい、 storybookないのpackage.jsonでもfirebaseをinstallしていた。実態として.storybook/preview.jsで初期化したfirebaseと ../src/view/**.vueのなかで参照される firebaseが実態として違うもの(2重に)存在してしまっていたため。
    • storybook/ 内のpackate.jsonのdependenciesからはfirebaseは削除。npm の仕様としてstorybookないのnode_modulesにfirebaseが存在しない場合は上位(../)のフォルダ内のnode_modulesを参照するので同じものが参照できるようになる

storybook実行

以下を実行し、表示できればOk

(base) storybook % npm run storybook
╭───────────────────────────────────────────────────╮
│                                                   │
│   Storybook 6.4.19 for Vue started                │
│   43 s for manager and 36 s for preview           │
│                                                   │
│    Local:            http://localhost:6006/       │
│    On your network:  http://192.168.1.15:6006/    │
│                                                   │
╰───────────────────────────────────────────────────╯

localhost:6006 にアクセスする

storycapの実行

以下を実行

(base) storybook % npm run screenshot
> storycap http://localhost:6006 --serverTimeout 600000

info Wait for connecting storybook server http://localhost:6006.
info Executable Chromium path: /XXX/node_modules/puppeteer/.local-chromium/mac-961656/chrome-mac/Chromium.app/Contents/MacOS/Chromium
info Storycap runs with managed mode
info Found 4 stories.
info Screenshot stored: __screenshots__/Home/Default.png in 3290 msec.
info Screenshot stored: __screenshots__/Dashboard/Default.png in 64422 msec.
info Screenshot was ended successfully in 68919 msec capturing 2 PNGs.

storybook/__screenshots__ 以下にstorybookの画面ごとのキャプチャが格納される

github action 組込み

各自のLocalPCで実行するだけでなく、GithubでPullRequest作成時に自動でScreenShotを作成し、一つ前のバージョンとの差分を検出したい。それらを自動化してくれるツールがreg-suitです。

https://github.com/reg-viz/reg-suit

github actionで firebase emmulatorをinstall, storycapを実行し、それをGCPに保存、
reg-suitをかけた結果をGithub PRに表示してくれます。

.github/workflow/vrt.yml
name: VisualRegressionTest

on: pull_request

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
        if: github.event_name == 'pull_request'
        with:
          fetch-depth: 0
          ref: ${{ github.event.pull_request.head.ref }}
      - uses: actions/checkout@v2
        if: github.event_name == 'push'
        with:
          fetch-depth: 0      
      - name: Set up Node.js 16
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v0
        with:
          project_id: XXX
          service_account_email: my-ci-account@XXX.iam.gserviceaccount.com
          service_account_key: ${{ secrets.GCP_SA_KEY }}
          export_default_credentials: true
      - name: install japanese font
        run: |
          sudo apt install fonts-ipafont fonts-ipaexfont
      - name: Run integration tests against emulator
        working-directory: ./firebase_emulator
        run: |
          npm install -g firebase-tools
          npx firebase emulators:start --import=exported2022MMDD &
      - name: npm install main
        run: |
          npm install
      - name: npm install for vrt
        working-directory: ./storybook
        run: |
          npm install --legacy-peer-deps
      - name: run storybook and screenshot(to avoid firestore initial load fail, do twice screenshot)
        working-directory: ./storybook
        run: |
          npm run build-storybook
          npm run screenshot-built
          npm run screenshot-built
      - name: run VRT
        working-directory: ./storybook
        run: |
          npm run regression
        env:
          REG_NOTICE_CLIENT_ID: ${{ secrets.REG_NOTICE_CLIENT_ID }}
      - name: upload artifact
        uses: actions/upload-artifact@v2
        with:
          name: screencap
          path: ./storybook/__screenshots__

PR上に下記のように通知がとどきます。めでたし、めでたし。
(今回の例だと新規のテストなので4つの画面が追加されたという白丸表示です)

pr-reg-suit.png

github action 小ハマリポイント

  • firebase emulator 実行方法
    • 当初専用Containerを用意しないとだめかなと思っていましたが以下のコマンドで一発で通常のubuntu-latestにinstallできました
      - name: Run integration tests against emulator
        working-directory: ./firebase_emulator
        run: |
          npm install -g firebase-tools
          npx firebase emulators:start --import=exported2022MMDD &
  • reg-suit で detached_head といわれておこられてしまう。
      - そもそもgithub actionのなかではgithubが独自につくったrefを参照しています
      - 下記のようにcheckout時に pull_requestのheadを参照するように設定することで回避できます
     - uses: actions/checkout@v2
       if: github.event_name == 'pull_request'
       with:
         fetch-depth: 0
         ref: ${{ github.event.pull_request.head.ref }}
  • storybookの表示データがロードされていない
    • どうもfirebase emulator 起動後一回目だけはどれだけまってからアクセスしてもloadに失敗する問題があるようです。詳しくは追えていないですが回避のためにかきで2度screenshotを実行しています
     - name: run storybook and screenshot(to avoid firestore initial load fail, do twice screenshot)
        working-directory: ./storybook
        run: |
          npm run build-storybook
          npm run screenshot-built
          npm run screenshot-built

以上です

Discussion

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