Skip to content

Instantly share code, notes, and snippets.

@altnight
Last active November 21, 2024 06:58
Show Gist options
  • Save altnight/eb561d31ebf05b7e239fef1bd5e5c132 to your computer and use it in GitHub Desktop.
Save altnight/eb561d31ebf05b7e239fef1bd5e5c132 to your computer and use it in GitHub Desktop.
2024/11/21 フロントエンドのテストはVRTがよい

2024/11/21 フロントエンドのテストはVRTがよい

社内発表資料を元に最新版に更新(参考: 2022/11/10(木) bpstyle 142)

結論

  • とりあえずはTypeScriptの導入
  • 他はVRTがおすすめ
    • 自分はChromaticを使用している
    • API通信のモックデータは実データから生成するとよい
    • 画面上の操作を記述し状態を複数定義することで、結合テストができる
  • Jestなどでの単体テストは書かなくてもよい。あるいはない方がよい
  • E2Eを導入するのは相応の覚悟が必要。廃止する判断までがセット。トータルでない方がよいこともある
    • 最近は外部サービスでE2Eもできるようなので、そちらに任せた方が無難そうではある

(一般) テストについて

  • テストは品質を可視化するが、テストがあれば内部品質が高いとは限らない
  • とはいえ、自動テストがないと改善の難易度が上がる
  • 内部品質とビジネス要求
  • フロントエンドのテストでよく見るテスティングトロフィーの図
    • 結合テスト多めにしたいが、実際の現場ではどうしているのか
      • 各社の紹介事例はあるが、実際問題便利なのか。逆に面倒になっていないか

今までやってきたフロントエンドのテストについて

静的型チェック / TypeScript

概要

  • 静的な型チェック
  • 最近はデファクトになってきている

感想

  • フロントエンドの他のテスト手法を導入できるかわからない以上、現在ではほぼ必須
    • 集団での持続的な開発においては必須
    • 壊れてても動きがちなJSの世界で、最低限の保証ができるのは大きい
    • 生のJSやDOMのAPIはundefinedが返ってきがちで、NULL安全でないコードが容易に生まれる
      • 当然なんでもAnyにしたりignoreしたら意味がないけど、それでも「意図的に無視している」ということはわかるし、それ以外の箇所は一応型で保証ができる
  • 導入が簡単
    • 新規案件なら最初からいれて書けばOK
    • 途中から導入する場合でも、JSと混ぜて段階的に移行できる
  • ESLintやtsconfig設定は気合いで
    • それでもVite等が生成するデフォルト設定に沿っていれば最低限は担保できる

単体テスト / Jest

概要

  • テストフレームワーク
  • 主に単体テストの記述に使用?

感想

  • アプリケーションコードの実装では不要に思えた
    • ライブラリ等であればテストしたいこともあるのだろう
    • ただ、多くのWebアプリケーションはVue/React等で宣言的に記述してサーバー側とAPI通信をするくらい。ロジックとしてはそこまで大きくない
    • そもそもフロントエンドではそこまで複雑な制御をしない(持ち込まない)
    • スナップショットテストも試してみたが、うーん...
  • 後述のVRTで補完した方が結合テストになるため、むしろJestは導入しない方が保守性が上がる

VRT

概要

  • Visual Regression Testing
    • 画面のスクリーンショットを撮影して差分を比較する。その結果、画面にどう変更があったのかを検知できる
      • HTML/CSSがいつ壊れたかがわかる
  • 実現するツールやサービス
    • reg-suit (OSS)
      • ただし最近は更新頻度が少なめとのこと? 安定している?
    • Chromatic (サービス)
      • スクショをとるためにコンポーネントカタログとしてStorybookを使用する
  • 自分自身は Chromatic を採用
    • サービス側で差分を判定してくれてわかりやすい
    • アップロードしたスナップショットをいい感じに管理してくれる
    • ウェブ上で Storybook を閲覧できる
    • 無料プランは 5000枚/月
      • 小規模プロジェクト/保守なら十分
      • 中規模プロジェクトで有用ならお金を払った方がよい

