Lesson 10

Vue のテスト

Lesson 10 Chapter 1
単体テスト

1. 単体テストとは

単体テストは、ユニットテスト(unit test)とも呼ばれ、プログラム作成後に比較的小さな単位(ユニット)で行うテストのことをいいます。
Vue における単体テストは、個々のコンポーネントやそのメソッドに対して、プログラムが期待どおりに動作しているかのテストを行うことになります。

本チャプターでは、Vitest(ビテスト/ヴィテスト)というツールを使用して、単体テストを学習していきます。

2. Vitest とは

Vitest は、Vite を利用した単体テストのフレームワークであり、Vue/Vite チームのメンバーによって開発・保守されています。

○ Vitest 公式ページ:https://vitest.dev/

vue_10-1-1.png

Vitest は、Vite を念頭に置いて開発されたツールであることから、Vite で作成したプロジェクトとの相性がよく、処理速度が高速な点に特長があります。

なお、これまで、Vue CLI を使用した Vue プロジェクトの単体テストには Jest(※)などの JS フレームワークが利用されてきました。
しかし、Vite プロジェクトにおいては、上記の Jest との間に重複した処理が多く存在するなど、その相性があまり良くありません。
そのため、最小限の労力で Vite ベースのプロジェクトと統合できる Vitest を利用することが Vue 公式でも推奨されています。

Jest とは

Jest は、JavaScript において最も人気の高い単体テスト用フレームワークの 1 つであり、Meta 社(Facebook 社)により開発・保守されています。
なお、Vitest は、Jest と互換性のあるように設計されており、簡易に Jest から Vitest への移行ができるようになっています。

なお、Vue で Vitest を使用する場合は、Vue 公式の単体テスト用ライブラリである Vue Test Utils を使用して、コンポーネント操作などを行うことになります。

3. Vitest を追加した新規プロジェクトの作成

それでは、実際に Vitest を使用してみましょう。
ここでは、create-vue ライブラリを使用して、新しく Vite プロジェクトを作成します。

以下の手順でプロジェクトの作成を行ってください。

① 作業フォルダに移動

まず、PowerShell を開いて、プロジェクトを作成するフォルダに移動します。

PS C:\Users\username> cd C:\vue_lesson
PS C:\vue_lesson>

② Vite プロジェクト生成コマンドの実行

Vue プロジェクトを作成するコマンドを入力します。

PS C:\vue_lesson> npm init vue@3

③ プロジェクト名の指定

プロジェクト名、ここでは「vitest-sample」と入力して Enter キーを押します(他の名前でも OK です)。

vue_10-1-2.png

④ 各選択肢について

ここでは「Add Vitest for Unit Testing?」の選択肢のみ「Yes」と選択します。
その他の選択肢は、全て「No」を選択します。

vue_10-1-3.png

以上の状態になれば、プロジェクトの作成は完了です。

⑤ npm install の実行

プロジェクトが作成されたら vitest-sample プロジェクトを VSCode で開き、ターミナルを開いて npm install を実行します。

vue_10-1-4.png

プロジェクトを作成する時期によって Warnig が出たりしますが、Error さえ出なければ OK としてください。

⑥ ローカルサーバを起動してブラウザで表示

npm run dev コマンドを打ってサーバを起動しましょう。

vue_10-1-5.png

以上のように表示されれば、新規プロジェクトの作成は完了です。
確認ができたら「ctrl + c」または「q」を入力して、サーバを停止しておいてください。

4. 自動生成されたコードを確認する

4-1. package.json ファイル

まず、package.json ファイルから確認してみましょう。

vue_10-1-6.png

① devDependencies に追加された内容

最初に、下の方の devDependencies に記載されているライブラリから確認します。
Vitest を選択したことにより、以下の 3 つのライブラリが追加されています。

No ライブラリ 説明
1 Vue Test Utils Vue 公式の単体テスト用ライブラリ
2 jsdom Node.js 上で HTML の DOM 操作を実行できるライブラリ
3 Vitest Vite を利用した単体テストフレームワーク

上記のうち「Vue Test Utils」と「Vitest」は既に説明したとおりです。
No 2 の「jsdom」は、ブラウザを使用しなくとも、メモリ上に DOM を構築することができるライブラリです。 この jsdom を使用することで、ブラウザを立ち上げなくとも、コンポーネントを生成して DOM 操作を行うことができます。

② scripts に追加された内容

次に、scripts のところを見てみましょう。
Vitest を選択したことにより、以下の 1 行が追加されています。

"test:unit": "vitest --environment jsdom --root src/"

これにより、npm run test:unit とコマンドを打つことで、Vitest を実行することができます。
指定されているオプションは、次のような意味となります。

No オプション 説明
1 --environment テストに使用する環境を指定。
ここでは、jsdom 環境を使用してテストを実行する。
2 --root プロジェクトルートを指定。
ルート内にある .spec.js または .test.js ファイルを実行する。

4-2. HelloWorld.spec.js ファイル

続いて、src\components\__tests__\HelloWorld.spec.js ファイルを開いてください。
このファイルは、Vitest を選択したことにより自動生成されたものとなります。

vue_10-1-7.png

コードは、次のようになっています。

src\components\__tests__\HelloWorld.spec.js
import { describe, it, expect } from 'vitest'

import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld', () => {
  it('renders properly', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    expect(wrapper.text()).toContain('Hello Vitest')
  })
})

Vitest から使用しているメソッドは、以下のとおりです。
これら 3 つが、最も使用頻度の高いメソッドとなります。

No メソッド 説明
1 describe テストブロックを指定する。
2 it(または test) テスト内容を記述する。
3 expect テストの評価(値の検証)を行う。

各メソッドについて、以下 1 つずつ説明をしていきます。
まずは、おおよそのコードの流れを把握していただければと思います。

① it メソッド

it メソッドは、テスト内容を記述するメソッドとなります。

it('テスト内容の説明', () => {
  // テスト内容を記述
})

第 1 引数に文字列で「テスト内容の説明」を記載し、第 2 引数にコールバック関数で「テストの内容」を記述します。

HelloWorld.spec.js を見ると、次のように指定しています。

it('renders properly', () => {
  const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
  expect(wrapper.text()).toContain('Hello Vitest')
})

第 1 引数の 'renders properly' は「レンダリングが適切か」という説明文です。
第 2 引数では、まず、mount メソッドで HelloWorld コンポーネントをマウントしています。その際に props として { msg: 'Hello Vitest' } を渡しています。 mount メソッドの戻り値は、マウントされたコンポーネントと仮想 DOM を含む Wrapper(ラッパー)です。
次の行で、expect メソッドでテスト結果の評価を行っています(詳細は後述します)。

なお、it メソッドの別名として test メソッドを使用することもできます。名称が異なるだけで実体は同じであるため、以下のように書いても全く同じ結果となります。

test('renders properly', () => {
  const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
  expect(wrapper.text()).toContain('Hello Vitest')
})

② expect メソッド

expect メソッドは、テストの評価を行うメソッドとなります。
具体的に言えば、取得された値(算出された値)がテストの期待値に沿ったものかどうかの判定を行うこととなります。

expect(比較する値)

expect メソッドは、Matcher(マッチャー)と呼ばれる「テストの評価条件を定義するメソッド」とともに使用します。
HelloWorld.spec.js では、expect(文字列1).toContain(文字列2) という形で toContain という Matcher を使用しています。この Matcher は「文字列 1文字列 2 が含まれるか否か」を判定するものとなります。

expect(wrapper.text()).toContain('Hello Vitest')

expect の引数にある wrapper.text() は、text メソッドを使用して wrapper(ラッパー)からコンポーネントに表示される文字列を取得するものとなります。
つまり、ここで判定されるのは「コンポーネントで表示される文字列に、'Hello Vitest' という文言が含まれるかどうか」ということになります。

③ describe メソッド

describe メソッドは、テストブロックを指定するメソッドとなります。

describe('テストブロックの説明', () => {
  // テストケースを記述
})

HelloWorld.spec.js では、次のように指定しています。

describe('HelloWorld', () => {
  it('renders properly', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    expect(wrapper.text()).toContain('Hello Vitest')
  })
})

テストブロックの説明として 'HelloWorld' と記述し、HelloWorld コンポーネント関連のテストであることを示しています。
この例では、一つのテストケースしか含まれていませんが、一般的には、複数のテストケースを記述することが多いです。

4-3. 自動生成コードでテスト実行

自動生成されたテストコードを使用して、テストの実行をしてみましょう。

以下のように npm run test:unit とコマンドを打ち Enter キーを押してください。

vue_10-1-8.png

次の赤枠部分のようなテスト結果が表示されるはずです。

vue_10-1-9.png

Files 1 passed は全部で 1 つのファイルのテストが成功したことを、Tests 1 passed は全部で 1 つのテストが成功したことを表しています。
なお、テストについてもホットリロードが効いているため、コードの変更を保存するとテストの再実行が行われます。

末尾に「press h to show help, press q to quit」と記載されています。
ここに書いてあるとおり、q キーを押すことでテストを終了することができます(ctrl + c でも終了できます)。
また、h キーを押すと、次のようなヒントが表示されます。

vue_10-1-10.png

a キーを押すと全てのテストを再実行、f キーを押すと失敗したテストのみを再実行することができるなど、Vitest の使用方法が確認できます。

5. Matcher(マッチャー)

5-1. Matcher の種類

テスト結果の評価に使用する Matcher(マッチャー)には、主に以下のようなものがあります。その他の Matcher については公式ページを参照してください。

No Matcher 説明
1 not 等しくない。
2 toBe 値が等しい(浮動小数点数には使用できない)。オブジェクトの参照が一致している。
3 toBeCloseTo 浮動小数点数が等しい。
4 toBeDefined 値が undefined でない。
5 toBeUndefined 値が undefined である。
6 toBeTruthy 値が true である。
7 toBeFalsy 値が false である。
8 toBeNull 値が null である。
9 toBeGreaterThan 指定した値より大きい。
10 toBeGreaterThanOrEqual 指定した値以上である。
11 toBeLessThan 指定した値より小さい。
12 toBelessThanOrEqual 指定した値以下である。
13 toEqual 値が等しい。オブジェクトの構造が一致する(undefined は無視)。
14 toStrictEqual オブジェクトの構造が一致する(undefined も含む)。
15 toContain 値が配列内にある。文字列が対象の文字列内に含まれる。
16 toHaveLength 長さ(length)が一致する。
17 toHaveProperty 指定したプロパティがオブジェクトに存在する。
18 toMatch 文字列が正規表現(または文字列)に一致する。
19 toMatchObject オブジェクトのサブセット(対象オブジェクトの一部分)が一致する。
20 toThrowError エラーがスローされている。

5-2. Matcher の具体例

実際に Matcher を使用してコードを書いてみましょう。
HelloWorld.spec.js と同じフォルダに MatcherSample.spec.js ファイルを作成してください。
次のように、ご自身でコードを記述してみてください。

vue_10-1-11-1.png vue_10-1-11-2.png

コードをテキストで表示
src\components\__tests__\MatcherSample.spec.js
import { test, expect } from 'vitest'

