GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

ダッシュボード (Vue.js, Composition API) とテストコード #GameWith #TechWith

はじめに

こんにちは。GameWith のエンジニアの tiwu です!

このブログはアドベントカレンダーの20日目のブログになります!

qiita.com

今回は Vue.js, Typescript で開発しているダッシュボードのテストコードについて書いていきたいと思います!

導入についてはこちらの記事を御覧ください。

tech.gamewith.co.jp

サンプルページ

サンプルページのコードと共に解説していこうと思います。

例としてユーザーの一覧と検索機能を持ったダッシュボードの場合、下記のような構成になります。

- src
  - components
    - pages
      - user
        - list.vue
  - composition
    - user
      - use-list.ts

composition-api.vuejs.org

composition フォルダには Composition API RFC を参考にロジック部分を分けています。

src/components/pages/user/list.vue

検索機能とユーザー一覧を表示するページのサンプルコードです。

コンポーネントライブラリとして BootstrapVue を利用しています 👍

bootstrap-vue.org

表示するデータや取得処理は Composition API 経由で、検索は URL パラメータを書き換え更新することで検索を実現させています。

The サンプルコードと言った感じのシンプルな作りになっています。

<template>
  <div>
    <label for="input-userId">ユーザID</label>
    <b-form-input
      id="input-userId"
      type="number"
      :state="userIdModel.state"
      v-model.number="userIdModel.input"
    >
    </b-form-input>
    <b-button @click="submitSearch" variant="outline-primary">検索</b-button>
    <b-button @click="resetSearch" variant="outline-secondary">リセット</b-button>

    <b-pagination-nav :link-gen="linkGen" :number-of-pages="numberOfPage"></b-pagination-nav>
    <b-table :items="users">
      // 省略
    </b-table>
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive, toRefs, getCurrentInstance } from '@vue/composition-api';
import { useList } from '@/composition/user/use-list';

export default defineComponent({
  setup() {
    const root = getCurrentInstance();
    const reactiveData = reactive({
      page: Number(root.$route.query.page || '1'),
      userIdModel: {
        key: 'userId',
        input: root.$route.query.userId ? Number(root.$route.query.userId) : null,
        state: null,
      },
    });

    const {
      fetchUserList,
      users,
      numberOfPage
    } = useList();

    const linkGen = (pageNum: number): string => {
      return `?page=${pageNum}` + '&' + buildQuery();
    };

    const submitSearch = (): void => {
      const query = buildQuery();
      window.location.href = '/list?' + query;
    };

    const resetSearch = (): void => {
      window.location.href = '/list?';
    };

    (async(): Promise<void> => {
      await fetchUserList(
        reactiveData.page,
        Number(userIdModel.input)
      );
    })();

    return {
      ...toRefs(reactiveData as any),
      users,
      numberOfPage,
      linkGen,
      submitSearch,
      resetSearch,
    };
  }
});
</script>

src/composition/users/use-list.ts

Composition API 側も至ってシンプルで、API 経由でデータを取得する関数と、ユーザー情報を持つ state を管理しています。

API のリクエストは aspida を利用しています。詳しくはこちら 👍

tech.gamewith.co.jp

import { reactive, toRefs } from '@vue/composition-api';
import { RepositoryFactory } from '@/repositories/repository-factory';

export interface User {
  id: number;
  name: string;
}

export interface Users {
  users: User[];
  numberOfPage: number;
}

export const useList = () => {
  const state = reactive<Users>({
    users: [],
    numberOfPage: 1
  });
  const DashboardRepository = RepositoryFactory.getDashboardRepository();

  const fetchUserList = async(
    page: number,
    userId?: number,
  ): Promise<void> => {
    const response = await DashboardRepository.api.user.$get({
      query: {
        page: page,
        user_id: userId,
      }
    });
    state.users = [];
    for (const user of response.users) {
      state.users.push({
        id: user.id,
        name: user.name,
      });
    }
    state.numberOfPage = response.number_of_page;
  };

  return {
    ...toRefs(state),
    fetchUserList
  };
};

テストについて

テストは Jest を利用しています。

Components のテスト

コンポーネントライブラリとして BootstrapVue を採用しているので、まずその設定を行います。

shallowMount() では BootstrapVue のコンポーネントをレンダリングしないので mount() を利用しています

ユーザーの情報をモックを利用して定義し、スナップショットテストを実行します。

import { createLocalVue, mount } from '@vue/test-utils';
import VueCompositionApi from '@vue/composition-api';
import { default as BootstrapVue, IconsPlugin } from 'bootstrap-vue';
import List from '@/components/pages/user/list.vue';
import * as useList from '@/composition/user/use-list';

const localVue = createLocalVue();
localVue.use(VueCompositionApi);
localVue.use(BootstrapVue);
localVue.use(IconsPlugin);

const mockUseList = jest.spyOn(useList, 'useList');

describe('list.vue', () => {
  it('ユーザー一覧', () => {
    const mockValue: useList.Users = {
      numberOfPage: 1,
      users: [
        {
          id: 1,
          name: 'user 1'
        },
        {
          id: 2,
          name: 'user 2'
        }
      ],
    };
    mockUseList.mockReturnValue({
      fetchUserList: jest.fn(),
      numberOfPage: mockValue.numberOfPage as any,
      users: mockValue.users as any,
    });

    const $route = {
      query: {
        page: 1,
      },
    };

    const wrapper = mount(List, {
      localVue,
      mocks: {
        $route,
      },
      stubs: {
        transition: false
      },
    });
    expect(wrapper.html()).toMatchSnapshot();
  });
});

Composition のテスト

aspida のブログでも触れてましたが、aspida のモックを作りテストを書いています。

import { createLocalVue } from '@vue/test-utils';
import VueCompositionApi from '@vue/composition-api';
import { useList, User } from '@/composition/user/use-list';
import { DashboardRepository } from '@/repositories/dashboard/repository';

const localVue = createLocalVue();
localVue.use(VueCompositionApi);

type UserListType = ReturnType<typeof DashboardRepository.api.user.$get> extends Promise<infer T> ? T : never;
const mockUserList = jest.spyOn(DashboardRepository.api.user, '$get');

describe('use-list.ts', () => {
  describe('fetchUserList', () => {
    it('ユーザー一覧の取得', async() => {
      const mockValue: UserListType = {
        number_of_page: 1,
        users: [
          {
            id: 1,
            name: 'user 1'
          },
          {
            id: 2,
            name: 'user 2'
          }
        ]
      };
      mockUserList.mockReturnValue(mockValue as any);

      const {
        fetchUserList,
        users,
        numberOfPage
      } = await useList();

      expect(users).toEqual([]);

      await fetchUserList(1);

      expect(numberOfPage.value).toEqual(mockValue.number_of_page);
      expect(users[0]).toEqual(mockValue.users[0]);
    });
  });
});

小ネタ

検索の箇所に出てきた window.location.href のテストも書いていきます

const submitSearch = (): void => {
  const query = buildQuery();
  window.location.href = '/list?' + query;
};

const resetSearch = (): void => {
  window.location.href = '/list?';
};

こちらを参考にテストを書いていきます 👍

stackoverflow.com

const buildQuery = (): string => {
  return 'username=hoge&userId=1';
};

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: ''
  }
});

expect(window.location.href).toEqual('');
submitSearch();
expect(window.location.href).toEqual('/list/?username=hoge&userId=1');
resetSearch();
expect(window.location.href).toEqual('/list');

終わりに

テストできなさそうな window.location.href も意外とテストできたりするのは驚きました!

これからもどしどしテスト書いていきましょう!

Twitter

GameWithのDeveloper向けTwitterアカウントも開設しました。
ブログの更新情報などを発信するので良かったらフォロー宜しくお願いします!

twitter.com