感想

  • 「とりあえず画面の変更がされていない」という保証ができるのはうれしい
  • ライブラリの更新もしやすい
    • CSSフレームワーク等のバージョンアップも比較的簡単
  • エンジニアで変更があったときに(比較的)追跡しやすい
  • StoryBookに渡す状態を増やせば結合テスト的なこともできる
    • これがとても大きい。例は以下
      • ダイアログを閉じた状態と開いた状態
      • 通常時とエラーメッセージが出ている場合の状態
      • A画面を開いてからB要素を選択しCボタンをクリックした後の画面の状態
  • StoryBook自体を単体で見ることはほぼない
    • StorybookはあくまでVRTのために整備しているだけなので、コンポーネントカタログとしては意図的に使っていない

API通信のデータ

  • APIレスポンスをモックするのいいとして、サーバーサイドとの乖離を減らしたい
  • モックを増やしまくってテストだけ通るというパターンにはしたくない
    • 静的なJSONのモックレスポンスをメンテナンスする作業は厳しい
    • 「OpenAPI等のスキーマにそって書けばよい」というのもかなり厳しい
  • モックの把握と記述に時間がかかる状況は避けたい
  • 結論として、サーバー側で factory-boy と Django のテストクライアントを使用してJSONを生成することにした

E2E

  • ブラウザを操作しての自動テスト
  • Selenium, Puppeteer, Cypress, Playwright などがある
  • 自分が使用したことあるのはCypress
    • 比較的新しいE2E用テストフレームワーク
    • 賢いスリープ
    • HTMLに独自のタグ(data-cy)をつけるのを推奨
    • ビデオ撮影+スクリーンショット撮影
    • Dockerイメージあり
    • Chrome, Edge 等まとめてサポート

導入

  • docker compose でがんばった
    • CircleCI 上で Docker Image を使用するには ECR等に登録しないといけない。そうなるとブランチごとにCI上でビルドするか、 AWS CodeBuild 上でビルドして登録することになる。であれば、 CircleCI 上で machine instance を使用し docker compose で実行する方がよい
  • 一通りの環境をすべて作成して、マイグレーション、データ生成後に実施
  • ローカル実行(CUI), ローカル実行(GUI), CircleCI実行(CUI)の3環境ある
  • テストケースの記述はBDD

感想

  • 本物のブラウザが本物のデータを対象に動く安心感はそれなりにある
  • ただ、必ずどこかで不安定なテストが生まれる
    • GitHub Actions内でのブラウザの描画が早すぎて、指定のDOM要素が存在しないなど
    • ローカル環境だと動くのにCI環境だと動かないとかがけっこうあって大変
    • データ取得はmswを使用しているので安定してたりするが...
    • Flakyなテストがあるのは結構ストレスで、チームのコミュニケーションも必要となり、結果的に全体の開発コストが上がる感がある。であれば、最初から導入しない方が結果的にトータルで品質を上げられる気はする
  • 実行時間が長くなり、環境が重くなりがち
  • 運用としては、テストケースは薄く、少なめのユースケースをカバーすればOKとした
  • 最近のE2Eは専用サービスもあるようなので、そちらを使った方がいいのかも
  • Playwrightは軽いという話もあるので、そちらでもいいのかもしれないが未知数
    • VRTもできるようではあるし(画像の管理等は自前だが)
  • ローカルやCIでは動かさず、dev環境やSTG環境のみで動かすというのはよいが、対象の環境のデータ管理もセットで行う必要がある
    • 「mainブランチにマージした後dev環境で動かす」という落とし所はある程度よいが、「たまたまデータが揃ってる環境だからE2Eが通っている」だと片手落ち

Appendix / テストツールについて

  • 少なくとも最近のフロントエンドのエコシステムは、昔言われてたほど不安定ではない印象
  • ただ、そもそも画面に関わらない箇所はバックエンドで保証した方が圧倒的に楽
    • バックエンドのPython/Djangoのテストはとても安定している
      • Python: unittest or pytest, mock
      • Django: テストクライアント、DB(作成/マイグレーション/テストケースごとのリセット)
      • Django: factory-boy 等のデータ生成ライブラリ
    • それらを活用するノウハウ
      • ツールの改善はあれど、基本的なことは10-15年で変わっていない
  • そのため、ビジネスロジックはバックエンドに書き、単体テストも書き、最低限API通信のレイヤーと分離していれば無難な構成にできる
    • まあそのAPIに密結合な形でビジネスロジックを書かれたりするとアレなんだけど...