test('not', () => {
  expect(1).not.equal(2)
})

test('toBe', () => {
  expect(100).toBe(100) // 値の一致
  const obj = { bar: 0 }
  expect(obj).toBe(obj) // 参照の一致
  expect({ foo: 1 }).not.toBe({ foo: 1 }) // 参照が異なる(not で否定)
})

test('toBeCloseTo', () => {
  expect(1.23).toBeCloseTo(1.23) // 浮動小数点数
})

test('toBeUndefined', () => {
  expect().toBeUndefined()
})

test('toBeTruthy', () => {
  expect(true).toBeTruthy()
  expect(1).toBeTruthy() // 0 以外は Truthy
})

test('toBeFalsy', () => {
  expect(false).toBeFalsy()
  expect(0).toBeFalsy() // 0 は Falsy
})

test('toEqual', () => {
  expect(100).toEqual(100)
  expect({ foo: 1 }).toEqual({ foo: 1 }) // 参照は異なるが構造が一致
  expect({ foo: 1, bar: undefined }).toEqual({ foo: 1 }) // undefined は無視
})

test('toStrictEqual', () => {
  expect({ foo: 1, bar: undefined }).not.toStrictEqual({ foo: 1 }) // undefined も含めて判定
})

test('toContain', () => {
  expect(['apple', 'banana', 'orange']).toContain('orange')
})

test('toHaveLength', () => {
  expect(['apple', 'banana', 'orange']).toHaveLength(3)
})

test('toMatch', () => {
  expect('hello world').toMatch(/ll.*ld/)
})

test('toMatchObject', () => {
  expect({ foo: 1, bar: 2, baz: 9 }).toMatchObject({ bar: 2, baz: 9 })
})

test メソッド(it メソッドでも OK)を 12 個追加しました。describe メソッドはあえて使用していません。
それぞれの Matcher の機能は、表と照らし合わせながら確認してみてください。

コードが書けたら npm run test:unit コマンドでテストを実行してみましょう。
次のように全て成功(passed)となれば OK です。

vue_10-1-12.png

新しくファイルを 1 つ追加したので、ファイル数は 2 つ(Files 2 passed)、また、テストを 12 個追加したので、合計のテスト数は 13 個(Tests 13 passed)となっていることも確認できると思います。

6. Wrapper(ラッパー)

コンポーネントをマウントする際は、Vue Test Utilsmount メソッドを使用します。
この mount メソッドの戻り値として「マウントされたコンポーネントと仮想 DOM を含む Wrapper(ラッパー)」が返却されます。

6-1. Wrapper で使用できるメソッド

Wrapper に用意されているメソッドを使用することで、コンポーネントに関する情報を取得したり、コンポーネントの操作などを行うことができます。

Wrapper で使用できるメソッドには、主に以下のようなものがあります。

No メソッド 説明
1 attributes DOM ノードの属性を返す。
2 classes 要素のクラスの配列を返す。
3 emitted 発行された全てのイベントを返す。
4 exists 要素が存在するか否かを返す。
5 find 最初に見つかった要素の Wrapper を返す。
指定方法は、JavaScript の querySelector() と同じ。
6 findAll 見つかった全ての要素の Wrapper 配列を返す。
ベースは querySelectorAll() と同じ。
7 findComponent 見つかったコンポーネントの Wrapper を返す。
8 findAllComponents 見つかった全てのコンポーネントの Wrapper 配列を返す。
9 html 要素の HTML を返す。
10 isVisible 要素が表示されているか否かを返す。
11 props コンポーネントに渡された props を返す。
12 setData コンポーネントのプロパティを更新する。
ただし、setup() 関数では使用できない。
13 setProps コンポーネントの props を更新する。
14 text 要素のテキストを返す。
15 trigger DOM イベントを実行する。

その他のメソッドなど詳細は、公式ページを参照してください。

6-2. メソッドの具体例

それでは、Wrapper のメソッドを使用して実際にコードを書いてみましょう。
様々なメソッドを試せるように、まず、コンポーネントの追加と修正を行います。

① Foo.vue コンポーネントの追加

src\components ディレクトリに、以下の Foo.vue コンポーネントを追加してください。

vue_10-1-13.png

コードをテキストで表示
src\components\Foo.vue
<script setup>
defineProps({
  truthy: Boolean,
  object: Object,
  string: String
})
const emits = defineEmits(['emitTest'])
const emitTest = (text) => emits('emitTest', text)
emitTest('Hello')
emitTest('Goodbye')
</script>

<template>
  <div id="foo-id" class="foo-class">
    <h4>Fooコンポーネント</h4>
    <p>{{ truthy }} / {{ object }} / {{ string }}</p>
    <span v-show="false">isVisible のテスト!</span>
  </div>
</template>

あくまでテストを実行するためのコンポーネントのため、内容に深い意味はありません。

② HelloWorld.vue コンポーネントの修正

次に、src\components\HelloWorld.vue ファイルに、下図の赤枠部分を追加します。

vue_10-1-14.png

コードをテキストで表示
src\components\HelloWorld.vue
<script setup>
import Foo from './Foo.vue'
defineProps({
  msg: {
    type: String,
    required: true
  }
})
</script>

<script>
export default {
  data() {
    return { count: 1 }
  }
}
</script>

<template>
  <div class="greetings">
    <h1 class="green">{{ msg }}</h1>
    <h3>
      You’ve successfully created a project with
      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
    </h3>
  </div>
  <div>
    <Foo truthy :object="{ bar: 'hoge' }" string="fuga" />
    <div>Count: {{ count }}</div>
    <button @click="count++">increment</button>
  </div>
</template>

<style scoped>
h1 {
  font-weight: 500;
  font-size: 2.6rem;
  top: -10px;
}

h3 {
  font-size: 1.2rem;
}

.greetings h1,
.greetings h3 {
  text-align: center;
}

@media (min-width: 1024px) {
  .greetings h1,
  .greetings h3 {
    text-align: left;
  }
}
</style>

上図のとおり、Foo コンポーネントを使用できるように修正しています。
また、setData メソッドが setup 関数内では実行できないことから、別途 <scpipt> タグを追加して count 変数を定義しています(中央の赤枠部分)。

③ WrapperSample.spec.js ファイルの追加

HelloWorld.spec.js と同じフォルダに WrapperSample.spec.js ファイルを追加します。
以下のように、テストコードを記述してください。

vue_10-1-15-1.png vue_10-1-15-2.png vue_10-1-15-3.png

コードをテキストで表示
src\components\Foo.vue
import { describe, test, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
import Foo from '../Foo.vue'

describe('Wrapperのメソッド', () => {
  test('attributes: DOMノードの属性を取得', () => {
    const wrapper = mount(Foo)
    expect(wrapper.attributes('id')).toEqual('foo-id')
    expect(wrapper.attributes('class')).toEqual('foo-class')
  })

  test('classes: 要素のクラスの配列を取得', () => {
    const wrapper = mount(Foo)
    expect(wrapper.classes()).toContain('foo-class')
  })

  test('emitted: emitイベントを取得', () => {
    const wrapper = mount(Foo)
    expect(wrapper.emitted().emitTest[0]).toContain('Hello') // 1回目のemit
    expect(wrapper.emitted().emitTest[1]).toContain('Goodbye') // 2回目のemit
  })

  test('exists: 要素の存否を確認', () => {
    const wrapper = mount(Foo)
    expect(wrapper.find('#foo-id').exists()).toBe(true)
    expect(wrapper.find('#bar-id').exists()).toBe(false) // 存在しないid
  })

  test('find: 要素を検索して取得', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    expect(wrapper.find('.green').text()).toEqual('Hello Vitest') // greenクラスを検索
  })

  test('findAll: 全ての要素を検索して配列で取得', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    const elements = wrapper.findAll('a') // aタグを検索
    expect(elements).toHaveLength(2)
    expect(elements[1].text()).toEqual('Vue 3')
  })

  test('getComponent: コンポーネントを検索して取得', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    const foo = wrapper.getComponent({ name: 'Foo' }) // Fooコンポーネントを検索
    expect(foo.text()).toContain('Fooコンポーネント') // コンポーネント内の表示文字で確認
  })

  test('isVisible: 要素の表示の有無を確認', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    expect(wrapper.find('span').isVisible()).toBe(false)
  })

  test('props: コンポーネントのpropsを取得', () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    const foo = wrapper.getComponent({ name: 'Foo' })
    expect(foo.props('truthy')).toBe(true)
    expect(foo.props('object')).toStrictEqual({ bar: 'hoge' })
    expect(foo.props('string')).toBe('fuga')
  })

  test('setData: コンポーネントのプロパティを更新', async () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    await wrapper.setData({ count: 1 }) // countプロパティは Options API で定義
    expect(wrapper.text()).toContain('Count: 1')
  })

  test('setProps: コンポーネントのpropsを更新', async () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    await wrapper.setProps({ msg: 'Goodbye' })
    expect(wrapper.find('.green').text()).toEqual('Goodbye')
  })

  test('trigger: DOMイベントを実行', async () => {
    const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
    await wrapper.find('button').trigger('click') // count の初期値 1 から 2 に変更
    expect(wrapper.text()).toContain('Count: 2')
  })
})

一つ一つのテストコードについては説明をしませんので、どのようにデータを取得/更新しているかを読み解きながらコードを書いてみてください。

コードが書けたら npm run test:unit でテストを実行しましょう。
次のように表示されれば成功です。

vue_10-1-16.png

以上、基本的な事項のみですが、Vitest を使用した単体テストの方法を学習しました。
他にも、様々な機能がありますので、必要に応じて公式ページなどを見ながら、理解を深めていただければと思います。

Lesson 10 Chapter 2
E2E テスト

1. E2E テストとは

E2E(End to End)テストは、UI(User Interface)テストとも呼ばれ、ユーザー操作の観点からアプリケーション全体の動作を確認するテストです。
読み方は「エンドツーエンドテスト」となります。

単体テストは、部分的な機能の確認はできますが、アプリケーション全体の中で適切に機能するかまでは確認することができません。
E2E テストは、ユーザーによる操作がアプリケーションにどのように影響するかをテストすることで、個別の機能の連携なども含め、より実際的な問題を検出することができます。

本チャプターでは、Cypress(サイプレス)というテストツールを使用して学習を進めていきます。

2. Cypress とは

Cypress は、Web アプリケーション用のフロントエンドテストツールです。
実際にブラウザを立ち上げて、画面に表示された文字を検索したり、ボタンをクリックしたり、画面遷移をしたりすることができます。
Cypress は、E2E テストのみではなく単体テストを行うことも可能ですが、本チャプターでは E2E テストの機能のみを使用します。

