🍣

Vue.jsのslotのおさらいからテストまで

2023/08/21に公開

はじめに

はじめまして、つきやまです。
この記事では Vue.js の slot についてまとめました。
僕自身 slot 機能の存在はもちろん知っていたのですが体系的に学んだことがなかったのでテストまでを備忘録として記事にしました。

対象読者

✅ slot の機能について理解したい方
✅ slot のテストについて理解したい方
❌ 環境構築や Vue.js の基本的な文法についての解説
❌ Vitest の基本的な文法やフロントエンドのテスト手法についての解説

実行環境

  • nuxt ^3.6.5
  • @vue/test-utils ^2.4.0-alpha.2
  • vitest ^0.34.2

slot とは?

slot とは親となるコンポーネント側から、子のコンポーネントのテンプレートの一部を差し込む機能です。

デフォルト slot

components/MyComponent.vue
<template>
  <div>
    <h1>MyComponent</h1>
    <slot />
  </div>
</template>
pages/index.vue
<template>
  <div>
    <MyComponent>
      <p>slot content</p>
    </MyComponent>
  </div>
</template>

🖥 画面出力結果

💁‍♂️ 解説

component テンプレート内の<slot />にコンポーネントを使う側の pages テンプレート内の<MyComponent></MyComponent>の中に書かれている<p>slot content</p>が差し込まれています。

また、component テンプレート内の<slot></slot>の中にデフォルト値を指定できます。

components/MyComponent.vue
<template>
  <div>
    <h1>MyComponent</h1>
    <slot>default</slot> <!-- デフォルト値を設定 -->
  </div>
</template>
pages/index.vue
<template>
  <div>
    <MyComponent /> <!-- コンポーネントのタグ内に何も入っていないのでデフォルト値が表示される -->
  </div>
</template>

🖥 画面出力結果


画面確認するとdefaultが表示されます。

デフォルト値なのでコンポーネントのタグ内に要素を入れるとその要素が差し込まれます。

テストコード

tests/components/MyConponent.spec.ts
import { describe, expect, test } from "vitest"
import { shallowMount } from "@vue/test-utils"
import MyComponent from "@/components/MyComponent.vue"

describe("index", () => {
  test("デフォルトスロットに文字列'test'を渡すと、'<p>'タグの中に'test'がレンダリングされる", () => {
    const wrapper = shallowMount(MyComponent, {
      slots: {
        default: "test" // デフォルトスロットに文字列を渡している
      }
    })
    expect(wrapper.get("p").html()).toContain("test")
  })

  test("デフォルトスロットに何も渡さないと、デフォルト値の'default'がレンダリングされる", () => {
    const wrapper = shallowMount(MyComponent) // デフォルトスロットに何も渡していない
    expect(wrapper.get("p").html()).toContain("default")
  })
})

shallowMountのマウンティングオプションでslots: { default: "test" }のようにデフォルトスロットに文字列を渡しています。
expect()で期待している値とマウントされた値を比較しています。

✅ テスト結果


どちらのテストも通過したことを確認しました。

名前付き slot

components/MyComponent.vue
<template>
  <div>
    <h1>MyComponent</h1>
    <p>name: <slot name="name">名無し</slot></p>
    <p>age: <slot name="age">未設定</slot></p>
  </div>
</template>
pages/index.vue
<template>
  <div>
    <MyComponent>
      <template #name>山田太郎</template>
      <template #age>20</template>
    </MyComponent>
  </div>
</template>

🖥 画面出力結果

💁‍♂️ 解説

使われる側のコンポーネントで<slot name="name">のように name 属性で名前をつけられます。
コンポーネントを使う側でv-slot:nameのように指定するとスロットコンテンツを設定できます。
v-slot:name#nameのように#で省略できます。

テストコード

tests/components/MyConponent.spec.ts
import { describe, expect, test } from "vitest"
import { shallowMount } from "@vue/test-utils"
import MyComponent from "@/components/MyComponent.vue"

describe("index", () => {
  test("nameスロットに文字列'testName'を渡すと、'<p>'タグの中に'testName'がレンダリングされる", () => {
    const result = "testName"
    const wrapper = shallowMount(MyComponent, {
      slots: {
        name: "testName",
      },
    })
    expect(wrapper.find("[data-qa='name']").html()).toContain(result)
  })

  test("ageスロットに文字列'testAge'を渡すと、'<p>'タグの中に'testAge'がレンダリングされる", () => {
    const result = "testAge"
    const wrapper = shallowMount(MyComponent, {
      slots: {
        age: "testAge",
      },
    })
    expect(wrapper.find("[data-qa='age']").html()).toContain(result)
  })

  test("nameスロットに何も渡さないと、デフォルト値の'名無し'が表示される", () => {
    const result = '名無し'
    const wrapper = shallowMount(MyComponent)
    expect(wrapper.find("[data-qa='name']").html()).toContain(result)
  })

  test("ageスロットに何も渡さないと、デフォルト値の'未設定'が表示される", () => {
    const result = "未設定"
    const wrapper = shallowMount(MyComponent)
    expect(wrapper.find("[data-qa='age']").html()).toContain(result)
  })
})

shallowMountのマウンティングオプションでslots: { name: "testName" }のように名前付きスロットに文字列を渡しています。

✅ テスト結果


テストが問題なく通過したことを確認しました。

スコープ付き slot

components/MyComponent.vue
<script setup lang="ts">
const userName = {
  nameEn: "Taro Yamada",
  nameJa: "山田 太郎",
}
</script>

<template>
  <div>
    <h1>MyComponent</h1>
    <p data-qa="name">name: <slot :userName="userName">{{ userName.nameEn }}</slot></p>
  </div>
</template>
pages/index.vue
<template>
  <div>
    <MyComponent>
      <template #default="slotProps">
        {{ slotProps.userName.nameJa }}
      </template>
    </MyComponent>
  </div>
</template>

🖥 画面出力結果

💁‍♂️ 解説

スコープ付きスロットは使われる側のコンポーネントからコンポーネントを使う側にスロットコンテンツの定義に必要なデータを渡すことが可能です。
使われる側のコンポーネント内で<slot :userName="userName">のように v-bind を行います。
コンポーネントを使う側で<template #default="slotProps">のように受け取ります。
slotProps は任意で名前を指定できます。
slotProps という名前は公式ドキュメントに合わせました。

テストコード

tests/components/MyComponent.vue
import { describe, expect, test } from "vitest"
import { shallowMount } from "@vue/test-utils"

const ComponentWithSlots = {
  template: `
    <div>
      <slot v-bind="{ userName }">{{ userName.nameEn }}</slot>
    </div>
  `,
  data() {
    return {
      userName: {
        nameEn: "Taro Yamada",
        nameJa: "山田 太郎",
      },
    }
  },
}

describe("index", () => {
  test("スコープ付きスロットでnameJaが渡されると、'山田 太郎'がレンダリングされる", () => {
    const result = "山田 太郎"
    const wrapper = shallowMount(ComponentWithSlots, {
      slots: {
        default: `
        <template #default="slotProps">
          {{ slotProps.userName.nameJa }}
        </template>
        `,
      },
    })
    expect(wrapper.html()).toContain(result)
  })

  test("スコープ付きスロットで何も渡さないと、デフォルト値の'Taro Yamada'がレンダリングされる", () => {
    const result = "Taro Yamada"
    const wrapper = shallowMount(ComponentWithSlots)
    expect(wrapper.html()).toContain(result)
  })
})

ComponentWithSlotsはスロットを持っているコンポーネントのモックです。モックをマウントしてslots: {default}でコンポーネントを使う側での振る舞いを記載してテストしています。
ComponentWithSlotsが optionsAPI のような定義になっていますが Vue Test Utils の公式ドキュメントに合わせました。compositionAPI のような定義ができるかもしれません。

✅ テスト結果


テストは通過します。
(🤔 コンポーネントをインポートしているわけではないのでカバレッジレポートは 0 になっている?)

名前付きスコープ付き slot

components/MyConponent.vue
<script setup lang="ts">
const userName = {
  nameEn: "Taro Yamada",
  nameJa: "山田 太郎",
}
const userAddress = {
  addressEn: "Tokyo",
  addressJa: "東京",
}
</script>

<template>
  <div>
    <h1>MyComponent</h1>
    <p data-qa="name">name: <slot name="name" :userName="userName">{{ userName.nameEn }}</slot></p>
    <p data-qa="age">age: <slot name="address" :userAddress="userAddress">{{ userAddress.addressEn }}</slot></p>
  </div>
</template>
pages/index.vue
<template>
  <div>
    <MyComponent>
      <template #name="slotProps">
        {{ slotProps.userName.nameJa }}
      </template>
      <template #address="slotProps">
        {{ slotProps.userAddress.addressJa }}
      </template>
    </MyComponent>
  </div>
</template>

🖥 画面出力結果

💁‍♂️ 解説

上の章で記載した名前付き slot とスコープ付き slot を合わせたコードです。

テストコード

tests/components/MyComponent.spec.ts
import { describe, expect, test } from "vitest"
import { shallowMount } from "@vue/test-utils"

const ComponentWithSlots = {
  template: `
    <div>
      <slot name="name" v-bind="{ userName }">{{ userName.nameEn }}</slot>
      <slot name="address" v-bind="{ userAddress }">{{ userAddress.addressEn }}</slot>
    </div>
  `,
  data() {
    return {
      userName: {
        nameEn: "Taro Yamada",
        nameJa: "山田 太郎",
      },
      userAddress: {
        addressEn: "Tokyo",
        addressJa: "東京",
      },
    }
  },
}

describe("index", () => {
  test("スコープ付き名前付きスロットでnameにnameJaが渡されると、'山田 太郎'がレンダリングされる", () => {
    const result = "山田 太郎"
    const wrapper = shallowMount(ComponentWithSlots, {
      slots: {
        name: `
        <template #name="slotProps">
          {{ slotProps.userName.nameJa }}
        </template>
        `,
      },
    })
    expect(wrapper.html()).toContain(result)
  })

  test("スコープ付き名前付きスロットでaddressにaddressJaが渡されると、'東京'がレンダリングされる", () => {
    const result = "東京"
    const wrapper = shallowMount(ComponentWithSlots, {
      slots: {
        address: `
        <template #address="slotProps">
          {{ slotProps.userAddress.addressJa }}
        </template>
        `,
      },
    })
    expect(wrapper.html()).toContain(result)
  })

  test("スコープ付きスロットで何も渡さないと、nameにデフォルト値の'Taro Yamada'がレンダリングされる", () => {
    const result = "Taro Yamada"
    const wrapper = shallowMount(ComponentWithSlots)
    expect(wrapper.html()).toContain(result)
  })

  test("スコープ付きスロットで何も渡さないと、addressにデフォルト値の'Tokyo'がレンダリングされる", () => {
    const result = "Tokyo"
    const wrapper = shallowMount(ComponentWithSlots)
    expect(wrapper.html()).toContain(result)
  })
})

✅ テスト結果

さいごに

Vue.js の slot 機能と slot のテストについてまとめました。
slot は Vue.js を触り始めてから存在は知っていたのですがあまり注目したことがなかったのですが体系的に振り返ってみて新たな知見が多かったです。
スコープ付きスロットは conposables で状態管理をすれば実用する場面があるのか疑問に思ったりしました。
slotは便利な機能ですが、コンポーネントの使われる側が使う側に依存してしまうので使い方には注意していきたいです。

参考文献

https://ja.vuejs.org/guide/components/slots.html
https://test-utils.vuejs.org/guide/advanced/slots.html
https://future-architect.github.io/articles/20200428/#スコープ付きslot

Discussion