Appendix / SPAについて

  • SPAが落ち着き始め、揺り戻し気味に最近はHTMXやHotwire等の話がある
    • 既に大きめのサーバーサイドテンプレートのアプリなら導入するのはわかるが
  • 個人的には、SPAだと比較的簡単にVRTが導入できるというメリットがかなり大きい
    • Playwrightや外部E2EサービスでもVRTできるようなので、それで保証できるならいいんだけど...もろもろコスト高くないか?ほんとにできる?
  • またアーキテクチャについて、エンジニア的には画面の複雑性(=JS)の話がよくされるが、個人的にはHTML/CSSを完全に別管理できるのが大きい
    • コンポーネントで簡単に名前空間を分離できる
    • サーバーサイドのWebフレームワークのテンプレート等の機能を使わないようにできる
  • SPA構成の場合フロントエンドのコードの中でMVC(MVVM等)はあるが、とはいえアプリ全体でみると一番外側になる。外側のレイヤーとしては責務を薄くしたい
  • 「無理にSPAにしなくてもいいじゃん」という流れもまあわかるが、サーバーサイドとフロントエンド完全分離できるメリットは、サーバーサイド側にも大きい
    • 単体テストを書いていればサーバー側のライブラリ更新等がかなり楽
    • 画面関係の実装がない分依存が減る
    • 運用や工数の妥協がしやすい管理画面系をサーバー側のWebフレームワークの機能で生成するとよい
    • 初期構築の重さを挙げられることがあるが、SPA案件も相当数ある現状、scaffoldとなるコードはあるでしょう

VRTを実現するためのコード

概要

  • サーバー:
    • APIで使用するモデル/API等を定義
    • テストに使用するデータ生成処理を定義
      • 主に factory-boy
    • テストクライアントを使用し、JSONレスポンスを生成
  • フロントエンド:
    • API mock ライブラリとして msw を使用
  • その他:
    • Chromatic のサービス登録 / GitHub アクセス権を付与
      • Storybook 関連のファイルを整備
    • CIでのアップロード処理
      • サーバーサイドのデータ作成スクリプトでデータを生成
      • Djangoのテストクライアントを使用し、Storybookで使用するレスポンスのJSONを生成
      • JSONファイルをStorybookで読み込める箇所に配置
      • Chromatic提供のツールでサービスにアップロード

コード

ファクトリの定義

import typing

import factory.fuzzy

from . import core


class AuthUserFactory(factory.django.DjangoModelFactory):
    email = core.FuzzyEmail()
    username = factory.fuzzy.FuzzyText(prefix="username_")
    password = factory.Faker("password")
    is_active = True
    is_staff = core.FuzzyBoolean()
    is_superuser = core.FuzzyBoolean()

    class Meta:
        model = "account.AuthUser"

    @classmethod
    def _after_postgeneration(cls, instance: create, results=None):
        instance.username = instance.username.replace(" ", "")
        instance.set_password(instance.password)
        instance.save()
        return instance

データ生成

import json
from pathlib import Path

from django.test import Client
from django.urls import reverse

from .. import factories as f

DEV_PASSWORD = "xxxxxxxx"


class StorybookMaker:
    def __init__(self):
        self.account = None

    def make(self):
        self._make_account()

    def _make_account(self):
        for n, name in enumerate(["aaa", "bbb", "ccc"], start=1):
            f.XXXAccountFactory(
                name=f"{name}",
                auth_user__username=f"xxxx{n}",
                auth_user__email=f"xxxx{n}@localhost.test",
                auth_user__password=DEV_PASSWORD,
            )


class StorybookJSONMaker:
    def __init__(self, fixture_path, account):
        self.fixture_path = fixture_path
        self.account = account
        self.token = account.token

        self._remove_all_json()

    def _remove_all_json(self):
        [path.unlink() for path in Path(self.fixture_path).glob("*.json")]

    def make(self):
        self._create_json(
            api_path=reverse("api:xxxx_login"),
            params={
                "username": self.account.auth_user.username,
                "password": DEV_PASSWORD,
            },
            json_name="login",
            method="POST",
        )
        self._create_json(
            api_path=reverse("api:master"),
            params={},
            json_name="master",
            method="GET",
        )
        self._create_json(
            api_path=reverse("api:for_storybook"),
            params={},
            json_name="forStorybook",
            method="GET",
        )

    def _create_json(self, api_path, params, json_name, method):
        client = Client()
        match method:
            case "GET":
                res = client.get(
                    api_path,
                    params,
                    **{"HTTP_X-XXX": self.token},
                )
            case "POST":
                res = client.post(
                    api_path,
                    json.dumps(params),
                    **{"HTTP_X-XXX": self.token},
                    content_type="application/json",
                    format="json",
                )
            case _:
                assert False

        with open(f"{self.fixture_path}/{json_name}.json", mode="w") as fp:
            data = json.loads(res.content.decode("utf-8"))
            fp.write(json.dumps(data, indent=2))