以下が公式ページです(URL: https://www.cypress.io/)。

vue_10-2-1.png

なお、Vue 公式でも、E2E テストにおける推奨ツールとされています。

3. Cypress を追加した新規プロジェクトの作成

早速 Cypress を使用していきます。
手始めとして、create-vue ライブラリを使用して、Cypress がインストール済みの新しいプロジェクトを作成します。

以下の手順でプロジェクトの作成を行ってください。

① 作業フォルダに移動

まず、PowerShell を開いて、プロジェクトを作成するフォルダに移動します。

PS C:\Users\username> cd C:\vue_lesson
PS C:\vue_lesson>

② Vite プロジェクト生成コマンドの実行

Vue プロジェクトを作成するコマンドを入力します。

PS C:\vue_lesson> npm init vue@3

③ プロジェクト名の指定

プロジェクト名、ここでは「cypress-sample」と入力して Enter キーを押します(他の名前でも OK です)。

vue_10-2-2.png

④ Cypress の選択

いつものように TypeScript の選択肢などが出てきますが、最初の 5 項目は「No」を選択していきます。
そして、次のように「Add an End-to-End Testing Solution?」という選択肢が出てきたときに「Cypress」を選択してください。
それ以降の選択肢は、全て「No」を選択します。

vue_10-2-3.png

以下の状態になれば、プロジェクトの作成は完了です。

vue_10-2-4.png

⑤ npm install の実行

プロジェクトが作成されたら cypress-sample プロジェクトを VSCode で開き、ターミナルを開いて npm install を実行します。

vue_10-2-5.png

プロジェクトを作成する時期によって Warnig が出たりしますが、Error さえ出なければ OK としてください。

⑥ ローカルサーバを起動してブラウザで表示

npm run dev コマンドを打ってサーバを起動しましょう。

vue_10-2-6.png

以上のように表示されれば、新規プロジェクトの作成は完了です。
確認ができたら「ctrl + c」または「q」を入力して、サーバを停止しておいてください。

4. 自動生成されたコードを確認する

4-1. package.json ファイル

まず、package.json ファイルから確認してみましょう。

vue_10-2-7.png

① devDependencies に追加された内容

最初に、下の方の devDependencies に記載されているライブラリから確認します。
Cypress を選択したことにより、以下の 2 つのライブラリが追加されています。

No ライブラリ 説明
1 Cypress Vue 公式推奨の E2E テスト用ライブラリ
2 start-server-and-test テスト実行時に自動的にサーバを立ち上げるライブラリ

② scripts に追加された内容

次に、scripts のところを見てみましょう。
Cypress を追加したことにより、以下の 4 行が追加されています。

"scripts": {
  // 省略
  "test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
  "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress open --e2e'",
  "test:unit": "cypress run --component",
  "test:unit:dev": "cypress open --component"
}

E2E テストに使用するコマンドは、次の 2 つです。

No コマンド 説明
1 test:e2e E2E テストをブラウザを起動せず実行する。
2 test:e2e:dev E2E テストをブラウザを起動して実行する。

4-2. 追加されたディレクトリ・ファイル

VSCode のエクスプローラーを見ると、以下のようなディレクトリとファイルが追加されています。

vue_10-2-8.png

赤枠部分が、今回の E2E テストに関係する部分です。
青枠部分は、Cypress の単体テスト用のファイルのため、今回は使用しません。

上の図のファイルのうち、cypress\e2e\example.cy.jscypress.config.js について、以下、見て行きます(赤矢印のファイルです)。

4-3. example.cy.js ファイル

まず、cypress\e2e\example.cy.js ファイルを開きます。

vue_10-2-9.png

コードは、次のようになっています。
Vitest と似たような構成になっています。

cypress\e2e\example.cy.js
describe('My First Test', () => {
  it('visits the app root url', () => {
    cy.visit('/')
    cy.contains('h1', 'You did it!')
  })
})

使用しているメソッドは、次のとおりです。

No メソッド 説明
1 describe テストブロックを指定する
2 it テスト内容を記述する
3 cy.visit 指定した URL にアクセスする('/' の場合はベース URL)
4 cy.contains 指定したテキストを含む DOM 要素を取得する

各メソッドについて、以下、簡単に見ていきます。

① describe メソッド

describe メソッドは、テストブロックを指定するメソッドです。

describe('テストブロックの説明', () => {
  // テストケースを記述
})

② it メソッド

it メソッドは、テスト内容を記述するメソッドとなります。

it('テスト内容の説明', () => {
  // テスト内容を記述
})

③ cy.visit メソッド

cy.visit は、指定した URL にアクセスするメソッドです。
Cypress では、cy に続けてメソッドを記述します。

cy.visit('URLを指定')

'/' で始まる URL は、ベース URL からの相対パスとなります(ベース URL については後述します)。

④ cy.contains メソッド

cy.contains は、文字列やセレクタから DOM 要素を取得するメソッドです。

cy.contains('検索文字列')
cy.contains('セレクタ', '検索文字列')

上記構文のほか、オプションの指定もすることができます(詳細は公式サイト参照)。

以上を踏まえて、example.cy.js ファイルのテストコードを見てみましょう。

cy.visit('/')
cy.contains('h1', 'You did it!')

まず、1 行目の cy.visit('/') で、ベース URL にアクセスしています。
次に、2 行目の cy.contains('h1', 'You did it!') で、h1 タグの 'You did it!' 文字列を探して、その DOM 要素を取得しているということになります。

4-4. cypress.config.js ファイル

以下の cypress.config.js ファイルは、Cypress の設定ファイルとなります。

vue_10-2-10.png

上記画像の赤枠部分が、E2E テストに関する設定内容となっています。

cypress.config.js
e2e: {
  specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
  baseUrl: 'http://localhost:4173'
}

各プロパティの意味は、次のとおりです。

No メソッド 説明
1 specPattern テストファイルを格納するディレクトリを指定します。
2 baseUrl ベース URL を指定します。

ベース URL に 'http://localhost:4173' が指定されていることが確認できます。
なお、specPattern の指定内容はデフォルト値のため、この指定がなくとも変わらず動作します。

4-5. 自動生成コードでテスト実行

自動生成されたテストコードを使用して、テストの実行をしてみましょう。

① E2E テストを起動する

以下のように npm run test:e2e:dev とコマンドを打ち Enter キーを押してください。

vue_10-2-11.png

次のように、ポート番号 4173 でローカルサーバが立ち上がります。

vue_10-2-12.png

少し待つと Cypress のアプリケーションも立ち上がります(下図)。
初めて Cypress を使用する場合は、次のような画面が表示されますので「Continue」をクリックします。

vue_10-2-13.png

次の画面でブラウザを選択することができます。
ここでは「Chrome」を選択して「Start E2E Testing in Chrome」ボタンをクリックします。

vue_10-2-14.png

新たに、Chrome のブラウザが開きます。
このブラウザで E2E のテストを視覚的に行うことができます。

vue_10-2-15.png

上記画像の赤枠部分に cypress\e2e ディレクトリ内のファイルが表示されています。
先ほど確認した example.cy.js ファイル(青枠部分)をクリックするとテストが実行されます。

② E2E テストの実行

テストファイル(example.cy.js)をクリックすると、次のようにテストが実行されます。
緑色のチェックマークのところに、テストが 1 つ成功したことが表示されています

vue_10-2-16.png

③ エラーを発生させる

ここで、エラーが発生した場合も確認してみましょう。
example.cy.js ファイルを開いて、下の画像のように、検索文字列 You did it! の後に 2 を追加します。

vue_10-2-17.png

コードを書き換えて保存すると、テストが再実行されます。
以下のように赤字で「You did it!2 という文字列が見つからない」旨のエラーが表示されます。

vue_10-2-18.png

エラー画面が確認できたら、検索文字列から 2 を削って、You did it! に戻しましょう。

vue_10-2-19.png

テストが再実行されて、以下のように緑色の成功の表示になれば OK です。
なお、青枠部分をクリックすると、手動でテストの再実行を行うことができます。

vue_10-2-20.png

上の画像の赤枠部分のボタンを押すと元のトップ画面に戻ることができます。

④ E2E テストを終了する

一通りの確認ができましたので、E2E テストを終了しましょう。
Cypress アプリの「Close」ボタンを押すと、テストを実行していたブラウザを閉じることができます(なお、再表示した場合は「Focus」をクリックします)。

vue_10-2-21.png

次に Cypress アプリを終了します。
以下のように「File」タブから「Close Window」を選択してください。

vue_10-2-22.png

以上、Cypress の基本的な操作を確認しました。

5. Cypress で使用できるメソッド

Cypress で使用できるメソッドについて、主要なものを紹介します。
ほかにも様々なメソッドがありますので、詳しくは、公式ページをご覧ください。

5-1. アサーション

アサーションは、テストの評価を作成するメソッドです。
Vitest の expect メソッドと同様の役割と考えて問題ありません。

No メソッド 説明/使用例
1 should アサーションを作成する。
cy.get('nav').should('be.visible')
2 and アサーションを作成する。処理内容は should と同じ(連結して使用)
cy.get('nav').should('be.visible').and('have.class', 'open')

上の表中に出てくる cy.get('nav').should('be.visible') は、nav タブを取得して、それが可視状態か(be.visible)を判定するものです。
また、.and('have.class', 'open') は、open という名前のクラスを持っているかを判定しています。

Cypress のアサーションでよく使用される構文を挙げると次の表のとおりです。
表中では .should で記載していますが、.and メソッドでも同様に判定できます。

アサーションの例 説明
.should('be.visible') 見える(表示されている)。
.should('not.be.visible') 見えない(表示されていない)。
.should('have.class', 'hoge') hoge というクラスを持っている。
.should('not.have.class', 'fuga') fuga というクラスを持っていない。
.should('have.id', 'foo') foo という id を持っている。
.should('not.have.id', 'bar') bar という id を持っていない。
.should('be.checked') チェックされている。
.should('not.be.checked') チェックされていない。
.should('be.selected') 選択されている。
.should('not.be.selected') 選択されていない。
.should('be.empty') 要素が空である。

上の例はごく一部のものです。
主なゲッターとして、behave を使用します。否定の場合は頭に not を付けます。
アサーションの詳細については、公式ページを参照してみてください。

5-2. アクション

アクションは、テストプログラム内でボタンをクリックしたり、入力ボックスにテキストを入力したりすることができるメソッドです。

No メソッド 説明/使用例
1 click DOM 要素をクリックする。
cy.get('button').click()
2 dblclick DOM 要素をダブルクリックする。
cy.get('button').dblclick()
3 rightclick DOM 要素を右クリックする。
cy.get('button').rightclick()
4 trigger 指定した DOM イベントを実行する。
cy.get('a').trigger('mousedown')
5 select セレクトボックスの選択を実行する。
cy.get('select').select('user-1')
6 check チェックボックスまたはラジオボタンをチェックする。
cy.get('[type="checkbox"]').check()
7 uncheck チェックボックスのチェックを外す。
cy.get('[type="checkbox"]').uncheck()
8 type DOM 要素に指定したテキストを入力する。
cy.get('input').type('Hello, World')
9 clear input または textarea の値をクリアする。
cy.get('input').clear()

5-3. クエリ

クエリは、DOM から特定の要素や値を取得するために使用するメソッドです。

No メソッド 説明/使用例
1 children DOM 要素の子要素(複数)を取得する。
cy.get('nav').children()
2 contains 指定したテキストを含む DOM 要素を取得する。
cy.get('nav').contains('About')
3 get セレクタを指定して DOM 要素を取得する。
cy.get('li')
4 find 特定の DOM 要素内から DOM 要素を検索する。
cy.get('.article').find('footer')
5 eq DOM 要素の配列から指定したインデックスの要素を取得する。
cy.get('tr').eq(0)
6 first DOM 要素内の最初の DOM 要素を取得する。
cy.get('a').first()
7 last DOM 要素内の最後の DOM 要素を取得する。
cy.get('a').last()
8 title document の title を取得する。
cy.title()
9 url 現在ページの URL を取得する。
cy.url()

5-4. その他のメソッド

その他として、次のようなメソッドがあります。

No メソッド 説明/使用例
1 visit 指定した URL にアクセスする。
cy.visit('/')
2 each 配列等を反復処理する。
cy.get('li').each(() => {...})
3 submit form の submit を実行する。
cy.get('form').submit()
4 log Cypress アプリのログにメッセージを出力する。
cy.log('created new user')

6. メソッドを使用したテストコードの記述

表だけでは分かりにくいので、上記の内の幾つかのメソッドを使用して、実際にコードを書いてみましょう。

6-1. HelloWorld.vue コンポーネントの修正

Cypress のメソッドを実行するため、src\components\HelloWorld.vue ファイルの一部を修正します。
以下の画像の赤枠部分を追加してください。

vue_10-2-23-1.png vue_10-2-23-2.png

コードをテキストで表示
src\components\HelloWorld.vue
<script setup>
import { ref } from 'vue'
defineProps({
  msg: {
    type: String,
    required: true
  }
})
const selected = ref('')
const toggle = ref(false)
const picked = ref(null)
const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <div>
    <button @click="increment">Increment</button> {{  count }}
    <div>
      SELECT:
      <select v-model="selected">
        <option disabled value="" hidden>選択してください</option>
        <option value="1">A</option>
        <option value="2">B</option>
        <option value="3">C</option>
      </select>
      {{ selected }}
    </div>
    <div>
      CHECK: <input type="checkbox" v-model="toggle" /> {{ toggle }}
    </div>
    <div>
      RADIO:
      <input type="radio" value="One" v-model="picked">One
      <input type="radio" value="Two" v-model="picked">Two
      / Picked: {{ picked }}
    </div>
  </div>
  <div class="greetings">
    <h1 class="green">{{ msg }}</h1>
    <h3>
      You’ve successfully created a project with
      <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
      <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
    </h3>
  </div>
</template>

<style scoped>
h1 {
  font-weight: 500;
  font-size: 2.6rem;
  top: -10px;
}

h3 {
  font-size: 1.2rem;
}

.greetings h1,
.greetings h3 {
  text-align: center;
}

@media (min-width: 1024px) {
  .greetings h1,
  .greetings h3 {
    text-align: left;
  }
}
</style>

セレクトボックスやチェックボックスなど、テストで使用したいものを追加しています。

6-2. App.vue コンポーネントの修正

結果を見やすくするため、src\App.vue ファイルの一部を修正します。
以下の画像の赤枠部分を削除(またはコメントアウト)してください。

vue_10-2-24.png

コードをテキストで表示
src\App.vue
<script setup>
import HelloWorld from './components/HelloWorld.vue'
// import TheWelcome from './components/TheWelcome.vue'
</script>

<template>
  <header>
    <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />

    <div class="wrapper">
      <HelloWorld msg="You did it!" />
    </div>
  </header>

  <main>
    <!-- <TheWelcome /> -->
  </main>
</template>

<style scoped>
header {
  line-height: 1.5;
}

.logo {
  display: block;
  margin: 0 auto 2rem;
}

@media (min-width: 1024px) {
  header {
    display: flex;
    place-items: center;
    padding-right: calc(var(--section-gap) / 2);
  }

  .logo {
    margin: 0 2rem 0 0;
  }

  header .wrapper {
    display: flex;
    place-items: flex-start;
    flex-wrap: wrap;
  }
}
</style>

以上のコード修正後、ブラウザでは次のように表示されます。

vue_10-2-25.png

6-3. テストファイルの追加

それでは、テストコードを書くためのファイルを追加しましょう。
以下のように cypress\e2e ディレクトリの直下に sample.cy.js ファイルを追加してください。

vue_10-2-26.png

記述したテストコードは以下のとおりです。

cypress\e2e\sample.cy.js
describe('テストサンプル', () => {
  it('メソッドのテスト', () => {
    cy.visit('/')

    cy.log('クリック')
    cy.get('button').click()

    cy.log('トリガー')
    cy.get('button').trigger('click')

    cy.log('セレクトボックス')
    cy.get('select').select('2').should('have.value', '2')
    cy.get('select')
      .select('C')
      .should('have.value', '3')
      .contains('C')
      .should('be.selected')
      .and('have.value', '3')

    cy.log('チェックボックス')
    cy.get('[type="checkbox"]').check().should('be.checked')
    cy.get('[type="checkbox"]').uncheck().should('not.be.checked')

    cy.log('ラジオボタン')
    cy.get('[type="radio"]').first().check()
    cy.get('[type="radio"]').eq(1).check()
  })
})

以下、コードの内容を簡単に見ていきます。

6-4. テストコードの説明

① click/trigger/log

最初に、以下のところでボタンのクリックを行っています。
単純に click メソッドを使用した場合と、trigger メソッドでイベント名を指定した場合の 2 つを記述しています。どちらもボタンをクリックすることに変わりはありません。

cy.log('クリック')
cy.get('button').click()

cy.log('トリガー')
cy.get('button').trigger('click')

なお、log メソッドで、実行したメソッドの内容をログに出力するようにしています。

② select/should/and

次に、セレクトボックスを選択する select メソッドの使用部分です。
cy.get('select').select('2') で、select タグ内の value が 2 の要素を選択しています。
cy.get('select').select('C') の方は、表示テキストが C の要素を選択するものです。
つまり、value でも表示テキストでも選択することが可能となっています。

cy.log('セレクトボックス')
cy.get('select').select('2').should('have.value', '2')
cy.get('select')
  .select('C')
  .should('have.value', '3')
  .contains('C')
  .should('be.selected')
  .and('have.value', '3')

併せて should メソッドおよび and メソッドを使用してアサーション(テストの評価)を行っています。
例えば、.should('have.value', '3') は、select タブの v-model の値が 3 ということを判定しています。
なお、v-model は、以下の HelloWorld.vue の HTML の 1 行目の部分にあります。

<select v-model="selected">
  <option disabled value="" hidden>選択してください</option>
  <option value="1">A</option>
  <option value="2">B</option>
  <option value="3">C</option>
</select>

そして、テストコード中の .contains('C') で、要素 <option value="3">C</option> を取得し、それが選択中であること(.should('be.selected'))を判定し、さらに、その value の値が 3 であること(.and('have.value', '3'))を判定しています。

③ check/uncheck/first/eq

続いて、セレクトボックス、ラジオボタンを操作する check メソッドおよび uncheck メソッドの部分です。

cy.log('チェックボックス')
cy.get('[type="checkbox"]').check().should('be.checked')
cy.get('[type="checkbox"]').uncheck().should('not.be.checked')

cy.log('ラジオボタン')
cy.get('[type="radio"]').first().check()
cy.get('[type="radio"]').eq(1).check()

cy.get(セレクタ).check() でチェックを行い、cy.get(セレクタ).uncheck() でチェックを外します。
ラジオボタンのところで出てくる first() は複数ある要素のうち最初の要素を取得するものです。また、eq(1) は、複数ある要素のうち、指定した index 番号の要素を取得するものとなります。

追加したテストコードの内容は、おおよそ以上のとおりとなります。

6-5. テストの実行

① Cypress アプリでテストを行う

それではテストを実行してみましょう。
ターミナルからコマンド npm run test:e2e:dev を入力して Enter キーを押します。

vue_10-2-27.png

Cypress のアプリが立ち上がったら「Chrome」を選択して「緑色の Start ボタン」をクリックします(下図)。

vue_10-2-28.png

先ほど作成した sample.cy.js ファイルが表示されていますので、それをクリックしてテストを実行します。

vue_10-2-29.png

すると、以下のように、テストが実行されます。
赤枠部分が log メソッドを使用して作成したログのテキストになります。
また、青枠部分は、アサーションの実行結果となります。

vue_10-2-30.png

なお、下図のように、ログの途中の項目(赤枠部分)にマウスカーソルを重ねると、その状態のときの画面が表示され、関連する部分が色付きで表示されます(青枠部分)。

vue_10-2-31.png

一通り確認を終えたら、Cypress アプリを終了します。

vue_10-2-32.png

上の図の右上の「×」のところをクリックすることでも、アプリを終了させることができます。

② コマンドラインでテストを実行する

さて、テストを実行するコマンドには、もう一つ npm run test:e2e というものがありましたので、今度は、それでテストを実行してみましょう。
ただし、このコマンドでテストを実行するには、一度アプリケーションをビルドする必要があります。

次のように、ターミナルで npm run build コマンドを実行して、ビルドを行ってください。

vue_10-2-33.png

ビルドが成功すると、左側のエクスプローラーのところに dist というフォルダが作成されます(青枠部分)。
dist とは「distribution」の略で、配布用のファイルという意味になります。つまり、本番環境にアップするファイルはこのフォルダの中のファイルとなります。
npm run test:e2e コマンドで実行するテストも、この dist フォルダ内のファイルを使用して行われます(※)。

コードの修正と dist フォルダ

Vite のプロジェクトでコードを修正した場合、その修正内容は、自動的に dist フォルダには反映されません。
そのため、コードを修正した後に npm run test:e2e コマンドでテストを行う場合は、再ビルドを行い dist フォルダ内のファイルも最新の状態に更新する必要があります。
なお、再ビルドのコマンドも npm run build となります。

ビルドが完了したら、以下のように、ターミナルに、npm run test:e2e コマンドを打ち込んでテストを実行してください。

vue_10-2-34.png

テストが完了するまでには、少し時間が掛かりますので、気長に待ちましょう。

テストが完了すると、ターミナルにテスト結果が表示されます(下図)。
テストコードを記述した example.cy.js ファイルと sample.cy.js ファイルのテストが順に実行され、最後に、最終的な実行結果が表示されていることが確認できます。

vue_10-2-35-1.png vue_10-2-35-2.png vue_10-2-35-3.png vue_10-2-35-4.png

なお、上の画像の緑枠の部分に Video output: という表示があり、ファイルのパスが記述されています。
これらのファイルは、cypress\videos フォルダの中に生成されています(下図赤枠部分)。

vue_10-2-36.png

sample.cy.js.mp4 ファイルを実行すると、次のように、テストの実行内容を動画で確認することができます。

こちらのコマンドのテストも便利ですので、ぜひ、使い方を把握しておいていただければと思います。

7. 天気予報アプリに Cypress を導入する

このセクション以降では、Cypress の機能をより具体的に見ていくために、Lesson 9 で作成した「天気予報アプリ」を使用してテストを実行していきます。

7-1. Cypress のインストール

先ほどまでのテストは、Vite プロジェクト作成時に自動的に Cypress もインストールして使用していましたので、初期設定もせずに簡単に使用することができました。
しかし、実際の開発では、後からテストライブラリをインストールすることも多いです。
ここでも、手動で 1 つずつインストールを行って、初期設定をしていきましょう。

① インストール方法

Cypress のインストール方法は、以下の公式ページに説明されています。

vue_10-2-37.png

書いてあるとおり、次のコマンドで Cypress のインストールをすることができます。

npm install cypress --save-dev

② 天気予報アプリに Cypress をインストールする

それでは、天気予報アプリのプロジェクト weather-app を VSCode で開いて、ターミナルを表示し、次のように npm install cypress --save-dev コマンドを実行しましょう。

vue_10-2-38.png

インストールが成功すると、package.jsoncypress が追加されます。

vue_10-2-39.png

③ start-server-and-test をインストールする

Cypress だけでもテストは実行できますが「start-server-and-test」ライブラリも一緒にインストールしておきます。
この start-server-and-test については「4. 自動生成されたコードを確認する」でも触れましたが、テスト実行時に自動的にサーバを立ち上げるライブラリとなります。テストが終了するとサーバの停止まで自動で行ってくれます。
インストール方法は、以下の npm の公式ページに記載されています。

vue_10-2-40.png

つまり、次のコマンドでインストールをすることができます。

npm install --save-dev start-server-and-test

ターミナルに npm install --save-dev start-server-and-test コマンドを打ってインストールを実行してください(下図)。

vue_10-2-41.png

インストールが成功すると、次のように package.jsonstart-server-and-test が追加されます。

vue_10-2-42.png

インストールは以上で完了です。

7-2. Cypress の初期設定を行う

必要なライブラリのインストールは行いましたが、Cypress の実行に必要なのフォルダやファイルはまだありません。
これらの初期設定については、Cypress が提供している機能を利用します。

① Cypress アプリの起動方法

次の公式ページに Cypress アプリの起動方法が紹介されています。

vue_10-2-43.png

ここで指定されているコマンド npx cypress open を使用することで Cypress アプリを起動することができます。

npx cypress open

② Cypress アプリの起動

weather-app のターミナルに npx cypress open コマンドを入力し Enter キーを押します。

vue_10-2-44.png

数秒待つと、次のように Cypress アプリが立ち上がります。
最初に「E2E テスト」を行うか「コンポーネントテスト」を行うかの選択肢が現れますので、ここでは「E2E Testing」をクリックします。

vue_10-2-45.png

③ 設定ファイル等の追加

E2E テストを選択すると、以下の画像ような表示が現れます。
タイトルは「Configuration files(設定ファイル)」となっており、その下に「 We added the following files to your project:(次のファイルをプロジェクトに追加しました)」と記載されています。

vue_10-2-46.png

上の画像で表示された追加ファイルは、次の 4 つです。

ファイル 説明
cypress.config.js Cypress の設定ファイル。
cypress\support\e2e.js 各テストの実行前に呼び出されるファイル。共通設定などを記述。
cypress\support\commands.js 共通処理(コマンド)を登録するファイル。
cypress\fixtures\example.json テストで使用するテストデータ等を記述するファイル。

実際にプロジェクトに追加されたかを確認してみましょう。
VSCode を見ると、以下の赤枠部分に 4 つのファイルが追加されていることが確認できます。

vue_10-2-47.png

以上の確認ができたら、Cypress アプリに戻って「Continue」をクリックしてください。

④ デフォルトのテストファイル追加

設定ファイルの確認画面の次は、ブラウザの選択画面となります。
これまでのように「Chrome」を選択して「緑色の Start ボタン」をクリックしてください。

vue_10-2-48.png

現在は、まだテストファイルが 1 つも無いため、最初のテストファイルを作成する画面が表示されます。
ここでは「Create new spec」をクリックします。

vue_10-2-49.png

次に、テストファイルの名前(パス)を指定する画面となります。
ここでは、デフォルトの名前のまま「Create spec」をクリックします。

vue_10-2-50.png

今度は、テストコードの作成画面となります。
ここもデフォルトのままとして「Okay, run the spec」をクリックします。

vue_10-2-51.png

すると、次のようにテストが実行されます。
このテストは Cypress が用意した URL https://example.cypress.io で実行されますので、手元のアプリケーションとは関係なく成功するようになっています。

vue_10-2-52.png

ここで作成した、デフォルトのテストファイルは、次のようにプロジェクトフォルダ内に追加されています。
この cypress\e2e ディレクトリに、独自のテストファイルを追加していくことになります。

vue_10-2-53.png

⑤ Cypress アプリの終了

ここまでの作業が終わったら、一旦、Cypress アプリを終了しましょう。
Cypress アプリの「Close」をクリックして、ブラウザを閉じます。

vue_10-2-54.png

次に「File」タブから「Close Window」を選択して、アプリを終了させます。

vue_10-2-55.png

以上で、Cypress に必要なフォルダとファイルが作成されました。

7-3. Cypress 設定ファイルの修正

ここで、Cypress の設定ファイルにベース URL を追加しておきましょう。
次のページを参考にします。

vue_10-2-56.png

設定ファイル cypress.config.js を開いて次の赤枠部分を追加します。

vue_10-2-57.png

ベース URL の設定は、e2e プロパティの中に記述します(e2e プロパティに設定できるものは公式ページ参照)。
なお、記載しなくとも変わらないのですが video: true という設定も追加しています。前セクションにおいて、コマンドからテストを実行したときに動画が生成されましたが、この動画の生成が不要な場合は video: false とします(ここでは true としておきます)。

7-4. デフォルトのテストコードを修正する

次に、デフォルトで作成されたテストファイル cypress\e2e\spec.cy.js を修正して、天気予報アプリに対してテストの実行を行うようにしてみます。

① テストコードで実行する処理

まず、ここでは、以下の赤枠部分の文字 天気予報アプリ全国の週間天気予報 が正しく表示されているかを確認するテストコードを書いてみます。

vue_10-2-58.png

② App.vue コンポーネントの修正

天気予報アプリ の文字は、以下の src\App.vue ファイルの q-toolbar-title タグ内に記述されています。この q-toolbar-title タグは、Quasar のコンポーネント名のため、cy.get メソッドでは取得することはできません。

vue_10-2-59.png

ここでは、cy.get メソッドで明示的に取得できるように(※)、q-toolbar-title タグにクラス名 app-title を追加しておきます(下図赤枠部分)。

vue_10-2-60.png

コードをテキストで表示
src\App.vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAreaStore } from './stores/area'

const route = useRoute()
const router = useRouter()
const areaStore = useAreaStore()
areaStore.fetchArea()

const selected = ref(null)
const options = ref([])

const filterFn = (val, update) => {
  update(() => {
    options.value = areaStore.officeArray.filter(v => v.name.indexOf(val) > -1)
  })
}

const updateSelect = (code) => movePage(code)

const movePage = (code) => {
  const name = route.name === 'week' ? 'week' : 'overview'
  router.push({ name, params: { code } })
}

const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => leftDrawerOpen.value = !leftDrawerOpen.value
</script>

<template>
  <q-layout view="hHh lpR fFf">

    <q-header elevated class="bg-primary text-white" height-hint="98">
      <q-toolbar>
        <q-btn dense flat round icon="menu" @click="toggleLeftDrawer" />

        <div class="row justify-between items-center full-width">
          <q-toolbar-title class="app-title">
            <q-avatar>
              <img src="https://cdn.quasar.dev/logo-v2/svg/logo-mono-white.svg">
            </q-avatar>
            天気予報アプリ
          </q-toolbar-title>
          <div class="row items-center">
            <span>地域検索: </span>
            <q-select
              outlined
              v-model="selected"
              dense
              options-dense
              use-input
              hide-selected
              input-debounce="0"
              :options="options"
              option-value="code"
              option-label="name"
              emit-value
              map-options
              @filter="filterFn"
              style="width: 250px;"
              bg-color="grey-2"
              @update:model-value="updateSelect"
            >
              <template v-slot:no-option>
                <q-item>
                  <q-item-section class="text-grey">
                    検索結果なし
                  </q-item-section>
                </q-item>
              </template>
            </q-select>
          </div>
        </div>
      </q-toolbar>

      <q-tabs align="left">
        <q-route-tab to="/" label="ホーム" />
        <q-route-tab :to="`/overview/${areaStore.code}`" label="天気概況" />
        <q-route-tab :to="`/week/${areaStore.code}`" label="週間予報" />
      </q-tabs>
    </q-header>

    <q-drawer show-if-above v-model="leftDrawerOpen" side="left" bordered>
      <div
        v-for="[code, name] in areaStore.offices"
        :key="code"
        @click="movePage(code)"
        class="area-item"
      >
        {{ name }}
      </div>
    </q-drawer>

    <q-page-container>
      <router-view />
    </q-page-container>

  </q-layout>
</template>

<style lang="scss" scoped>
.area-item {
  line-height: 28px;
  padding: 2px 16px;
  cursor: pointer;
  &:hover {
    background-color: #EEEEEE;
  }
}
</style>

q-toolbar-title タグに付されるクラス

Quasar の q-toolbar-title タグがレンダリングされると、実際は、次のような HTML となっています。

vue_10-2-61.png

クラス名として q-toolbar__title が付された div タグとなるため、次のように記述して DOM 要素を取得することも可能ではあります。

cy.get('.q-toolbar__title')

③ Home.vue コンポーネントの確認

全国の週間天気予報 の文字は、src\views\Home.vue ファイルの h4 タグ内に記述されています。
この DOM 要素については、cy.get('h4') で、シンプルに取得できそうです。

vue_10-2-62.png

④ spec.cy.js ファイルの修正

以上を踏まえて、テストファイル cypress\e2e\spec.cy.js を次のように修正します。

vue_10-2-63.png

テストコードの内容は次のとおりです。

cypress\e2e\spec.cy.js
describe('template spec', () => {
  it('表示テキスト', () => {
    cy.visit('/')
    cy.get('.app-title').contains('天気予報アプリ')
    cy.get('h4').contains('全国の週間天気予報')
  })
})

まず、cy.visit('/') で、アプリのベース URL からブラウザ表示を行います。
次に、cy.get('.app-title').contains('天気予報アプリ') で、クラス名が「app-title」かつテキストに「天気予報アプリ」が含まれている DOM 要素を検索します。
最後に、cy.get('h4').contains('全国の週間天気予報') で、タグが「h4」であり、テキストに「全国の週間天気予報」が含まれている DOM 要素の検索を行います。

contains メソッドは、引数に指定したテキストを含む DOM 要素を取得するものですが、指定したテキストが見つからない場合はエラーを吐きますので、文字列の有無の検証にも使用することができます。

7-5. テストを実行する

テストコードが書けたら、テストの実行を行いましょう。

① E2E テストを指定してコマンドを実行する

Cypress アプリを立ち上げるコマンドは npx cypress open でしたが、次のように、末尾に --e2e を付加することで、テストの選択画面を飛ばして、ダイレクトに「E2E テスト」を実行することができます。

npx cypress open --e2e

一度、次のようにコマンドを打って実行してみましょう。

vue_10-2-64.png

少し待つと、Cypress アプリが立ち上がります。
しかし、下の画像のように、ベース URL http://localhost:4173 に接続できませんとの警告が表示されてしまいます。

vue_10-2-65.png

つまり、先にアプリのサーバを立ち上げて、アクセスできるようにしてあげる必要があるということです。

② ポートを指定してサーバを起動する

次の図の赤枠部分をクリックして、ターミナルをもう 1 つ表示させます。

vue_10-2-66.png

いつものように、npm run dev コマンドを実行すると、ポート番号 5173 でサーバが起動してしまいます。今回は、ポート番号 4173 を指定してサーバを起動する必要がありますので、次のコマンドを使用します。

npx vite --port <ポート番号>

なお、npm run dev -- --<ポート番号> というコマンドでもポート番号を指定できます。

それでは、npx vite --port 4173 とコマンドを打ってサーバを立ち上げてみましょう。

vue_10-2-67.png

上図のようにサーバが立ち上がったことが確認できたら、下の図の「Try again」をクリックします。

vue_10-2-68.png

すると、いつものブラウザ選択画面が表示されます。
「Chrome」を選択して「緑色の Start ボタン」をクリックしてください。

vue_10-2-69.png

③ テストを実行する

ブラウザが立ち上がったら「spec.cy.js」ファイルをクリックします。

vue_10-2-70.png

少し時間が掛かると思いますが、以下のようにテストが実行されます。

vue_10-2-71.png

④ テストを終了する

テストが無事に成功したら、Cypress アプリを終了させましょう。
下図の「×」ボタンを押せば、ブラウザが閉じ、Cypress アプリも終了します。

vue_10-2-72.png

Cypress アプリを終了させると、下図の左側のテスト実行コマンドも終了します。
しかし、右側のローカルサーバは立ち上がったままです。

vue_10-2-73.png

ローカルサーバについては「q」キーまたは「ctrl + c」で停止させます。

vue_10-2-74.png

以上、天気予報アプリで簡単なテスト実行をすることができました。

7-6. テスト時にサーバを自動起動する

さて、先ほどのテストの方法では、ローカルサーバの立ち上げとテストの実行につきコマンドを 2 回実行する必要がありました。次のような手順となります。

(1) npx vite --port 4173 でアプリケーションのサーバを立ち上げる。
(2) サーバが立ち上がるのを待つ。
(3) npx cypress open --e2e で Cypress のアプリを実行する。

ここでは、これを 1 回のコマンドで実行できるようにしていきます。

① start-server-and-test ライブラリの使用方法

Cypress のインストール時に「start-server-and-test」というライブラリもインストールしたことを覚えていると思います。
このライブラリを使用することで、上記の一連の操作を、まとめて行うことができます。

start-server-and-test を使用してコマンドを実行するには、次の構文で指定を行います。

npx start-server-and-test <サーバコマンド> <URL または ポート番号> <テストコマンド>

ここでは、次の指定を行います。

(1) サーバコマンド:npx vite --port 4173
(2) ポート番号::4173
(3) テストコマンド:npx cypress open --e2e

当てはめると、次のようなコマンドになります。

npx start-server-and-test 'vite --port 4173' :4173 'cypress open --e2e'

② start-server-and-test を使用してコマンドを実行する

それでは、VSCode のターミナルに次のコマンドを打ってE2E テストを実行してみましょう。

vue_10-2-75.png

以下のように、Cypress のアプリが立ち上げれば成功です。

vue_10-2-76.png

ここから Chrome を起動してテストを実行することができますが、特に新しいテストも作成していないので、そのまま右上の「×」ボタンを押してテストを終了します。

③ コマンドを package.json に登録する

さて、毎回次のようなコマンドを実行するのは大変です。

npx start-server-and-test 'vite --port 4173' :4173 'cypress open --e2e'

このコマンドを、package.json に登録して、いつでも実行できるようにしましょう。
次のように npx を省いたコマンドを登録すれば、npm run test:e2e:dev というコマンドで E2E テストを実行できるようになります。

"scripts": {
  // 省略
  "test:e2e:dev": "start-server-and-test 'vite --port 4173' :4173 'cypress open --e2e'"
}

実際に package.json に登録すると、次のようになります。
説明は省きますが、併せて test:e2e コマンドも登録しておきました。

vue_10-2-77.png

コードをテキストで表示
package.json
{
  "name": "weather-app",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test:e2e": "start-server-and-test preview :4173 'cypress run --e2e'",
    "test:e2e:dev": "start-server-and-test 'vite --port 4173' :4173 'cypress open --e2e'"
  },
  "dependencies": {
    "@quasar/extras": "^1.15.10",
    "pinia": "^2.0.28",
    "quasar": "^2.11.5",
    "vue": "^3.2.45",
    "vue-router": "^4.1.6"
  },
  "devDependencies": {
    "@quasar/vite-plugin": "^1.3.0",
    "@vitejs/plugin-vue": "^4.0.0",
    "cypress": "^12.5.1",
    "sass": "^1.32.12",
    "start-server-and-test": "^1.15.4",
    "vite": "^4.0.0"
  }
}

結果として、Vite プロジェクト作成時に自動生成されたコマンドと、ほぼ同じものになっています。

以上で、天気予報アプリへの Cypress の導入は完了となります。

8. 天気予報アプリで E2E テストを実行する

E2E テストを実行できる環境になりましたので、天気予報アプリに対していくつかのテストコードを書いて、正常に動作するかを確認していきます。

8-1. 左メニューページ遷移のテスト

まず、左メニューを操作した際に、正しくページ遷移をするか確認しましょう。

① テストコードの記述

cypress\e2e ディレクトリに、以下のように、page-transition.cy.js ファイルを追加してください。

vue_10-2-78.png

テストコードの内容は次のとおりです。

cypress\e2e\page-transition.cy.js
describe('ページ遷移テスト', () => {
  it('左メニューページ遷移', () => {
    cy.visit('/')

    // 左メニュー表示(左メニューが非表示であれば)
    if (cy.get('.q-drawer').should('not.be.visible')) {
      cy.get('button').click()
    }

    // 左メニューの青森県をクリック
    cy.get('.area-item').contains('青森県').click()
    // 青森県 天気概況が表示されているかを確認
    cy.contains('h4', '青森県 天気概況').should('exist')

    // ヘッダーメニューの週間予報タブをクリック
    cy.get('header').find('a').contains('週間予報').click()
    // 青森県 週間天気予報が表示されているかを確認
    cy.contains('h4', '青森県 週間天気予報').should('exist')
  })
})

② テストコードの説明

記述したテストコードにつき、簡単に説明をします。

・左メニューを表示
まず、cy.get('.q-drawer').should('not.be.visible') で、左メニューが非表示か否かを確認して、非表示であればメニュー表示ボタンをクリックします。
.q-drawer は、Quasar の q-drawer コンポーネントに付けられるクラス名となります。

・左メニューから青森県の天気概況を表示
次に、cy.get('.area-item').contains('青森県') で、左メニューから青森県を探し出してクリックをします。
その後、cy.contains('h4', '青森県 天気概況').should('exist') で「青森県 天気概況」が表示されているかを判定(評価)しています。

・ヘッダータブから週間予報を表示
続いて、cy.get('header').find('a').contains('週間予報') で、ヘッダーの「週間予報」タブを取得しクリックします。
最後に、cy.contains('h4', '青森県 週間天気予報').should('exist') で「青森県 週間天気予報」が表示されているかを判定(評価)しています。

③ テストを実行する

それでは、テストを実行してみましょう。
ターミナルを開いて、npm run test:e2e:dev コマンドを実行してください。

vue_10-2-79.png

ブラウザの選択画面が表示されますので「Chrome」を選択してください。
次の画面が表示されたら、page-transition.cy.js ファイルをクリックして、テストを実行します。

vue_10-2-80.png

次のように、テストが実行されれば成功です。

vue_10-2-81.png

なお、テストは正常に実行されたものの、各天気予報の API から情報を取得するときに、非同期通信が完了するまでの間の一瞬だけ、次のような「○○が取得できませんでした。」の表示がされていることが確認できます(これについては、後ほど補正を行います)。

vue_10-2-82.png

テストが終わったら、Cypress アプリを閉じてテストを終了しておきましょう。

④ ターミナル内でテストを実行する

今度は、test:e2e のコマンドで、Cypress アプリを起動せずに、テストを実行してみましょう。

ターミナルを開き、以下のコマンドでアプリケーションをビルドしてください。

npm run build

次に、以下の npm run test:e2e コマンドでテストを実行します。

npm run test:e2e

ターミナルで、次のようにテスト結果が表示されれば成功です。

vue_10-2-83.png

動画は、cypress\videos ディレクトリに 2 つ保存されています。

vue_10-2-83-2.png

page-transition.cy.js.mp4 には、次のような動画が作成されていると思います。

ここでも、折々に「○○が取得できませんでした。」の表示がされてしまっています。
これは、次の項で修正を行っていきます。

8-2. API 通信中のコンポーネント表示の修正

先に見たように「○○が取得できませんでした。」の表示がされてしまうのは、API からデータ取得を行う非同期通信を行っているときに、取得開始から終了までにタイムラグがあるためです。
レスポンスデータ取得完了までのわずかな時間の間、ストア内のレスポンスデータが「空」の状態であるため「○○が取得できませんでした。」の表示がされているということです。

この API との通信中の状態を、forecast ストアに持たせて、通信中は「○○が取得できませんでした。」の表示をしないように制御していきます。

① forecast ストアの修正

src\stores\forecast.js ファイルを開いて、次の赤枠部分を追加してください。

vue_10-2-84-1.png vue_10-2-84-2.png

コードをテキストで表示
src\stores\forecast.js
import { defineStore } from 'pinia'
import { useAreaStore } from './area'

export const useForecastStore = defineStore('forecast', {
  state: () => {
    return {
      overview: {
        headlineText: '',
        publishingOffice: '',
        reportDatetime: '',
        targetArea: '',
        text: '',
      },
      forecast: [],
      forecastList: [],
      loaded: false
    }
  },
  actions: {
    async fetchOverview(code) {
      this.loaded = false
      const areaStore = useAreaStore()
      areaStore.code = code

      const url = `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${code}.json`
      try {
        const response = await fetch(url);
        this.overview = await response.json();
      } catch {
        this.$reset()
      } finally {
        this.loaded = true
      }
    },
    async fetchForecast(code) {
      this.loaded = false
      const areaStore = useAreaStore()
      areaStore.code = code

      const url = `https://www.jma.go.jp/bosai/forecast/data/forecast/${code}.json`
      try {
        const response = await fetch(url)
        this.forecast = await response.json()
      } catch {
        this.$reset()
      } finally {
        this.loaded = true
      }
    },
    async fetchForecastList() {
      this.loaded = false
      const url = `https://www.jma.go.jp/bosai/forecast/data/forecast/010000.json`
      try {
        const response = await fetch(url)
        this.forecastList = await response.json()
      } catch {
        this.$reset()
      } finally {
        this.loaded = true
      }
    }
  }
})

ストアに loaded プロパティ(ステート)を追加しています。
このプロパティが、API の通信中は false となるように、各アクションに設定を追加しています。

以下、このプロパティを、各コンポーネントに適用していきます。

② Home コンポーネントの修正

src\views\Home.vue ファイルを開き、以下のように v-if="forecastStore.loaded" を追加します。

vue_10-2-85.png

コードをテキストで表示
src\views\Home.vue
<script setup>
import { computed } from 'vue'
import { useForecastStore } from '../stores/forecast'
import WeekTable from '../components/WeekTable.vue'

const forecastStore = useForecastStore()
forecastStore.fetchForecastList()

const weatherList = computed(() => {
  // 週間予報データが取得できていなければ return
  if (forecastStore.forecastList.length === 0) return []

  /** 天気データを格納するMapオブジェクト */
  const list = new Map()

  // 全国の地域ごとにループ処理
  forecastStore.forecastList.forEach((forecast) => {
    /** 1地域分のデータ */
    const areaData = []

    // 短期予報(Short-Range Forecast)のデータ処理
    const srf = forecast.srf.timeSeries

    // 2日間の降水確率を取得する
    const pops = srf[1].areas.pops.concat() // 降水確率データをコピー
    pops.unshift(...new Array(8 - pops.length).fill('-')) // 8 要素にする

    // 2日間の温度(最低/最高)を取得する
    const temps = srf[2].areas.temps.concat() // 温度データをコピー
    temps.unshift(...new Array(4 - temps.length).fill('-')) // 4 要素にする
    temps[0] = '-' // 1日目の最低温度は常に非表示

    // 今日・明日の 2 日分のデータをループ処理
    for (let j = 0; j < 2; j++) {
      areaData.push(
        {
          timeDefines: srf[0].timeDefines[j], // 時間
          areaName: forecast.name, // 地域名
          weatherCode: srf[0].areas.weatherCodes[j], // 天気コード
          pop: pops.slice(j * 4, j * 4 + 4).join('/'), // 降水確率
          reliability: '-', // 信頼度(短期予報は値無し)
          tempMin: temps[2 * j], // 最低温度
          tempMax: temps[2 * j + 1] // 最高温度
        }
      )
    }
  
    // 週間予報のデータ処理
    const week = forecast.week.timeSeries
    for (let j = 1; j < week[0].timeDefines.length; j++) {
      areaData.push(
        {
          timeDefines: week[0].timeDefines[j], // 時間
          areaName: forecast.name, // 地域名
          weatherCode: week[0].areas.weatherCodes[j], // 天気コード
          pop: week[0].areas.pops[j], // 降水確率
          // 信頼度(空文字の場合は '-' に変換)
          reliability: !week[0].areas.reliabilities[j] ? '-' : week[0].areas.reliabilities[j],
          tempMin: week[1].areas.tempsMin[j], // 最低温度
          tempMax: week[1].areas.tempsMax[j] // 最高温度
        }
      )
    }
    list.set(forecast.officeCode, areaData)
  })
  return list
})
</script>

<template>
  <div class="wrap">
    <h4 class="heading">全国の週間天気予報</h4>
    <template v-if="forecastStore.forecastList.length > 0">
      <WeekTable :weatherList="weatherList" />
    </template>
    <template v-else>
      <div v-if="forecastStore.loaded">全国の週間天気予報を取得できませんでした。</div>
    </template>
  </div>
</template>

<style lang="scss" scoped>
.wrap {
  padding: 48px;
  overflow: auto;
  height: calc(100vh - 98px);
}
.heading {
  margin-top: 0;
}
</style>

上の修正により、forecastStore.loadedfalse の場合には「○○が取得できませんでした。」の表示をしないようにしています。

③ Overview コンポーネントの修正

続いて、src\views\Overview.vue ファイルを開き、こちらも Home コンポーネントと同様に v-if="forecastStore.loaded" を追加します。

vue_10-2-86.png

コードをテキストで表示
src\views\Overview.vue
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAreaStore } from '../stores/area'
import { useForecastStore } from '../stores/forecast'
import { onBeforeRouteUpdate } from 'vue-router'

const route = useRoute()
const areaStore = useAreaStore()
const forecastStore = useForecastStore()

/** 地域名 */
const officeName = computed(() => {
  if (forecastStore.overview.targetArea !== '') {
    return forecastStore.overview.targetArea
  } else {
    return areaStore.officeName
  }
})

const weekDays = { 0: '日', 1: '月', 2: '火', 3: '水', 4: '木', 5: '金', 6: '土' };
/** 発表日時 */
const reportDateTimeDisplay = computed(() => {
  if (!forecastStore.overview.reportDatetime) return '' // 日時が取得できない場合は return
  const d = new Date(forecastStore.overview.reportDatetime) // 日時を Date 型に変換
  const date = `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日` // 年月日
  const weekDay = weekDays[d.getDay()] // 曜日
  const time = `${d.getHours()}時${d.getMinutes()}分` // 時間
  return `${date}(${weekDay}) ${time}` // 年月日・曜日・時間を連結して返却
})

forecastStore.fetchOverview(route.params.code)
onBeforeRouteUpdate(async (to) => {
  forecastStore.fetchOverview(to.params.code)
})
</script>

<template>
  <div class="wrap">
    <h4 class="heading">{{ officeName }} 天気概況</h4>
    <template v-if="forecastStore.overview.reportDatetime">
      <table>
        <tr><th>発表日時</th><td>{{ reportDateTimeDisplay }}</td></tr>
        <tr><th>天気概況</th><td>{{ forecastStore.overview.text }}</td></tr>
      </table>
    </template>
    <template v-else>
      <p v-if="forecastStore.loaded">{{ officeName }} の天気概況を取得できませんでした。</p>
    </template>
  </div>
</template>

<style lang="scss" scoped>
.wrap {
  padding: 48px;
  overflow: auto;
  height: calc(100vh - 98px);
}
.heading {
  margin-top: 0;
}
tr {
  vertical-align: top;
  text-align: left;
  white-space: pre-wrap;
  th {
    min-width: 80px;
    padding: 8px;
  }
  td {
    padding: 8px;
  }
}
</style>

④ Week コンポーネントの修正

src\views\Week.vue ファイルにも v-if="forecastStore.loaded" を追加します。

vue_10-2-87.png

コードをテキストで表示
src\views\Week.vue
<script setup>
import { computed } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
import { useAreaStore } from '../stores/area'
import { useForecastStore } from '../stores/forecast'
import { CODE_CONVERSION } from '../constant'
import WeekTable from '../components/WeekTable.vue'

const route = useRoute()
const areaStore = useAreaStore()
const forecastStore = useForecastStore()

forecastStore.fetchForecast(route.params.code)
onBeforeRouteUpdate(async (to) => {
  forecastStore.fetchForecast(to.params.code)
})

const weatherList = computed(() => {
  // 週間予報データが取得できていなければ return
  if (forecastStore.forecast.length === 0) return []

  /** 天気データを格納するMapオブジェクト */
  const list = new Map()

  /** 短期予報 1 つ目のエリアの地域コード */
  let defaultCode = ''

  // 短期予報(Short-Range Forecast)のデータ処理
  const srf = forecastStore.forecast[0].timeSeries
  // 地域(Area)数分ループする
  for (let i = 0; i < srf[0].areas.length; i++) {
    /** Mapオブジェクトのキーとなる地域コード */
    const code = srf[0].areas[i].area.code
    if (i === 0) defaultCode = code

    // 2日間の降水確率を取得する
    const pops = srf[1].areas[i].pops.concat() // 降水確率データをコピー
    pops.unshift(...new Array(8 - pops.length).fill('-')) // 8 要素にする

    // 2日間の温度(最低/最高)を取得する
    const temps = srf[2].areas[i].temps.concat() // 温度データをコピー
    temps.unshift(...new Array(4 - temps.length).fill('-')) // 4 要素にする
    temps[0] = '-' // 1日目の最低温度は常に非表示

    /** 1地域分のデータ(最初は短期予報の2日分を入れる) */
    const areaData = []
    // 今日・明日の 2 日分のデータをループ処理
    for (let j = 0; j < 2; j++) {
      areaData.push(
        {
          timeDefines: srf[0].timeDefines[j], // 時間
          areaName: srf[0].areas[i].area.name, // 地域名
          weatherCode: srf[0].areas[i].weatherCodes[j], // 天気コード
          pop: pops.slice(j * 4, j * 4 + 4).join('/'), // 降水確率
          reliability: '-', // 信頼度(短期予報は値無し)
          tempMin: temps[2 * j], // 最低温度
          tempMax: temps[2 * j + 1] // 最高温度
        }
      )
    }
    list.set(code, areaData)
  }

  // 週間予報のデータ処理
  const week = forecastStore.forecast[1].timeSeries
  for (let i = 0; i < week[0].areas.length && i < srf[0].areas.length; i++) {
    /** Mapオブジェクトのキーとなる地域コード */
    let code = week[0].areas[i].area.code
    // 該当する地域コードがない場合
    if (!list.has(code)) {
      code = code in CODE_CONVERSION ? CODE_CONVERSION[code] : defaultCode
    }

    for (let j = 1; j < week[0].timeDefines.length; j++) {
      list.get(code).push(
        {
          timeDefines: week[0].timeDefines[j], // 時間
          areaName: '', // 地域名(短期予報の地域名を使用のため不要)
          weatherCode: week[0].areas[i].weatherCodes[j], // 天気コード
          pop: week[0].areas[i].pops[j], // 降水確率
          // 信頼度(空文字の場合は '-' に変換)
          reliability: !week[0].areas[i].reliabilities[j] ? '-' : week[0].areas[i].reliabilities[j],
          tempMin: week[1].areas[i].tempsMin[j], // 最低温度
          tempMax: week[1].areas[i].tempsMax[j] // 最高温度
        }
      )
    }
  }
  return list
})
</script>

<template>
  <div class="wrap">
    <h4 class="heading">{{ areaStore.officeName }} 週間天気予報</h4>
    <template v-if="forecastStore.forecast.length > 0">
      <WeekTable :weatherList="weatherList" />
    </template>
    <template v-else>
      <div v-if="forecastStore.loaded">{{ areaStore.officeName }} の週間天気予報を取得できませんでした。</div>
    </template>
  </div>
</template>

<style lang="scss" scoped>
.wrap {
  padding: 48px;
  overflow: auto;
  height: calc(100vh - 98px);
}
.heading {
  margin-top: 0;
}
</style>

⑤ テストを再実行する

修正が終わったら、再度テストを実行してみましょう。
ターミナルを開き、以下のコマンドでアプリケーションを再ビルドします。

npm run build

次に、npm run test:e2e コマンドでテストを再実行します。

npm run test:e2e

テストが完了したら cypress\videos\page-transition.cy.js.mp4 ファイルが新しい動画に更新されています。
次のように「○○を取得できませんでした。」の表示がされなくなっていれば OK です。

