Help us understand the problem. What is going on with this article?

[Vue] Bulma CSS の Pagination を動くようにしてから Jest のテストを追加するまで

はじめに

Nuxt 2.9 リリースされましたね :tada:
TypeScript が正式に分離された、ということでそろそろ TypeScript 環境も整ったのだろうと思い、ようやっと重い腰を上げて、Nuxt + TypeScript で何かしら作ろうと画策してます。(結果として、今の時期にこの構成に飛び付くのは(自分には)焦燥だった感あるけど、それは置いといて...)

今回は、Bulma CSS の Pagination を動かして、それに Jest でのテストを加えてみました。

やったこと

  • Bulma CSS のページネーションを動くようにする
    • 既存でレポジトリも見つかったけど勉強がてら車輪
  • Jest でのテストをする
    • vue コンポーネントのユニットテストはやったことなかったので...

環境

package.json ↓ (いらないものが色々入ってますが、 create-nuxt-app した大体直後です。 )

  "dependencies": {
    "@nuxt/typescript-runtime": "^0.1.3",
    "@nuxtjs/axios": "^5.3.6",
    "@nuxtjs/bulma": "^1.2.4",
    "@nuxtjs/font-awesome": "^1.0.4",
    "nuxt": "^2.0.0",
    "vue-property-decorator": "^8.2.1"
  },
  "devDependencies": {
    "@nuxt/typescript-build": "^0.1.11",
    "@nuxtjs/eslint-config": "^1.0.1",
    "@nuxtjs/eslint-config-typescript": "^0.1.2",
    "@nuxtjs/eslint-module": "^1.0.0",
    "@nuxtjs/tailwindcss": "^1.0.0",
    "@types/jest": "^24.0.18",
    "@vue/test-utils": "^1.0.0-beta.27",
    "babel-eslint": "^10.0.1",
    "babel-jest": "^24.1.0",
    "eslint": "^6.1.0",
    "eslint-plugin-nuxt": ">=0.4.2",
    "jest": "^24.1.0",
    "ts-jest": "^24.0.2",
    "vue-class-component": "^7.1.0",
    "vue-jest": "^4.0.0-0"
  }

できたもの

pagination.gif

テンプレート

シンプルです。
公式の Pagination をコピペ -> prev, next で click イベント待機させる。 -> link を v-for から動的に作るようにする。

... の部分はページネーション実装する時にいつも思いますが、めんどいですね。今回は v-if, else で分岐させました。

<template>
  <div>
    <nav class="pagination" role="navigation" aria-label="pagination">
      <a class="pagination-previous" @click="prev">Previous</a>
      <a class="pagination-next" @click="next">Next page</a>
      <ul class="pagination-list">
        <li v-for="page in pages" :key="page.number">
          <a
            v-if="page.isLink"
            class="pagination-link"
            :class="{ 'is-current': page.isCurrent }"
            :aria-label="`Goto page ${page.number}`"
            @click="syncedCurrent = page.number"
          >{{ page.number }}</a>
          <span v-else class="pagination-ellipsis">&hellip;</span>
        </li>
      </ul>
    </nav>
  </div>
</template>

スクリプト

TypeScript で書きました。 ( Class API )
まずはざっと

<script lang="ts">
import { Vue, Component, Prop, PropSync } from 'vue-property-decorator'

interface Page {
  number: Number | null
  isCurrent: boolean
  isLink: boolean
}

const MIN_CURRENT = 1
const VIEW_NUM = 3
const MAX_TOTAL = 100

@Component
class Pagination extends Vue {
  @PropSync('current', { type: Number, default: MIN_CURRENT }) syncedCurrent!: number
  @Prop({ default: MAX_TOTAL }) readonly total!: number
  @Prop({ default: VIEW_NUM }) readonly viewNum!: number

  prev (): void {
    if (this.syncedCurrent === MIN_CURRENT) {
      return
    }
    this.syncedCurrent--
  }
  next (): void {
    if (this.syncedCurrent === this.total) {
      return
    }
    this.syncedCurrent++
  }

  get pages (): Array<Page> {
    const numbers: Array<Number|null> = []

    numbers.push(MIN_CURRENT)
    if (this.syncedCurrent >= (MIN_CURRENT + VIEW_NUM)) {
      numbers.push(null)
    }

    if ((this.syncedCurrent - this.viewNum + 1) <= 0) {
      numbers.push(2)
      numbers.push(3)
    } else if ((this.syncedCurrent + this.viewNum - 1) > this.total) {
      numbers.push(98)
      numbers.push(99)
    } else {
      numbers.push(this.syncedCurrent - 1)
      numbers.push(this.syncedCurrent)
      numbers.push(this.syncedCurrent + 1)
    }

    if (this.syncedCurrent <= (this.total - this.viewNum)) {
      numbers.push(null)
    }
    numbers.push(this.total)

    return numbers.map(number => ({
      number,
      isCurrent: this.syncedCurrent === number,
      isLink: number !== null
    }))
  }
}

export default Pagination
</script>

詰まっていたのはこの辺

  • enum 使ったらデフォルトの eslint に死ぬほど怒られて半日ぐらい過ぎる
    • デコレーターが eslint に死ぬほど怒られ...
    • readonly が @Componrnt が ...
  • export default class Pagination extends... の書き方が怒られた。
    • export を分離で回避
  • @PropSync で宣言した syncedCurrent が更新されない。
    • update:{prop} をやって親に知らせる都合上か、親側で *.sync プロパティを渡していないと更新されない模様

わかったのはこの辺

  • 型安全なのは強い(VSCodeとの親和性がよくてしょうもない間違いを防げる)
  • アノテーションで Vue ライフサイクルの定義ができるのはスマート

Jest テスト

今回はすごく単純なところで、prop として親から子に渡した場合の動的な html 生成部分を担保しました。

import 'jest'
import { shallowMount } from '@vue/test-utils'
import Pagination from '@/components/Pagination.vue'

const linkFactory = (number, isCurrent) => {
  if (number === null) {
    return '<li><span class="pagination-ellipsis">…</span></li>'
  }
  return `<li><a aria-label="Goto page ${number}" class="pagination-link${isCurrent ? ' is-current' : ''}">${number}</a></li>`
}

describe('Pagination', () => {
  it('renders default', () => {
    const wrapper = shallowMount(Pagination)
    expect(wrapper.html()).toContain([
      linkFactory(1, true),
      linkFactory(2, false),
      linkFactory(3, false),
      linkFactory(null, false),
      linkFactory(100, false)
    ].join(''))
  })

  const dataProviderList = [
    {
      current: 1, numbers: [1, 2, 3, null, 100]
    },
    {
      current: 2, numbers: [1, 2, 3, null, 100]
    },
    {
      current: 3, numbers: [1, 2, 3, 4, null, 100]
    },
    {
      current: 4, numbers: [1, null, 3, 4, 5, null, 100]
    },
    {
      current: 50, numbers: [1, null, 49, 50, 51, null, 100]
    },
    {
      current: 97, numbers: [1, null, 96, 97, 98, null, 100]
    },
    {
      current: 98, numbers: [1, null, 97, 98, 99, 100]
    },
    {
      current: 99, numbers: [1, null, 98, 99, 100]
    },
    {
      current: 100, numbers: [1, null, 98, 99, 100]
    }
  ]
  dataProviderList.forEach(({ current, numbers }) => {
    it(`current ${current}`, () => {
      const wrapper = shallowMount(Pagination, {
        propsData: {
          current
        }
      })
      expect(wrapper.html()).toContain(numbers.map(
        (number) => {
          return linkFactory(number, current === number)
        }).join(''))
    })
  })
})

詰まったのは以下

  • *.spec.ts としてファイル作成したもののコマンドに引っかからなかった
    • 公式には、package.json いじるといけるよとか書いてて設定の仕方がどうも間違ってるっぽい
    • テストだしいっかと思って *.js にした
  • a タグに対する trigger('click') の反応がなかった
    • PropSync が影響してるのか, a タグだからなのか ... 今回は必須項目としては除外した(けど, ちゃんとしたやつならやるべきところではあるのでいずれ解決したい)

終わり

「作りたいもの」に対して「やりたいこと」が先行して、あっちゃこっちゃへと遠回りしてる気がします。
とはいえ、得られたものも多く、TypeScript 楽しいなと思えました。
(Nuxt + TypeScript は発展途上感満載なので今飛び込むのは趣味にとどめておいたほうが良さそうですね)

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away