def make_storybook(fixture_path: str):
    """Storybook用のデータ作成"""
    if not os.environ["DJANGO_DATABASE_URL"] == "sqlite:///:memory:":
        raise RuntimeError("環境変数[DJANGO_DATABASE_URL]は、[sqlite:///:memory:]を指定して実行してください.")
    call_command("migrate", "--noinput")

    MasterMaker.make()

    storybook_maker = StorybookMaker()
    storybook_maker.make()

    storybook_json_maker = StorybookJSONMaker(fixture_path=fixture_path, account=storybook_maker.account)
    storybook_json_maker.make()
on:
  pull_request:
  workflow_call:
env:
  DJANGO_DATABASE_URL: "sqlite:///:memory:"
jobs:
  vrt_data:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: abatilo/actions-poetry@v2
        with:
          poetry-version: 1.8.4
      - uses: actions/setup-python@v5
        with:
          python-version: 3.13
          cache: poetry
      - run: poetry install --no-interaction
        working-directory: backend

      - run: mkdir -p /tmp/fixtures
      - run: poetry run python src/manage.py shell -c "import dev;dev.make_storybook('/tmp/fixtures')"
        working-directory: backend
      - uses: actions/upload-artifact@v4
        with:
          name: fixtures
          path: /tmp/fixtures/*.json
          retention-days: 1

  vrt_chromatic:
    runs-on: ubuntu-latest
    needs:
      - vrt_data
    steps:
      - uses: actions/checkout@v4
        # Chromaticではshallow cloneではなく完全な履歴が必要
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: "20.10"
      - run: npm install
        working-directory: frontend

      - uses: actions/download-artifact@v4
        with:
          name: fixtures
          path: frontend/.storybook/fixtures
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          workingDir: frontend

App.stories.ts

import {Meta, StoryObj} from '@storybook/vue3'
import {watch} from 'vue'

import App from '@/App.vue'
import {viewports} from '@/stories/vrt'

const meta = {
  title: 'App',
  component: App,
} satisfies Meta<typeof App>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  name: 'デフォルト',
  parameters: {
    chromatic: {
      viewports,
    },
    msw: {
      handlers: [],
    },
  },
  render: () => ({
    components: {App},
    template: '<App ref="component" />',
    setup() {},
    async mounted() {},
  }),
}

export const InputCleared: Story = {
  name: '入力クリア(サンプル)',
  parameters: {
    chromatic: {
      viewports,
    },
  },
  render: () => ({
    components: {App},
    template: '<App ref="component" />',
    setup() {},
    async mounted() {
      // サンプル実装
      const component = this.$refs.component
      // 値を強制的に変更するサンプル
      const unwatch = watch(() => component.main.state.sampleResponse, (_) => {
        component.main.clear()
        unwatch()
      })
    },
  }),
}

mockResponses.ts

// eslint-disable-next-line
// @ts-nocheck
import { http, HttpResponse } from 'msw'

import type { paths } from '@/repositories/gen/schema.d.ts'
import account from '@/../.storybook/fixtures/account.json'
import forStorybook from '@/../.storybook/fixtures/forStorybook.json'

const urls: Record<string, paths> = {
  forStorybook: '/api/for_storybook',
  account: '/api/account',
}

export const mockResponses = [
  http.get(urls.forStorybook, () => {
    return HttpResponse.json(forStorybook)
  }),
  http.get(urls.account, () => {
    return HttpResponse.json(account)
  }),
]

.storybook/main.ts

import type { StorybookConfig } from '@storybook/vue3-vite'

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
  ],
  framework: {
    name: '@storybook/vue3-vite',
    options: {},
  },
  staticDirs: ['./public'],
}
export default config
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment