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

【Vue.js】【TDD】QiitaAPIを使って投稿の詳細を表示するコンポーネントをTDDで作ってみました。[PART 1/2]

More than 1 year has passed since last update.

何を作る?

このアプリケーションをTDDで開発します。Qiitaの投稿IDを入力すると、ユーザー、いいね、などを表示します。ライブバージョンはここにあるので試してみてください。

ソースコードはこちらです。

なぜTDD?(テスト駆動開発)

エンジニアは、作るものを正しく行動するためにテストします。例:

  • 車を製造するエンジニアは安全のためにブレーキをテストします
  • 充電をデザインエンジニアはシミュレーションで爆発するかテストします
  • プログラムを書く人は・・・とりあえず使ってみてどうなるかみてみようかな?

「使ってみる」はいいテストする方法ではないです。コントロールできる環境で確認した方が安全です。

環境設定

vue-cliをインストールします。

yarn global add @vue/cli

そして新しいプロジェクトを作ります:

vue create qiita_component

オプションはbabeljest

条件

  • idをテキストボックスに入力する
  • idでapiを叩く
  • レスポンスを表示する

この3つの条件をそれぞれのコンポーネントに分けます。

  • idを受け取る -> PostForm.vue
  • apiを叩く -> PostContainer.vue
  • レスポンスを表示する -> PostDisplay.vue

さらに、コンポーネントの役割を考えてみましょう。

  • PostForm.vue

    • ユーザーの入力を受け取る
    • 入力をPostContainer.vueに渡す
  • PostDisplay.vue

    • データを受け取って(propsとして?)
    • データを表示する
  • PostContainer.vue

    • PostForm.vueからidを受け取る
    • apiを叩く
    • レスポンスをPostDisplay.vueに渡す(propsがいいかも)

PostContainerのノートを見ると、PostFormPostDisplayも入っているので、この構成がいいとわかります:

<PostContainer>
  <PostForm />     入力を`$emit`でPostContainerに渡す
  <PostDisplay />  `props`として投稿のデータを受け取る
</PostContainer>

この記事でPostForm.vuePostDisplay.vueを作ります。次の記事でPostContainer.vueを作ります。

何をどこでやるを決めたので、開発しましょう。

PostForm.vue

テストファイルを作ります。

touch tests/unit/PostForm.spec.js

とりあえず、PostFormをレンダーしてみます。

import { shallowMount } from "@vue/test-utils"
import PostForm from "../../src/PostForm.vue"

describe("PostForm", () => {
  it("renders", () => {
    const wrapper = shallowMount(PostForm)

    console.log(wrapper.html())
  })
})

shallowMountを使ってコンポーネントをレンダーします。yarn test:unitを実行して、エラーに合わせて開発しましょう。

Cannot find module '../../src/PostForm.vue' from 'PostForm.spec.js'

  1 | import { shallowMount } from "@vue/test-utils"
> 2 | import PostForm from "../../src/PostForm.vue"
    | ^
  3 |
  4 | describe("PostForm", () => {
  5 |   it("renders", () => {

まだ作成してないので、importもできないですね。作成します:

touch src/PostForm.vue

テストを実行したら、別のエラーができます。

[Vue warn]: Failed to mount component: template or render function not defined.

templateがないので、template or render function not definedが出ました。templateを作ります。

<template>
  <div class="wrapper">
  </div>
</template>

エラーがなくなりました。

 PASS  tests/unit/PostForm.spec.js
  PostForm
    ✓ renders (39ms)

上に書いてある条件を参考します:

  • ユーザーの入力を受け取る
  • 入力をPostContainer.vueに渡す

2つのテストが必要です。

入力を受け取るテスト

テストを書きましょう。ユーザーから入力を受け取って、dataにある変数にアサインしたいです。

  • ユーザーの入力をsetValueでシミュレーションできます
  • inputで入力を受け取る(当たり前)

その2つのことを考えながらテストを書いてみましょう。

import { shallowMount } from "@vue/test-utils"
import PostForm from "../../src/PostForm.vue"

describe("PostForm", () => {
  // ...

  it("ユーザーから入力を受け取る", () => {
    const wrapper = shallowMount(PostForm, {
      data() {
        return { postId: '' }
      }
    })

    wrapper.find("input").setValue("123")

    expect(wrapper.vm.postId).toBe("123")
  })
})

実行して:

[vue-test-utils]: find did not return input, cannot call setValue() on empty Wrapper

inputは存在してないので、findができません。作成します:

<template>
  <div class="wrapper">
    <input />
  </div>
</template>

別のエラーが出ます:

expect(received).toBe(expected) // Object.is equality

    Expected: "123"
    Received: undefined

undefinedとなっているので、dataに追加します。

<template>
  <div class="wrapper">
    <input />
  </div>
</template>

<script>
export default {
  data() {
    return { postId: "" }
  }
}
</script>
expect(received).toBe(expected) // Object.is equality

    Expected: "123"
    Received: ""

""になりました。最後にv-modelでバインドします。

<template>
  <div class="wrapper">
    <input v-model="postId" />
  </div>
</template>

<script>
export default {
  data() {
    return { postId: '' }
  }
}
</script>
 PASS  tests/unit/PostForm.spec.js
  PostForm
    ✓ renders (39ms)
    ✓ ユーザーから入力を受け取る (25ms)

入力したデータを親に$emitする

ユーザーが入力して、エンターを押すと投稿IDをペイロードとしてイベントを$emitしたいです。そうすると、親のPostContainerはその入力したデータでAPIを叩いて、レスポンスをもらいます。

  • triggerでエンターキーをシミュレーションができます。
  • emittedでイベントを$emitしたか確認できます。

先にテストを書きます。

import { shallowMount } from "@vue/test-utils"
import PostForm from "../../src/PostForm.vue"

describe("PostForm", () => {
  // ...

  it("エンターキーを押すとsubmitイベントを$emit", () => {
    const wrapper = shallowMount(PostForm)

    wrapper.find("input").setValue("123")
    wrapper.find("input").trigger("keyup.enter")

    expect(wrapper.emitted("submit")[0][0]).toBe("123")
  })
})

emittedのシンタックスがちょっとわかりづらいです。console.log(wrapper.emitted())で遊んでみた方がわかりやすいと思います。ドキュメントを読むことも良いです

テストを実行します:

TypeError: Cannot read property '0' of undefined

なぜかというと、イベントを$emitしていないので、emitted()undefined。エンターキーに@keyup.enterを追加して、$emitしてみます。

<template>
  <div class="wrapper">
    <input v-model="postId" @keyup.enter="handleSubmit" />
  </div>
</template>

<script>
export default {
  data() {
    return { postId: '' }
  },

  methods: {
    handleSubmit() {
      this.$emit("submit")
    }
  }
}
</script>

別のエラーが出ます。

Expected: "123"
    Received: undefined

イベントを$emitしているが、ペイロードはまだundefined。追加してみます:

<template>
  <div class="wrapper">
    <input v-model="postId" @keyup.enter="handleSubmit" />
  </div>
</template>

<script>
export default {
  data() {
    return { postId: '' }
  },

  methods: {
    handleSubmit() {
      this.$emit("submit", this.postId)
    }
  }
}
</script>
 PASS  tests/unit/PostForm.spec.js
  PostForm
    ✓ renders (39ms)
    ✓ ユーザーから入力を受け取る (25ms)
    ✓ エンターキーを押すとsubmitイベントを$emit (4ms)

成功です!全部の条件を完了させたので、進みます。

PostDisplay

PostDisplayは:

  • データを受け取って(propsとして?)
  • データを表示する

新しいテストファイルとコンポーネントを作成します:

touch src/PostDisplay.vue && touch tests/unit/PostDisplay.spec.js

進む前に、「データ」について調べてみましょう。このエンドポイントを使います。とりあえずブラウザのコンソールで使ってみます。

fetch("https://qiita.com/api/v2/items/229a4f15b99a19f94b76")
.then(data => data.json())
.then(json => console.log(Object.keys(json)))


//=> ["rendered_body", "body", "coediting", "comments_count", "created_at", "group", "id", "likes_count", "private", "reactions_count", "tags", "title", "updated_at", "url", "user", "page_views_count"]

プロパティが16個あります。表示したいものは

  • likes_count
  • title
  • user
  • bodyの最初の2つの文章

一個ずつ props として受け取ってレンダーします。bodyだけをちょっと処理してからレンダーします。

テストを書きます:

import { shallowMount } from "@vue/test-utils"
import PostDisplay from "../../src/PostDisplay.vue"

describe("PostDisplay", () => {
  it("renders", () => {
    const wrapper = shallowMount(PostDisplay)
  })
})

そしてyarn test:unit:

[Vue warn]: Failed to mount component: template or render function not defined.

PostDisplay.vueにまだ何も書いてないので失敗します。書きましょう:

<template>
  <div>
  </div>
</template>

<script>
export default {

}
</script>

エラーがなくなりました。次は、postオブジェクトを作って、propsDataで受け取ります。vue-test-utilspropsをコンポーネントに渡したいときにpropsDataを使います。ここに参考してください

テストを書きましょう。テストのためにdata-test-属性を使います。普通のHTMLクラスでも大丈夫です。

import { shallowMount } from "@vue/test-utils"
import PostDisplay from "../../src/PostDisplay.vue"

describe("PostDisplay", () => {
  const post = {
    title: "タイトル",
    likesCount: 10,
    user: "webpack_master",
    body: "これは投稿です。テスト駆動開発。楽しいです。"
  }

  it("renders", () => {
    const wrapper = shallowMount(PostDisplay, {
      propsData: {
        title: post.title,
        likesCount: post.likesCount,
        user: post.user,
        pageViewsCount: post.pageViewsCount,
        body: post.body
      }
    })

    expect(wrapper.find("[data-test-title]").text()).toBe("タイトル:" + post.title)
    expect(wrapper.find("[data-test-likesCount]").text()).toBe("いいね:" + post.likesCount)
    expect(wrapper.find("[data-test-user]").text()).toBe("ユーザー:" + post.user)
    expect(wrapper.find(".post-body").text()).toBe("これは投稿です。テスト駆動開発。")
  })
})

bodyだけのためにclass="body"を使います。特に理由がないですが、ただ例をあげたいです。findquerySelectorと同じシンタックスを追加います。ソースコードを見るとquerySelectorを使っているかわかります。

実行すると:

FAIL  tests/unit/PostDisplay.spec.js
● PostDisplay › renders

  [vue-test-utils]: find did not return [data-test-title], cannot call text() on empty Wrapper

    21 |     })
    22 |
  > 23 |     expect(wrapper.find("[data-test-title]").text()).toBe("タイトル:" + post.title)

data-test-titleは存在していないです。同時にdata-test属性を全部追加します。

<template>
  <div>
    <div data-test-title>
    </div>
    <div data-test-likesCount>
    </div>
    <div data-test-user>
    </div>
    <div class="post-body">
    </div>
  </div>
</template>

<script>
export default {

}
</script>

また実行します:

FAIL  tests/unit/PostDisplay.spec.js
● PostDisplay › renders

  expect(received).toBe(expected) // Object.is equality

  Expected: "タイトル:タイトル"
  Received: ""

    21 |     })
    22 |
  > 23 |     expect(wrapper.find("[data-test-title]").text()).toBe("タイトル:" + post.title)

propsとして受け取って、レンダーしてみましょう。

<template>
  <div>
    <div data-test-title>
      タイトル:{{ title }}
    </div>
    <div data-test-likesCount>
    </div>
    <div data-test-user>
    </div>
    <div class="post-body">
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      required: true
    }
  }
}
</script>

また実行します:

FAIL  tests/unit/PostDisplay.spec.js
● PostDisplay › renders

  expect(received).toBe(expected) // Object.is equality

  Expected: "いいね:10"
  Received: ""

    22 |
    23 |     expect(wrapper.find("[data-test-title]").text()).toBe("タイトル:" + post.title)
  > 24 |     expect(wrapper.find("[data-test-likesCount]").text()).toBe("いいね:" + post.likesCount)

likesCountまで進みました。post.titleはちゃんとレンダーしています。残りのpropsを同じように書いてみます:

<template>
  <div>
    <div data-test-title>
      タイトル:{{ title }}
    </div>
    <div data-test-likesCount>
      いいね:{{ likesCount }}
    </div>
    <div data-test-user>
      ユーザー:{{ user }}
    </div>
    <div class="post-body">
      {{ body }}
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      required: true
    },

    likesCount: {
      type: Number,
      required: true
    },

    user: {
      type: String,
      required: true
    },

    body: {
      type: String,
      required: true
    },
  }
}
</script>

likesCountpageViewsCountNumberを追加しました。

どうなりました?

expect(received).toBe(expected) // Object.is equality

Expected: 10
Received: "10"

Difference:

  Comparing two different types of values. Expected number but received string.

  23 |
  24 |     expect(wrapper.find("[data-test-title]").text().includes(post.title)).toBe(true)
> 25 |     expect(wrapper.find("[data-test-likesCount]").text().includes(post.likesCount)).toBe(true)

あ、残念ながらNumberをレンダーするとStringとなります。別のやり方はString.includesで検証します。更新します:

import { shallowMount } from "@vue/test-utils"
import PostForm from "../../src/PostForm.vue"

describe("PostForm", () => {
  const post = {
    title: "タイトル",
    likesCount: 10,
    user: "webpack_master",
    body: "これは投稿です。テスト駆動開発。楽しいです。"
  }

  it("renders", () => {
    const wrapper = shallowMount(PostForm, {
      propsData: {
        title: post.title,
        likesCount: post.likesCount,
        user: post.user,
        body: post.body
      }
    })

    expect(wrapper.find("[data-test-title]").text().includes(post.title)).toBe(true)
    expect(wrapper.find("[data-test-likesCount]").text().includes(post.likesCount)).toBe(true)
    expect(wrapper.find("[data-test-user]").text().includes(post.user)).toBe(true)
    expect(wrapper.find(".post-body").text().includes("これは投稿です。テスト駆動開発。")).toBe(true)
  })
})

そしてまた実行します:

expect(received).toBe(expected) // Object.is equality

  Expected: "これは投稿です。テスト駆動開発。"
  Received: "これは投稿です。テスト駆動開発。楽しいです。"

投稿のbodyを全部表示したくないです。将来に、このコンポーネントをQiitaのトレンドのようにフィードに使いたいので、ただ最初の2つの文章を表示したいです。この処理をするために、computedプロパティを使えます。

// ...
computed: {
  postSummary() {
    return this.body.split("").slice(0, 2).join("") + ""
  }
}
// ...

そしてマークアップに使う:

<div class="post-body">
  {{ postSummary }}
</div>

テストを実行すると:

Test Suites: 2 passed, 2 total
Tests:       4 passed, 4 total

まとめと次のステップ

この記事で学んだことは: 

  • shallowMountでコンポーネントをレンダーする
  • コードを書く前にテストを書く開発ながれ
  • propsDataを使ってテスト環境をコントロール
  • setValueでユーザーの入力をシミュレーションする
  • triggerでエンターキーのシミュレーションをする
  • emittedでイベントがちゃんと動くか検証すること

次の記事で、PostContainer.vueをTDDで作ります。そのためには

  • axiosを使ってAPIを叩く
  • jest.mockでAPIのレスポンスをモックする
  • stubsでコンポーネントをスタブする
  • existsでコンポーネントをレンダーするか検証する
  • などなど
Why not register and get more from Qiita?
  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