今回の修正は「単に画面に表示させない」という簡素な方法を取りました。
実際の開発では「読み込み中」の文言や「スケルトンローダー」を表示する場合などが多いです。
興味がある方は、ご自身で調べて修正をしてみてください。

8-3. 検索ボックスからのページ遷移のテスト

次に、検索ボックスからページ遷移を行う場合についてテストを行っていきます。

① テストコードの記述

cypress\e2e\page-transition.cy.js ファイルを開いて、次の赤枠部分を追加します。
1 つ目の it の記述は折り畳んでいますので、行数の表示を見ながら追加してください。

vue_10-2-88.png

コードをテキストで表示
cypress\e2e\page-transition.cy.js
describe('ページ遷移テスト', () => {
  it('左メニューページ遷移', () => {
    cy.visit('/')

    // 左メニュー表示(左メニューが非表示であれば)
    if (cy.get('.q-drawer').should('not.be.visible')) {
      cy.get('button').click()
    }

    // 左メニューの青森県をクリック
    cy.get('.area-item').contains('青森県').click()
    // 青森県 天気概況が表示されているかを確認
    cy.contains('h4', '青森県 天気概況').should('exist')

    // ヘッダーメニューの週間予報タブをクリック
    cy.get('header').find('a').contains('週間予報').click()
    // 青森県 週間天気予報が表示されているかを確認
    cy.contains('h4', '青森県 週間天気予報').should('exist')
  })

  it('検索ボックスページ遷移', () => {
    cy.visit('/')

    // 検索ボックスから岩手県を選択してページ遷移
    cy.get('input').type('岩')
    cy.get('.q-menu').contains('岩手県').click()
    // 岩手県 天気概況が表示されているかを確認
    cy.contains('h4', '岩手県 天気概況').should('exist')

    // 左メニューから青森県を選択してページ遷移
    if (cy.get('.q-drawer').should('not.be.visible')) {
      cy.get('button').click()
    }
    cy.get('.area-item').contains('青森県').click()
    // 青森県 天気概況が表示されているかを確認
    cy.contains('h4', '青森県 天気概況').should('exist')

    // 再度、検索ボックスから岩手県を選択してページ遷移
    cy.get('input').type('岩')
    cy.get('.q-menu').contains('岩手県').click()
    // 岩手県 天気概況が表示されているかを確認
    cy.contains('h4', '岩手県 天気概況').should('exist')
  })
})

追加したテストコードの内容は次のとおりです。

cypress\e2e\page-transition.cy.js
it('検索ボックスページ遷移', () => {
  cy.visit('/')

  // 検索ボックスから岩手県を選択してページ遷移
  cy.get('input').type('岩')
  cy.get('.q-menu').contains('岩手県').click()
  // 岩手県 天気概況が表示されているかを確認
  cy.contains('h4', '岩手県 天気概況').should('exist')

  // 左メニューから青森県を選択してページ遷移
  if (cy.get('.q-drawer').should('not.be.visible')) {
    cy.get('button').click()
  }
  cy.get('.area-item').contains('青森県').click()
  // 青森県 天気概況が表示されているかを確認
  cy.contains('h4', '青森県 天気概況').should('exist')

  // 再度、検索ボックスから岩手県を選択してページ遷移
  cy.get('input').type('岩')
  cy.get('.q-menu').contains('岩手県').click()
  // 岩手県 天気概況が表示されているかを確認
  cy.contains('h4', '岩手県 天気概況').should('exist')
})

② テストコードの説明

記述したテストコードにつき、簡単に説明をします。

・検索ボックスから岩手県の天気概況を表示
まず、cy.get('input').type('岩') で、検索ボックスに「岩」の文字を入力して地域名を検索し、 cy.get('.q-menu').contains('岩手県').click() で「岩手県」をクリックします。
続いて、cy.contains('h4', '岩手県 天気概況').should('exist') で「岩手県 天気概況」の表示がされているかを判定(評価)します。

・左メニューから青森県の天気概況を表示
次に、左メニューを表示の上、青森県をクリックしてページ遷移しています。
また、ページ遷移後に「青森県 天気概況」が正しく表示されているかを確認しています。

・再度、検索ボックスから岩手県の天気概況を表示
最後に、再度、検索ボックスから岩手県の天気概況を表示しています。

以上の操作により、「(1) 検索ボックスでページ遷移」→「(2) 左メニューでページ遷移」→「(3) 検索ボックスでページ遷移」という流れで、正しくページ遷移ができるかを検証しています。

③ テストを実行する

それでは、テストを実行します。
npm run test:e2e:dev コマンドから Cypress アプリを開いて Chrome を選択します。
以下のように、ブラウザに表示されたファイル名から、page-transition.cy.js ファイルをクリックして、テストを実行してください。

vue_10-2-89.png

実行すると、テストコードの最後のところ(42行目)で、次のようなエラーが生じます。

vue_10-2-90.png

エラーメッセージは「h4 セレクター内に '岩手県 天気概況' が見つかりません」というものです。

表示画面の方を確認すると、岩手県ではなく「青森県 天気概況」の画面が表示されていることが分かります。

vue_10-2-91.png

これは、「(1) 検索ボックスでページ遷移」→「(2) 左メニューでページ遷移」→「(3) 検索ボックスでページ遷移」という流れの中で、(1) と (2) の処理は成功したが、(3) で失敗したという結果となっています。

テスト結果が確認できたら、Cypress アプリを閉じて、一旦、テストを終了しておきます。

8-4. テスト結果の検証

① page-transition.cy.js ファイルの確認

さて、先ほどのテストでは、page-transition.cy.js ファイルの 42 行目でエラーが生じていました。
実際のコードを見ると、以下の 40 行目の「岩手県」のクリック(青枠部分)は成功したが、42 行目の文字列「岩手県 天気概況」の表示確認(赤枠部分)を失敗した、ということになります。

vue_10-2-92.png

以上より「クリックはできたが、ページ遷移のイベントが正しく発火していなかった」などの原因が推測されます。

② App.vue ファイルの確認

クリック時のイベントは、App.vue コンポーネントで定義しましたので、そちらを確認してみます。

vue_10-2-93.png

イベントをキャッチしているのは @update:model-value のところです。
これは v-model の値が変化したときのみイベントが発火するようになっています。

v-model の値である selected の状態は、テストケースに当てはめると次のようになっています。

No 操作 selected の値の変化 説明
(1) 検索ボックスで
岩手県をクリック
null'030000' 検索結果選択時に、岩手県の地域コード 030000 に更新
(2) 左メニューから
青森県をクリック
'030000' で変化なし selected に変化はないためイベントは発火せず
(3) 検索ボックスで
岩手県をクリック
'030000' で変化なし selected に変化はないためイベントは発火せず

結論を言うと、最後の (3) で、検索ボックスから岩手県をクリックした際には、selected の変化はなく、イベントが発火していないということになります。
以上を踏まえて、次の項で、天気予報アプリのコードを修正していきます。

8-5. 検索ボックスのイベント処理の修正

ここでは、一番簡単と思われる修正を行います。
App.vue ファイルを開いて、次の赤枠部分のところを修正してください。

vue_10-2-94.png

全てのコードをテキストで表示
src\App.vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAreaStore } from './stores/area'

const route = useRoute()
const router = useRouter()
const areaStore = useAreaStore()
areaStore.fetchArea()

const selected = ref(null)
const options = ref([])

const filterFn = (val, update) => {
  update(() => {
    options.value = areaStore.officeArray.filter(v => v.name.indexOf(val) > -1)
  })
}

const updateSelect = (code) => {
  movePage(code)
  selected.value = null
}

const movePage = (code) => {
  const name = route.name === 'week' ? 'week' : 'overview'
  router.push({ name, params: { code } })
}

const leftDrawerOpen = ref(false)
const toggleLeftDrawer = () => leftDrawerOpen.value = !leftDrawerOpen.value
</script>

<template>
  <q-layout view="hHh lpR fFf">

    <q-header elevated class="bg-primary text-white" height-hint="98">
      <q-toolbar>
        <q-btn dense flat round icon="menu" @click="toggleLeftDrawer" />

        <div class="row justify-between items-center full-width">
          <q-toolbar-title class="app-title">
            <q-avatar>
              <img src="https://cdn.quasar.dev/logo-v2/svg/logo-mono-white.svg">
            </q-avatar>
            天気予報アプリ
          </q-toolbar-title>
          <div class="row items-center">
            <span>地域検索: </span>
            <q-select
              outlined
              v-model="selected"
              dense
              options-dense
              use-input
              hide-selected
              input-debounce="0"
              :options="options"
              option-value="code"
              option-label="name"
              emit-value
              map-options
              @filter="filterFn"
              style="width: 250px;"
              bg-color="grey-2"
              @update:model-value="updateSelect"
            >
              <template v-slot:no-option>
                <q-item>
                  <q-item-section class="text-grey">
                    検索結果なし
                  </q-item-section>
                </q-item>
              </template>
            </q-select>
          </div>
        </div>
      </q-toolbar>

      <q-tabs align="left">
        <q-route-tab to="/" label="ホーム" />
        <q-route-tab :to="`/overview/${areaStore.code}`" label="天気概況" />
        <q-route-tab :to="`/week/${areaStore.code}`" label="週間予報" />
      </q-tabs>
    </q-header>

    <q-drawer show-if-above v-model="leftDrawerOpen" side="left" bordered>
      <div
        v-for="[code, name] in areaStore.offices"
        :key="code"
        @click="movePage(code)"
        class="area-item"
      >
        {{ name }}
      </div>
    </q-drawer>

    <q-page-container>
      <router-view />
    </q-page-container>

  </q-layout>
</template>

<style lang="scss" scoped>
.area-item {
  line-height: 28px;
  padding: 2px 16px;
  cursor: pointer;
  &:hover {
    background-color: #EEEEEE;
  }
}
</style>

検索ボックスからページ遷移するたびに、selected の値を null に初期化するという処理を追加しただけです。
これにより、検索ボックスから地域名を選択した際は、必ず selected の値が変化することになります。

8-6. テストの再実行

コードの修正が終わりましたら、テストの再実行を行いましょう。
ターミナルを開き、npm run build コマンドでアプリケーションを再ビルドし、ビルドが終わったら npm run test:e2e コマンドでテストを実行します(下図)。

vue_10-2-95.png

正しくコードの修正ができていれば、次のようにテストが成功するはずです。

vue_10-2-96.png

cypress\videos\page-transition.cy.js.mp4 ファイルが新しい動画に更新されていますので、再生してみてください。

vue_10-2-97.png

動画の内容が、次のようになっていれば OK です。

以上、Cypress の基本的な使い方について学習をしました。
今回のテストコードは、あらかじめ把握してあるエラー(バグ)が出るようにピンポイントで作成しましたが、実際のテストにおいては「テスト仕様書」などを用意して、様々なケースを漏れなく網羅することが必要となります。
テストの使用や設計の概念については、ここでは取り上げませんが、興味がある方はご自身で調べるなどしていただければと思います。

以上で、Vue の講座は完了となります。
おつかれさまでした。