原文:zh.annas-archive.org/md5/f9404aca1fb3b78cb6f2f92bcb2c199d

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:10. 使用 Vuex – 获取远程数据

概述

在本章中,您将学习如何使用Axios库与远程 API 一起工作。您将进行网络调用并使用 Vuex 存储结果。您还将看到一个如何使用 Vuex 存储身份验证令牌并用于后续 API 调用的示例。

到本章结束时,您将了解 Vuex 如何帮助抽象并创建远程 API 的包装器,并简化它们集成到 Vue 应用程序中的过程。这种抽象使得将来迁移到其他 API 变得更容易,确保您的应用程序的其他部分继续正常工作。

简介

第九章使用 Vuex – 状态、获取器、动作和突变中,您被介绍了 Vuex,并看到了多个如何与之交互的示例,以从存储中读取数据并向存储写入数据。我们看到了多个组件如何与存储一起工作,并且在我们这边几乎不需要做任何工作就能保持同步。在本章中,我们将通过使用Axios(一个流行的开源库,使使用网络资源变得容易)将 Vuex 与远程数据集成来扩展我们的 Vuex 使用。让我们从对Axios的深入了解开始。

Axios(github.com/axios/axios)是一个具有asyncawait功能的 JavaScript 库。其他功能包括支持默认参数(对于每个调用都需要键的 API 很有用)以及转换您的输入和输出数据的能力。在本章中,我们不会涵盖每个用例,但您将了解如何为未来的项目使用Axios

为了明确,如果您不喜欢Axios,您不必使用它。您可以使用任何其他库,或者根本不使用库。Fetch API(developer.mozilla.org/en-US/docs/Web/API/Fetch_API)是一个现代浏览器 API,用于处理网络请求,虽然不如Axios强大,但不需要额外的库。

在下一节中,我们将探讨如何安装Axios

Axios 的安装

与 Vuex 类似,您有多种方法可以将Axios包含到项目中。最简单的方法是将指向库的内容分发网络CDN)的<script>标签粘贴到项目中:

<script src="img/axios.min.js"></script>

另一个选项是使用npm。在现有的 Vue 应用程序中,您可以按照以下方式安装Axios

npm install axios

一旦完成此操作,您的 Vue 组件就可以按照以下方式导入库:

import axios from 'axios';

您如何使用Axios将取决于您交互的 API。以下是一个简单的示例,用于调用一个假想的 API:

axios.get('https://www.raymondcamden.com/api/cats')
.then(res => {
  this.cats = res.data.results;
})
.catch(error => {
  console.error(error);
});

在前面的示例中,我们正在对一个虚构的 API 执行GET请求(GET是默认值),即https://www.raymondcamden.com/api/catsAxios返回 promises,这意味着我们可以使用thencatch链式处理结果和错误。结果 JSON(再次强调,这是一个虚构的 API)会自动解析,所以剩下的只是将结果分配给一个值,在这个例子中,是一个名为cats的值,用于我的 Vue 应用程序。

现在让我们看看使用Axios从 API 加载数据的逐步过程。

练习 10.01:使用 Axios 从 API 加载数据

让我们看看一个使用Axios的复杂示例。此示例将对星球大战 API 进行两次不同的 API 调用,并返回两个信息列表。目前,我们将跳过使用 Vuex,以使这个介绍更简单。

要访问此练习的代码文件,请参阅packt.live/3kbn1x1

  1. 创建一个新的 Vue 应用程序,CLI 完成之后,将Axios添加为npm依赖项:

    npm install axios
    
  2. 打开App.vue页面并添加对axios的导入:

    import axios from 'axios';
    
  3. 打开App.vue页面并为filmsships数组添加数据值:

    data() {
        return {
          films:[],
          ships:[]
        }
      },
    
  4. 打开App.vue并使用created方法从 API 加载filmsstarships

    created() {
        axios.get('https://swapi.dev/api/films')
        .then(res => {
          this.films = res.data.results;
        })
        .catch(error => {
          console.error(error);
        });
        axios.get('https://swapi.dev/api/starships')
        .then(res => {
          this.ships = res.data.results;
        })
        .catch(error => {
          console.error(error);
        });
      }
    
  5. 接下来,编辑模板以迭代值并显示它们:

        <h2>Films</h2>
        <ul>
          <li v-for="film in films" :key="film.url">
            {{ film.title }} was released in {{ film.release_date }}
          </li>
        </ul>
        <h2>Starships</h2>
        <ul>
          <li v-for="ship in ships" :key="ship.url">
            {{ ship.name }} is a {{ ship.starship_class }} 
          </li>
        </ul>
    

    注意

    错误处理是通过 catch 处理程序完成的,但只是发送到浏览器控制台。如果远程数据没有加载,最好告诉用户一些信息,但到目前为止,这是可以接受的。另一个建议是处理加载状态,您将在本章后面的示例中看到。

  6. 使用以下命令启动应用程序:

    npm run serve 
    

    在您的浏览器中打开 URL 将生成以下输出:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_01.jpg

图 10.1:浏览器中渲染的 API 调用结果

这个简单的示例展示了将Axios添加到 Vue 应用程序是多么容易。请记住,Axios不是 Vue 的必需品,您可以使用任何您想要的库,或者简单地使用浏览器本地的 Fetch API。

现在您已经看到了如何将Axios引入项目,让我们看看Axios的一个更酷的特性:指定默认值。

使用 Axios 的默认值

虽然练习 10.01中的使用 Axios 从 API 加载数据代码运行良好,但让我们考虑一个稍微复杂一点的例子。Axios的一个特性是能够设置在后续调用中使用的默认值。如果您查看前面代码中进行的两个调用,您可以看到它们是相似的。您可以更新created方法来利用这一点:

created() {
  const api = axios.create({
    baseURL:'https://swapi.dev/api/',
    transformResponse(data) {
      data = JSON.parse(data);
      return data.results;
    }
  });
  api.get('films')
  .then(res => this.films = res.data);
  api.get('starships')
  .then(res => this.ships = res.data);
}

在这个更新版本中,我们切换到Axios的一个实例。指定了一个默认的baseURL值,这样在后续操作中可以节省输入。接下来,使用transformResponse功能来转换响应。这让我们可以在数据发送到后续调用处理程序之前对其进行修改。由于所有 API 调用都返回一个结果值,而我们只关心这个值,所以我们通过只返回这个值而不是整个结果来简化事情。请注意,如果你想要构建一个复杂的转换集,Axios允许你在transformResponse中使用一个函数数组。

在下一节,我们将学习如何使用Axios与 Vuex 结合。

使用 Vuex 与 Axios 结合

现在你已经看到了使用Axios的基本方法,是时候考虑如何将它与 Vuex 结合使用了。一种简单的方法是直接使用 Vuex 来处理对 API 的调用封装,使用Axios来执行 HTTP 调用。

练习 10.02:在 Vuex 中使用 Axios

我们将使用之前的功能(加载filmsships数组)并在 Vuex 存储的上下文中重新构建它。和之前一样,你需要使用 CLI 来搭建一个新的应用,并确保你要求包含 Vuex。CLI 完成后,你可以使用npm命令添加Axios

这个练习将与我们在练习 10.01中构建的第一个应用非常相似,即使用 Axios 从 API 加载数据,但有一些细微的差别。让我们首先看看 UI。在初始加载时,FilmsShips都是空的:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_02.jpg

图 10.2:初始应用 UI

注意到Films部分有一个加载信息。一旦应用加载,我们将发起一个请求来获取这些数据。对于Ships,我们则等待用户明确请求他们想要这些数据。以下是films数组加载后的样子:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_03.jpg

图 10.3:应用的渲染电影

最后,在点击Load Ships按钮后,按钮将禁用(以防止用户多次请求数据),然后在数据加载完成后,整个按钮将被移除:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_04.jpg

图 10.4:所有内容加载完成后的最终视图

要访问这个练习的代码文件,请参考packt.live/32pUsWy

  1. 从第一个组件App.vue开始,编写 HTML。记住,films在组件中显示,但ships将在自己的组件中。使用v-else添加一个加载信息,这个信息将在Axios进行 HTTP 请求时显示:

    <template>
      <div id="app">
        <h2>Films</h2>
        <ul v-if="films.length">
          <li v-for="film in films" :key="film.url">
            {{ film.title }} was released in {{ film.release_date }}
          </li>
        </ul>
        <div v-else>
          <i>Loading data...</i>
        </div>
        <Ships />
      </div>
    </template>
    
  2. 现在添加必要的代码来加载和注册Ships组件:

    import Ships from './components/Ships.vue'
    export default {
      name: 'app',
      components: {
        Ships
      },
    
  3. 同时导入mapState

    import { mapState } from 'vuex';
    
  4. 接下来,添加代码将我们的存储中的films数组映射到一个本地的计算值。记住要导入mapState

    computed: {
        ...mapState(["films"])
      },
    
  5. 最后,使用created方法在我们的存储器中触发一个动作:

    created() {
      this.$store.dispatch('loadFilms');
    }
    
  6. 接下来,在components/Ship.vue中构建Ships组件。Ships组件也包含数据列表,但使用按钮让用户可以请求加载数据。按钮在完成时应该自动消失,并在加载过程中禁用:

    <template>
      <div>
        <h2>Ships</h2>
        <div v-if="ships.length"> 
          <ul>
            <li v-for="ship in ships" :key="ship.url">
              {{ ship.name }} is a {{ ship.starship_class }} 
            </li>
          </ul>
        </div>
        <button v-else @click="loadShips" :disabled="loading">Load       Ships</button>
      </div>
    </template>
    
  7. 添加处理ships状态映射的代码,并触发 Vuex 中的动作来加载ships

    <script>
    import { mapState } from 'vuex';
    export default {
      name: 'Ships',
      data() {
        return {
          loading:false
        }
      },
      computed: {
        ...mapState(["ships"])
      },
      methods:{
        loadShips() {
          this.loading = true;
          this.$store.dispatch('loadShips');
        }
      }
    }
    </script>
    
  8. 现在,构建存储器。首先,定义state来保存filmsships数组:

    import Vue from 'vue'
    import Vuex from 'vuex'
    import axios from 'axios'
    Vue.use(Vuex)
    export default new Vuex.Store({
      state: {
        films:[],
        ships:[]
      },
    
  9. 接下来,添加加载shipsfilms数据的动作。它们都应该使用mutations来将值赋给state

      mutations: {
        setFilms(state, films) {
          state.films = films;
        },
        setShips(state, ships) {
          state.ships = ships;
        }
      },
      actions: {
        loadFilms(context) {
          axios.get('https://swapi.dev/api/films')
          .then(res => {
            context.commit('setFilms', res.data.results);
          })
          .catch(error => {
            console.error(error);
          });
        },
        loadShips(context) {
          axios.get('https://swapi.dev/api/starships')
          .then(res => {
            context.commit('setShips', res.data.results);
          })
          .catch(error => {
            console.error(error);
          });
        }
      }
    })
    
  10. 使用以下命令运行您的应用程序:

    npm run serve
    

    您的输出将是以下内容:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_05.jpg

图 10.5:最终输出

总体而言,这并不是与没有 Vuex 的初始版本有巨大变化(如果我们忽略 UI 变化),但现在我们所有的 API 使用都由存储器处理。如果我们决定停止使用Axios并切换到 Fetch,这可以在这里完成。无论我们决定添加缓存系统还是存储数据以供离线使用,都可以在存储器中完成。通过运行npm run serve并在浏览器中打开 URL 来自行测试这个版本。

现在是时候将您所学到的知识应用到下一个活动上了!

活动十.01:使用 Axios 和 Vuex 进行身份验证

Vuex 的一个更有趣的功能是管理身份验证。我们这是什么意思?在许多 API 中,在使用服务之前需要身份验证。用户验证后,他们会被分配一个令牌。在未来的 API 调用中,令牌会随请求一起传递,通常作为头部信息,这会让远程服务知道这是一个授权用户。Vuex 可以为您处理所有这些,而Axios使得处理头部信息变得容易,所以让我们考虑一个实际操作的例子。

在本书中构建具有身份验证和授权的服务器远远超出了本书的范围,因此,我们将采取模拟的方式。我们将使用两个JSONBin.io,这是我们曾在第九章,使用 Vuex – 状态、获取器、动作和突变中使用的服务。第一个端点将返回一个令牌:

{
  "token": 123456789
}

第二个端点将返回一个cats数组:

[
  {
    "name": "Luna",
    "gender": "female"
  },
  {
    "name": "Pig",
    "gender": "female"
  },
  {
    "name": "Cracker",
    "gender": "male"
  },
  {
    "name": "Sammy",
    "gender": "male"
  },
  {
    "name": "Elise",
    "gender": "female"
  }
]

在这个活动中,我们将使用 Vue Router 来处理表示应用程序的两个视图,即登录界面和猫展示界面。

步骤

  1. 为应用程序的初始视图提供一个登录界面。它应该提示用户名和密码。

  2. 将登录凭证传递给端点并获取一个令牌。这部分将进行模拟,因为我们不是在构建一个完整的、真实的身份验证系统。

  3. 从远程端点加载猫,并将令牌作为身份验证头部传递。

初始输出应该是以下内容:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_06.jpg

图 10.6:初始登录界面

登录后,您将看到以下数据:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_07.jpg

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_10_07.jpg

图 10.7:登录后成功显示数据

注意

该活动的解决方案可以通过此链接找到。

摘要

在本章中,你学习了 Vuex 的一个重要用例——与远程 API 协同工作。远程 API 可以为你的应用程序提供大量的额外功能,有时对开发者的额外成本几乎为零。你看到了如何使用Axios使网络调用更简单,以及如何将 Vuex 的状态管理功能与之结合。最后,你将其与 Vue Router 结合,创建了一个简单的登录/授权演示。

在下一章中,我们将讨论如何使用模块构建更复杂的 Vuex 存储。

第十一章:11. 使用 Vuex – 组织更大的存储

概述

在本章中,你将学习如何更好地组织更大的 Vuex 存储。随着你的应用程序在复杂性和功能上的增长,你的存储文件可能变得难以操作。随着文件越来越大,甚至简单地找到东西也可能变成一项困难的任务。本章将讨论两种不同的方法来简化存储的组织,以便进行更简单的更新。第一种方法将要求你将代码拆分到不同的文件中,而第二种方法将使用更高级的 Vuex 功能,即模块。

简介

到目前为止,我们处理过的存储都很简单且简短。但是,正如众所周知的那样,即使是简单的应用程序随着时间的推移也会趋向于复杂化。正如你在前面的章节中学到的,你的存储可以包含一个state、一个getters的块、一个mutationsactions的块,以及你将在本章后面学到的内容,即modules

随着你的应用程序增长,拥有一个文件来管理你的 Vuex 存储(store)可能会变得难以管理。修复错误和更新新功能可能会变得更加困难。本章将讨论两种不同的方法来帮助管理这种复杂性并组织你的 Vuex 存储。为了明确,这些都是你可以做的可选事情来帮助管理你的存储。如果你的存储很简单,并且你希望保持这种状态,那也是可以的。你总是可以在将来使用这些方法,而且好处是,没有人需要知道你的存储之外的事情——他们将继续像以前一样使用 Vuex 数据。你可以将这些技巧作为一组工具保留在心中,以帮助你在应用程序需要升级时使用。让我们从最简单的方法,文件拆分,开始。

方法一 – 使用文件拆分

第一种方法,当然也是最简单的一种方法,就是简单地将你的各种 Vuex 部分的代码(如stategetters等)移动到它们自己的文件中。然后,这些文件可以被主 Vuex 存储import并正常使用。让我们考虑一个简单的例子:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    name:"Lindy", 
    favoriteColor: "blue",
    profession: "librarian"
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

这是从第九章中的第一个练习使用 Vuex – 状态、获取器、动作和突变中来的,并且是一个只有三个状态值的存储。要将状态迁移到新文件,你可以在store文件夹中创建一个名为state.js的新文件,并按照如下设置:

export default {
  name: 'Lindy',
  favoriteColor: 'blue',
  profession: 'librarian'
}

然后,回到你的存储中,将其修改为import并使用代码:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
export default new Vuex.Store({
  state,
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

虽然这个例子最终变成了更多的代码行,但你可以看到我们是如何开始将存储的不同部分分离到不同的文件中,以便更容易更新的。让我们考虑一个稍微大一点的例子,再次参考第九章中的第二个练习,使用 Vuex – 状态、获取器、动作和突变。以下是原始存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    firstName: "Lindy",
    lastName: "Roberthon"
  },
  getters: {
    name(state) {
      return state.firstName + ' ' + state.lastName;
    }
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

这个例子只使用了state值和一个getter,但让我们将它们都移动到新文件中。首先,让我们将state移动到名为state.js的文件中:

export default {
  firstName: 'Lindy',
  lastName: 'Roberthon'
}

接下来,让我们将 getters 移入一个名为 getters.js 的文件中:

export default {
  name(state) {
    return state.firstName + ' ' + state.lastName;
  }
}

现在我们可以更新存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import state from './state.js';
import getters from './getters.js';
export default new Vuex.Store({
  state,
  getters,
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

将相同类型的更新应用于 mutationsactions 将遵循完全相同的模式,并且显然,你不必拆分一切。例如,你可以将状态值保留在主文件中,但只拆分你的函数(gettersmutationsactions)。

练习 11.01:使用文件拆分

在这个练习中,我们将在一个稍微大一点的 Vue store 中使用文件拆分。说实话,它并不大,但我们将会使用文件拆分来处理 stategettersmutationsactions

要访问此练习的代码文件,请访问 packt.live/32uwiKB

  1. 生成一个新的 Vue 应用程序并添加 Vuex 支持。

  2. 修改默认存储的 index.js 文件(位于 src/store/index.js),导入我们将创建的四个文件来表示存储:

    import Vue from 'vue'
    import Vuex from 'vuex'
    Vue.use(Vuex)
    import state from './state.js';
    import getters from './getters.js';
    import mutations from './mutations.js';
    import actions from './actions.js';
    export default new Vuex.Store({
      state,
      getters,
      mutations,
      actions,
      modules: {
      }
    })
    
  3. 编辑新的 state.js 文件,添加姓名和姓氏的值,代表该人拥有的猫和狗数量的数字,以及一个最爱电影:

    export default {
            firstName: 'Lindy',
            lastName: 'Roberthon',
            numCats: 5,
            numDogs: 1,
            favoriteFilm:''
    }
    
  4. 添加一个 getter.js 文件来定义全名和宠物总数的 getter

    export default {
            name(state) {
                    return state.firstName + ' ' +state.lastName
            },
            totalPets(state) {
                    return state.numCats + state.numDogs
            }
    }
    
  5. 接下来,添加一个 mutations.js 文件来添加猫和狗的数量,设置姓名和姓氏,以及添加最爱电影:

    export default {
            addCat(state) {
                state.numCats++;
            },
            addDog(state) {
                state.numDogs++;
            },
            setFirstName(state, name) {
                if(name !== '') state.firstName = name;
            },
            setLastName(state, name) {
                if(name !== '') state.lastName = name;
            },
            setFavoriteFilm(state, film) {
                if(film !== '') state.favoriteFilm = film;
            }
    }
    
  6. 最后,添加 actions.js 文件来定义一个动作,updateFavoriteFilm。这将向 Star Wars API 发起网络请求,以确保只有当新的最爱电影是《星球大战》电影时才允许:

    export default {
        async updateFavoriteFilm(context, film) {
            try {
                let response = await fetch('https://swapi.dev/api/films?search='+encodeURIComponent(film));
                let data = await response.json();
                if(data.count === 1) context.commit               ('setFavoriteFilm', film);
                else console.log('Ignored setting non-Star Wars               film '+film+' as favorite.'); 
            } catch(e) {
                console.error(e);
            }
        }
    }
    
  7. 要看到它的实际效果,更新 src/App.vue 以访问存储的各个部分。这一步的唯一目的是强调你使用存储的方式并没有改变:

    <template>
      <div id="app">
        My first name is {{ $store.state.firstName }}.<br/>
        My full name is {{ $store.getters.name }}.<br/>
        I have this many pets - {{ $store.getters.totalPets }}.<br/>
        My favorite film is {{ $store.state.favoriteFilm }}.
      </div>
    </template>
    <script>
    export default {
      name: 'app',
      created() {
        this.$store.dispatch('updateFavoriteFilm', 'A New Hope');
      }
    }
    </script>
    

    上述代码将生成如下输出:

    ![图 11.1:新组织存储的输出

    ![img/B15218_11_01.jpg]

图 11.1:新组织存储的输出

你现在已经看到了一个(相对简单)的例子,使用文件拆分来管理 Vuex 存储的大小。虽然功能与之前看到的不同,但随着你的应用程序的增长,你可能会发现添加和修复要容易得多。

第二种方法 – 使用模块

在先前的方法中,我们主要只是将代码行移动到其他文件中。正如我们所说的,虽然这使处理存储本身变得更容易,但它并没有改变 Vue 组件使用存储的方式。模块帮助我们处理组件级别的复杂性。

想象一个包含许多不同值的 state 对象,例如这个:

state: {
  name:"Lindy", 
  favoriteColor: "blue", 
  profession: "librarian", 
  // lots more values about Lindy
  books: [
    { name: "An Umbrella on Fire", pages: 283 },
    { name: "Unicorn Whisperer", pages: 501 },
    // many, many more books
  ],
  robots: {
    skill:'advanced',
    totalAllowed: 10,
    robots: [
      { name: "Draconis" },
      // so much robots 
    ]
  }
}

这个例子包含了关于一个人的信息,与书籍相关的数据,以及代表机器人的值集。这是一大批数据,涵盖了三个独特不同的主题。将这些内容移入单独的文件并不一定能使使用变得更简单或有助于保持组织有序。这种复杂性也会影响到gettersmutationsactions。给定一个名为setName的操作,你可以假设它适用于代表个人的状态值,但如果其他状态值有类似的名字,可能会开始变得混乱。

这就是模块的作用。一个模块允许我们定义一个完全独立的stategettersmutationsactions,与或核心存储完全分离。

下面是一个使用resume模块的示例存储:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {
    firstName:'Raymond',
    lastName:'Camden'
  },
  getters: {
    name(state) {
      return state.firstName + ' ' + state.lastName;
    }
  },
  modules: {
    resume: {
      state: {
        forHire:true,
        jobs: [
          "Librarian", 
          "Jedi",
          "Cat Herder"
        ]
      },
      getters: {
        totalJobs(state) {
          return state.jobs.length;
        }
      }
    }
  }
})

stategetters也可以公开mutationsactions。注意在resume模块的getters中,totalJobsstate变量引用的是它自己的状态,而不是父状态。这是非常好的,因为它确保你可以在模块内部工作,而不用担心意外修改根或其他模块中的某个值。你可以在getters中使用一个新的第三个参数rootState来访问根状态:

totalJobs(state, anyArgument, rootState)

动作可以通过上下文对象context.rootState使用rootState。然而,从理论上讲,你的模块应该关注它们自己的数据,并且只有在必要时才向外扩展到根状态。

当使用模块值时,你的代码必须知道模块的名称。考虑以下示例:

first name {{ $store.state.firstName }}<br/>
for hire? {{ $store.state.resume.forHire }}<br/>

gettersactionsmutations并没有被区分。这就是你访问getters的方式:

full name {{ $store.getters.name }}<br/>
total jobs {{ $store.getters.totalJobs }}<br/>

这个想法背后的目的是允许一个模块或多个模块可能对相同的调用做出响应。如果你不喜欢这个,你可以使用namespaced选项:

modules: {
  resume: {
    namespaced: true,
    state: {
      forHire:true,
      jobs: [
        "Librarian", 
        "Jedi",
        "Cat Herder"
      ]
    },
    getters: {
      totalJobs(state) {
        return state.jobs.length;
      }
    }
  }
}

然后要引用此模块的gettersmutationsactions,你必须将模块的名称作为调用的一部分传递。例如,现在的 getter 变成了:$store.getters['resume/totalJobs']

大部分来说,这是模块支持的核心,但请注意,还有更多关于模块如何全局暴露自己的选项,这些选项超出了本书的范围。请参阅模块文档的后半部分(vuex.vuejs.org/guide/modules.html)以获取相关示例。最后,请注意,你可以根据需要将模块嵌套在模块中,Vuex 允许这样做!

练习 11.02:利用模块

在这个练习中,我们将与一个 Vuex 存储库一起工作,它使用不止一个模块,为了使它更有趣,其中一个模块将存储在另一个文件中,这表明我们在使用模块时也可以使用第一种方法。

要访问此练习的代码文件,请访问packt.live/35d1zDv

  1. 如同往常,生成一个新的 Vue 应用程序,并确保你添加了 Vuex。

  2. store/index.js存储文件中,为姓氏和名字添加两个state值,并添加一个 getter 来返回两者:

      state: {
        firstName:'Raymond',
        lastName:'Camden'
      },
      getters: {
        name(state) {
          return state.firstName + ' ' + state.lastName;
        }
      },
    
  3. 接下来,向store文件添加一个resume模块。它将有两个state值,一个表示可雇佣值,另一个是一个表示过去工作的数组。最后,添加一个 getter 来返回工作的总数:

      modules: {
        resume: {
          state: {
            forHire:true,
            jobs: [
              "Librarian", 
              "Jedi",
              "Cat Herder"
            ]
          },
          getters: {
            totalJobs(state) {
              return state.jobs.length;
            }
          }
        },
    
  4. 现在为下一个模块创建一个新的文件,store/portfolio.js。这将包含一个表示已工作的网站数组的state值和一个添加值的mutation

    export default {
            state: {
                websites: [
                    "https://www.raymondcamden.com",
                    "https://codabreaker.rocks"
                ]
            },
            mutations: {
                addSite(state, url) {
                    state.websites.push(url);
                }
            }
    }
    
  5. 在主存储的index.js文件中,导入portfolio

    import portfolio from './portfolio.js';
    
  6. 然后将portfolio添加到模块列表中,在resume之后:

      modules: {
        resume: {
          state: {
            forHire:true,
            jobs: [
              "Librarian", 
              "Jedi",
              "Cat Herder"
            ]
          },
          getters: {
            totalJobs(state) {
              return state.jobs.length;
            }
          }
        },
        portfolio
      }
    
  7. 现在,让我们在我们的主src/App.vue文件中使用这些模块。修改模板以添加对存储中各个部分的调用:

        <p>
        My name is {{ $store.getters.name }} and I 
        <span v-if="$store.state.resume.forHire">
            am looking for work!
        </span><span v-else>
            am not looking for work.
        </span>
        </p>
        <p>
          I've had {{ $store.getters.totalJobs }} total jobs. 
        </p>
        <h2>Portfolio</h2>
        <ul>
          <li 
            v-for="(site,idx) in $store.state.portfolio.websites"
            :key="idx"><a :href="site" target="_new">{{ site }}</a></li>
        </ul>
    
  8. 然后添加一个表单,以便我们可以添加一个新网站

        <p>
          <input type="url" placeholder="New site for portfolio"         v-model="site">
          <button @click="addSite">Add Site</button>
        </p>
    
  9. 定义addSite方法的函数。它将提交mutation并清除站点值。务必为站点添加一个本地数据值。以下是完整的脚本块:

    export default {
      name: 'app',
      data() {
        return {
          site:''
        }
      },
      methods: {
        addSite() {
          this.$store.commit('addSite', this.site);
          this.site = '';
        }
      }
    }
    

    结果将如下所示:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_11_02.jpg

    ](https://davestewart.github.io/vuex-pathify/)

图 11.2:使用模块利用 Vuex 数据的应用程序

现在你已经看到了另一种帮助管理你的 Vuex 存储的方法。模块提供了一种更深入、更复杂的组织存储的方式。一如既往,选择最适合你的应用程序需求以及你和你的团队最舒适的方法!

组织 Vuex 存储的其他方法

虽然前两种方法应该为你提供一些管理 Vuex 存储的好选项,但你可能还想考虑其他一些选项。

Vuex Pathify

Vuex Pathify([davestewart.github.io/vuex-pathify/](https://davestewart.github.io/vuex-pathify/))是一个实用工具,它使得通过resumestatejobs访问 Vuex 存储变得更加容易:store.get('resume/jobs')。基本上,它为读取和写入存储中的值以及简化同步创建了一个快捷方式。XPath 的爱好者会喜欢这个。

Vuex 模块生成器(VMG)

statemutationsactions。任何在 Web 开发领域工作过一段时间的人都会熟悉 CRUD 模式,并且绝对会为不必再次编写这些函数而感到高兴。

查看 GitHub 仓库(github.com/abdullah/vuex-module-generator)以获取更多详细信息及示例应用程序。

Vuex ORM

ORM库添加到 Vuex 存储中。ORM代表对象关系映射,是一种帮助简化对象持久化的模式。像 VMG 一样,Vuex ORM 旨在简化 Web 开发者必须编写的相对常见的 CRUD 任务。

Vuex ORM 允许你定义代表你的存储数据结构的类。一旦你定义了数据结构,Vuex ORM 就提供了实用函数,使得在存储中存储和检索数据变得更加简单。它甚至处理数据之间的关系,例如属于它的 cat 对象。

下面是如何定义一种数据类型的示例:

class Cat extends Model {
  static entity = 'cats'
  static fields () {
    return {
      id: this.attr(null),
      name: this.string(''),
      age: this.number(0),
      adoptable: this.boolean(true)
    }
  }
}

在前面的课程中,为 Cat 类定义了四个属性:idnameageadoptable。对于每个属性,都指定了默认值。一旦定义,请求所有数据就像 Cat.all() 一样简单。Vuex ORM 还有更多内容,你可以在 vuex-orm.github.io/vuex-orm/ 上查看。

活动 11.01:简化 Vuex 存储

这个活动将与你之前做过的活动略有不同。在这个活动中,你将使用一个 现有 的应用,该应用使用 Vuex,并应用本章中学到的某些技术来简化存储,使其在未来更新中更容易使用。这在进行功能调整或修复时可能非常有用。

步骤:

  1. 要开始这个活动,你将使用位于 Chapter11/activity11.01/initial 的完成示例(packt.live/3kaqBHH)。

  2. 修改存储文件,将 stategettersmutations 放入它们自己的文件。

  3. 修改 state,使 cat 值位于 module 中。

  4. 将与猫相关的 getter 迁移到 module

  5. 更新 App.vue 文件,使其仍然正确显示最初的数据。

    这是构建后的样子:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_11_03.jpg

图 11.3:活动的最终输出

注意

这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,你学习了多种不同的技术来为你的 Vuex 存储准备增长复杂性。你首先学习了如何将逻辑移动到单独的文件并在你的存储中包含它们。然后你学习了模块以及它们是如何通过存储暴露给组件的。最后,你学习了可能使 Vuex 使用更加强大的某些可选库。

在下一章,你将学习关于开发一个极其重要的方面,单元测试。

第十二章:12. 单元测试

概述

在本章中,我们将探讨对 Vue.js 应用程序进行单元测试的方法,以提高我们的质量和交付速度。我们还将探讨使用测试来驱动开发,即 Test-Driven DevelopmentTDD)。

随着我们继续前进,你将了解为什么代码需要被测试,以及可以在 Vue.js 应用的不同部分采用哪些类型的测试。你将看到如何使用浅渲染和 vue-test-utils 对隔离组件及其方法进行单元测试,你还将学习如何测试异步组件代码。在整个章节的过程中,你将熟悉编写针对 混入过滤器 的有效单元测试的技术。在章节的末尾,你将熟悉包括路由和 Vuex 在内的 Vue.js 应用程序的测试方法,你还将了解如何使用快照测试来验证你的用户界面。

简介

在本章中,我们将探讨有效测试 Vue.js 应用程序的目的和方法。

在前面的章节中,我们看到了如何构建合理的复杂 Vue.js 应用程序。本章是关于测试它们以保持代码质量和防止缺陷。

单元测试将使我们能够编写快速且具体的测试,我们可以针对这些测试进行开发,并确保功能不会表现出不受欢迎的行为。我们将了解如何为 Vue.js 应用的不同部分编写单元测试,例如组件、混入、过滤器以及路由。我们将使用 Vue.js 核心团队支持的工具,如 vue-test-utils,以及开源社区其他部分支持的工具,如 Vue 测试库和 Jest 测试框架。这些不同的工具将用于说明不同的单元测试哲学和方法。

我们为什么需要测试代码

测试对于确保代码按预期执行至关重要。

质量生产软件是经验上正确的。这意味着对于开发人员和测试人员发现的列举案例,应用程序的行为符合预期。

这与已被 证明 正确的软件形成对比,这是一个非常耗时的工作,通常是学术研究项目的一部分。我们仍然处于这样一个阶段,即 正确的软件(已证明)仍在构建,以展示在正确性的约束下可以构建哪些类型的系统。

测试可以防止引入缺陷,如错误和回归(即,当某个功能停止按预期工作时)。在下一节中,我们将了解各种测试类型。

理解不同类型的测试

测试范围从端到端测试(通过操作用户界面)到集成测试,最后到单元测试。端到端测试测试一切,包括用户界面、底层 HTTP 服务,甚至数据库交互;没有任何内容被模拟。例如,如果你有一个电子商务应用程序,端到端测试可能会实际使用真实信用卡下订单,或者它可能会使用测试信用卡下测试订单。

端到端测试的运行和维护成本较高。它们需要使用通过程序性驱动程序(如SeleniumWebdriverIOCypress)控制的完整浏览器。这种测试平台运行成本较高,应用代码中的微小变化都可能导致端到端测试开始失败。

集成或系统级测试确保一组系统按预期工作。这通常涉及确定被测试系统的界限,并允许它运行,通常是对模拟或存根的上游服务和系统进行测试(因此这些服务和系统不在测试范围内)。由于外部数据访问被存根,可以减少许多问题,如超时和故障(与端到端测试相比)。集成测试套件通常足够快,可以作为持续集成步骤运行,但完整的测试套件通常不会由工程师在本地运行。

单元测试在开发过程中提供快速反馈方面非常出色。单元测试与 TDD(测试驱动开发)相结合是极限编程实践的一部分。单元测试擅长测试复杂的逻辑或从预期的输出构建系统。单元测试通常足够快,以至于开发者在将代码提交审查和持续集成测试之前,会先针对它们编写代码。

以下是对测试金字塔的解释。它可以理解为:你应该有大量便宜且快速的单元测试,合理数量的系统测试,以及少数端到端 UI 测试:

![图 12.1:测试金字塔图]

![图 B15218_12_01.jpg]

图 12.1:测试金字塔图

现在我们已经了解了为什么我们应该测试应用程序,让我们开始编写一些测试。

你的第一个测试

为了说明在 Vue CLI 项目中开始自动化单元测试有多快、有多简单,我们将首先设置并使用 Jest 和@vue-test-utils编写一个单元测试。有一个官方的 Vue CLI 包可以用来生成一个包含使用 Jest 和vue-test-utils进行单元测试的设置。以下命令应在已设置 Vue CLI 的项目中运行:

vue add @vue/unit-jest

Vue CLI 将 Jest 作为测试运行器,@vue/test-utils作为官方的Vue.js测试工具,以及vue-jest,它是 Jest 中用于处理.vue单文件组件文件的处理器。它添加了一个test:unit脚本。

默认情况下,它创建一个tests/unit文件夹,我们将删除它。相反,我们可以创建一个__tests__文件夹,并创建一个App.test.js文件,如下所示。

我们将使用 shallowMount 来渲染应用程序并测试它是否显示正确的文本。为了本例的目的,我们将使用文本:“The Vue.js Workshop Blog”。

shallowMount 进行浅渲染,这意味着只渲染组件的最顶层;所有子组件都是占位符。这对于单独测试组件很有用,因为子组件的实现并未运行:

import { shallowMount } from '@vue/test-utils'
import App from '../src/App.vue'
test('App renders blog title correctly', () => {
  const wrapper = shallowMount(App)
  expect(wrapper.text()).toMatch("The Vue.js Workshop Blog")
})

当我们运行 npm run test:unit 时,这个测试将失败,因为我们没有在 App 组件中包含 The Vue.js Workshop Blog

![图 12.2:在命令行中测试失败的博客标题标题]

![图片 B15218_12_02.jpg]

图 12.2:在命令行中测试失败的博客标题标题

为了让测试通过,我们可以在 App.vue 文件中实现我们的博客标题标题:

<template>
  <div id="app" class="p-10">
    <div class="flex flex-col">
      <h2
        class="leading-loose pb-4 flex justify-center m-auto           md:w-1/3 text-xl mb-8 font-bold text-gray-800 border-b"
      >
      The Vue.js Workshop Blog
      </h2>
    </div>
  </div>
</template>

现在我们已经得到了正确的标题,npm run test:unit 将会通过:

![图 12.3:博客标题测试通过]

![图片 B15218_12_03.jpg]

图 12.3:博客标题测试通过

我们还可以检查它在浏览器中的渲染是否符合预期:

The Vue.js Workshop Blog

你刚刚完成了你的第一个 TDD(测试驱动开发)。这个过程从编写一个失败的测试开始。随后是对测试代码(在本例中是 App.vue 组件)的更新,这使得失败的测试通过。TDD 过程让我们有信心我们的功能已经得到了适当的测试,因为我们可以看到在更新驱动我们功能的代码之前,测试是失败的。

测试组件

组件是 Vue.js 应用程序的核心。使用 vue-test-utils 和 Jest 对它们进行单元测试非常简单。对大多数组件进行测试可以让你有信心它们按设计运行。理想的组件单元测试运行速度快且简单。

我们将继续构建博客应用程序示例。我们现在已经构建了标题,但一个博客通常还需要一个帖子列表来显示。

我们将创建一个 PostList 组件。目前,它将只渲染一个 div 包装器并支持 posts Array 属性:

<template>
  <div class="flex flex-col w-full">
  </div>
</template>
<script>
export default {
  props: {
    posts: {
      type: Array,
      default: () => []
    }
  }
}
</script>

我们可以在 App 组件中添加一些数据:

<script>
export default {
  data() {
    return {
      posts: [
        {
          title: 'Vue.js for React developers',
          description: 'React has massive popularity here are the             key benefits of Vue.js over it.',
          tags: ['vue', 'react'],
        },
        {
          title: 'Migrating an AngularJS app to Vue.js',
          description: 'With many breaking changes, AngularJS developers             have found it easier to retrain to Vue.js than Angular 2',
          tags: ['vue', 'angularjs']
        }
      ]
    }
  }
}
</script>

现在我们有一些帖子,我们可以将它们作为绑定属性从 App 组件传递给 PostList 组件:

<template>
  <!-- rest of template -->
        <PostList :posts="posts" />
  <!-- rest of template -->
</template>
<script>
import PostList from './components/PostList.vue'
export default {
  components: {
    PostList
  },
  // rest of component properties
}

我们的 PostList 组件将在 PostListItem 组件中渲染每篇帖子,我们将按以下方式创建它。

PostListItem 接受两个属性:title(一个字符串)和 description(也是一个字符串)。它分别用 h3 标签和 p 标签渲染它们:

<template>
  <div class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4">
    <h3 class="flex text-md font-semibold text-gray-700">
      {{ title }}</h3>
    <p class="flex leading-relaxed">{{ description }}</p>
  </div>
</template>
<script>
export default {
  props: {
    title: {
      type: String
    },
    description: {
      type: String
    }
  }
}
</script>

现在,我们需要遍历帖子并使用 PostList.vue 组件渲染带有相关属性的 PostListItem 组件:

<template>
  !-- rest of template -->
    <PostListItem
      v-for="post in posts"
      :key="post.slug"
      :title="post.title"
      :description="post.description"
    />
  <!-- rest of template -->
</template>
<script>
import PostListItem from './PostListItem.vue'
export default {
  components: {
    PostListItem,
  },
  // rest of component properties
}
</script>

我们现在可以在应用程序中看到标题和帖子列表:

The Vue.js Workshop blog

要测试 PostListItem 组件,我们可以使用一些任意的 titledescription 属性进行浅渲染,并检查它们是否被渲染:

import { shallowMount } from '@vue/test-utils'
import PostListItem from '../src/components/PostListItem.vue'
test('PostListItem renders title and description correctly',   () => {
  const wrapper = shallowMount(PostListItem, {
    propsData: {
      title: 'Blog post title',
      description: 'Blog post description'
    }
  })
  expect(wrapper.text()).toMatch("Blog post title")
  expect(wrapper.text()).toMatch("Blog post description")
})

运行 npm run test:unit __tests__/PostListItem.test.js 的测试输出如下;组件通过了测试:

![图 12.4:PostListItem 测试输出]

](https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_04.jpg)

图 12.4:PostListItem 测试输出

接下来,我们将看到浅渲染的一个陷阱。当测试PostList组件时,我们所能做的就是测试它渲染的PostListItem组件的数量:

import { shallowMount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
import PostListItem from '../src/components/PostListItem.vue'
test('PostList renders the right number of PostListItem',   () => {
  const wrapper = shallowMount(PostList, {
    propsData: {
      posts: [
        {
          title: "Blog post title",
          description: "Blog post description"
        }
      ]
    }
  })
  expect(wrapper.findAll(PostListItem)).toHaveLength(1)
})

这通过了,但我们测试的是用户不会直接与之交互的东西,即PostList中渲染的PostListItem实例的数量,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_05.jpg

图 12.5:PostList 测试输出

一个更好的解决方案是使用mount函数,它渲染完整的组件树,而shallow函数只会渲染正在渲染的组件的子组件。使用mount,我们可以断言标题和描述被渲染到页面上。

这种方法的缺点是我们同时测试了PostList组件和PostListItem组件,因为PostList组件不渲染标题或描述;它渲染一组PostListItem组件,这些组件反过来渲染相关的标题和描述。

代码如下:

import { shallowMount, mount } from '@vue/test-utils'
import PostList from '../src/components/PostList.vue'
// other imports and tests
test('PostList renders passed title and description for each   passed post', () => {
  const wrapper = mount(PostList, {
    propsData: {
      posts: [
        {
          title: 'Title 1',
          description: 'Description 1'
        },
        {
          title: 'Title 2',
          description: 'Description 2'
        }
      ]
    }
  })
  const outputText = wrapper.text()
  expect(outputText).toContain('Title 1')
  expect(outputText).toContain('Description 1')
  expect(outputText).toContain('Title 2')
  expect(outputText).toContain('Description 2')
})

新的测试按照以下npm run test:unit __tests__/PostList.vue的输出通过:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_06.jpg

图 12.6:PostList 的浅渲染和挂载测试运行

我们现在已经看到了如何使用 Jest 和vue-test-utils为 Vue.js 组件编写单元测试。这些测试可以经常运行,测试运行在几秒内完成,这在我们处理新组件或现有组件时提供了几乎即时的反馈。

练习 12.01:构建和单元测试标签列表组件

当创建posts的测试用例时,我们用vueangularjsreact填充了tags字段,但没有显示它们。为了使标签有用,我们将在帖子列表中显示标签。

要访问此练习的代码文件,请参阅packt.live/2HiTFQ1

  1. 我们可以首先编写一个单元测试,说明当传递一组标签作为 props 给PostListItem组件时,我们期望它做什么。它期望每个标签都会有一个前置的井号;例如,react标签将显示为#react。在__tests__/PostListItem.test.js文件中,我们可以添加一个新的test

    // rest of tests and imports
    test('PostListItem renders tags with a # prepended to   them', () => {
      const wrapper = shallowMount(PostListItem, {
        propsData: {
          tags: ['react', 'vue']
        }
      })
      expect(wrapper.text()).toMatch('#react')
      expect(wrapper.text()).toMatch('#vue')
    })
    

    当使用npm run test:unit __tests__/PostListItem.test.js运行此测试时,测试失败:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_07.jpg

    图 12.7:PostListItem 标签测试失败

  2. 接下来,我们应该在src/components/PostListItem.vue中实现标签列表渲染。我们将添加标签作为Array类型的 props,并使用v-for渲染标签:

    <template>
        <!-- rest of template -->
        <div class="flex flex-row flex-wrap mt-4">
          <a
            v-for="tag in tags"
            :key="tag"
            class="flex text-xs font-semibold px-2 py-1 mr-2           rounded border border-blue-500 text-blue-500"
          >
            #{{ tag }}
          </a>
        </div>
        <!-- rest of template -->
    </template>
    <script>
    export default {
      props: {
        // rest of props
        tags: {
          type: Array,
          default: () => []
        }
      }
    }
    </script>
    

    在实现了PostListItem组件之后,单元测试现在应该通过:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_08.jpg

    图 12.8:PostListItem 单元测试通过

    然而,标签在应用程序中没有显示:

    ![图 12.9:显示没有标签的 PostList]

    正确的 PostListItem 实现

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_09.jpg

    图 12.9:尽管 PostListItem 实现正确,但 PostList 显示没有标签

  3. 我们可以为 PostList 编写一个单元测试,以展示这种行为。本质上,我们将向我们的 posts 列表中传递一些标签,并运行 PostListItem.test.js 文件中已经存在的相同断言。我们将在 __tests__/PostList.test.js 中这样做:

    // rest of tests and imports
    test('PostList renders tags for each post', () => {
      const wrapper = mount(PostList, {
        propsData: {
          posts: [
            {
              tags: ['react', 'vue']
            },
            {
              tags: ['html', 'angularjs']
            }
          ]
        }
      })
      const outputText = wrapper.text()
      expect(outputText).toContain('#react')
      expect(outputText).toContain('#vue')
      expect(outputText).toContain('#html')
      expect(outputText).toContain('#angularjs')
    })
    

    根据我们的应用程序输出,当使用 npm run test:unit __tests__/PostList.test.js 运行时,测试失败:

    ![图 12.10:PostList 标签测试失败]

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_10.jpg

    图 12.10:PostList 标签测试失败

  4. 为了修复这个测试,我们可以在 src/components/PostList.vue 中找到问题,这里的 PostListItemtags 属性没有被绑定。通过更新 src/components/PostList.vue 来绑定 tags 属性,我们可以修复单元测试:

    <template>
      <!-- rest of template-->
        <PostListItem
          v-for="post in posts"
          :key="post.slug"
          :title="post.title"
          :description="post.description"
          :tags="post.tags"
        />
      <!-- rest of template -->
    </template>
    

    失败的单元测试现在通过了,如下面的截图所示:

    ![图 12.11:PostList 标签测试通过]

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_11.jpg

图 12.11:PostList 标签测试通过

标签也出现在应用程序中,如下面的截图所示:

![图 12.12:带有标签的博客列表渲染]

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_12.jpg

图 12.12:带有标签的博客列表渲染

我们已经看到了如何使用浅渲染和组件挂载来测试渲染的组件输出。让我们简要了解这些术语的含义:

  • 浅渲染:这将在深度 1 处渲染,这意味着如果子元素是组件,它们将仅作为组件标签渲染;它们的模板将不会运行。

  • 挂载:这将以与在浏览器中渲染相似的方式渲染整个组件树。

接下来,我们将探讨如何测试组件方法。

测试方法、过滤器和方法混合

由于 clickinput changefocus changescroll)。

例如,一个将输入截断为八个字符的过滤器将实现如下:

<script>
export default {
  filters: {
    truncate(value) {
      return value && value.slice(0, 8)
    }
  }
}
</script>

有两种测试它的方法。我们可以直接通过导入组件并在某些输入上调用 truncate 来测试它,就像 truncate.test.js 文件中那样:

import PostListItem from '../src/components/PostListItem.vue'
test('truncate should take only the first 8 characters', () => {
  expect(
    PostListItem.filters.truncate('longer than 8 characters')
  ).toEqual('longer t')
})

另一种方法是检查它在 PostListItem 组件中的使用情况:

<template>
  <!-- rest of template -->
    <h3 class="flex text-md font-semibold text-gray-700">
      {{ title | truncate }}
    </h3>
  <!-- rest of template -->
</template>

现在,我们可以通过在 PostListItem.test.js 文件中将长标题传递给 PostListItem 组件来测试 truncate,我们在以下测试中这样做:

// imports
test('PostListItem renders title and description correctly',   () => {
  const wrapper = shallowMount(PostListItem, {
    propsData: {
      title: 'Blog post title',
      description: 'Blog post description'
    }
  })
  expect(wrapper.text()).toMatch("Blog post title")
  expect(wrapper.text()).toMatch("Blog post description")
})
// other tests

前面的代码将生成以下截图所示的输出:

![图 12.13:PostListItem 测试失败,因为]

标题的内容被截断

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_13.jpg

图 12.13:PostListItem 的标题测试失败,因为标题的内容被截断

为了修复这个问题,我们可以更新失败的测试,期望 Blog pos 而不是 Blog post title

这两种方法都是测试过滤器的优秀方法。正如我们之前在filters.truncate()测试中看到的那样,它直接访问了truncate过滤器。较宽松的单元测试是使用传入的属性并验证组件输出的测试。更紧密的单元测试通常意味着测试更简单,但这也意味着有时以与最终用户感知非常不同的方式测试功能。例如,用户永远不会直接调用filters.truncate()

我们已经看到了如何测试一个任意的truncate过滤器。现在我们将实现一个ellipsis过滤器并对其进行测试。

ellipsis过滤器将应用于帖子描述,并将其长度限制为40个字符加上

练习 12.02:构建和测试省略号过滤器

我们已经看到了如何测试一个任意的truncate过滤器;现在我们将实现一个ellipsis过滤器并对其进行测试。

要访问此练习的代码文件,请参阅packt.live/2UK9Mcs

现在让我们看看构建和测试ellipsis过滤器的步骤:

  1. 我们可以先为ellipsis过滤器编写一组测试(该过滤器将位于src/components/PostListItem.vue中)。一个测试应该检查如果传入的值少于50个字符,过滤器不做任何处理;另一个测试应该检查如果传入的值超过50个字符,它将截断到50个字符并附加。我们将在__tests__/ellipsis.test.js文件中这样做:

    import PostListItem from '../src/components/PostListItem.vue'
    test('ellipsis should do nothing if value is less than 50   characters', () => {
      expect(
        PostListItem.filters.ellipsis('Less than 50 characters')
      ).toEqual('Less than 50 characters')
    })
    test('ellipsis should truncate to 50 and append "..." when   longer than 50 characters', () => {
      expect(
        PostListItem.filters.ellipsis(
          'Should be more than the 50 allowed characters by a         small amount'
        )
      ).toEqual('Should be more than the 50 allowed characters by     a...')
    })
    
  2. 我们现在可以在src/components/PostListItem.vue中实现ellipsis的逻辑。我们将添加一个带有ellipsisfilters对象,如果传入的值超过50个字符,它将使用String#slice,否则不做任何处理:

    <script>
    export default {
      // rest of component properties
      filters: {
        ellipsis(value) {
          return value && value.length > 50
            ? `${value.slice(0, 50)}...`
            : value
        }
      }
    }
    </script>
    

    在这种情况下,现在测试通过npm run test:unit __tests__/ellipsis.test.js,如图图 12.14所示:

    ![图 12.14:省略号过滤器单元测试通过]

    ![图片 B15218_12_14.jpg]

    图 12.14:省略号过滤器单元测试通过

  3. 现在,我们需要将我们的ellipsis过滤器集成到组件中。为了检查这能否工作,我们首先可以在__tests__/PostListItem.test.js中编写测试:

    // other tests and imports
    test('PostListItem truncates long descriptions', () => {
      const wrapper = shallowMount(PostListItem, {
        propsData: {
          description: 'Very long blog post description that goes         over 50 characters'
        }
      })
      expect(wrapper.text()).toMatch("Very long blog post description     that goes over 50 ...")
    })
    

    这个测试失败了,因为我们没有在组件模板中使用过滤器。输出将如下所示:

    ![图 12.15:PostListItem 省略号测试失败]

    ![图片 B15218_12_15.jpg]

    ![图 12.15:PostListItem 省略号测试失败]

  4. 为了使测试通过,我们需要将description属性通过src/components/PostListItem.vue中的ellipsis过滤器:

    <template>
      <!-- rest of template -->
        <p class="flex leading-relaxed">{{ description | ellipsis }}      </p>
      <!-- rest of template -->
    </template>
    

    现在,测试将通过,如下面的截图所示:

    ![图 12.16:PostListItem 省略号测试通过]

    ![图片 B15218_12_16.jpg]

![图 12.16:PostListItem 省略号测试通过]

我们可以在浏览器中的应用程序界面中看到描述被截断,如下所示:

![图 12.17:博客帖子项描述被截断到 50 个字符]

![图片 B15218_12_17.jpg]

图 12.17:博客帖子项描述被截断到 50 个字符

我们已经看到了如何测试 Vue.js 组件的过滤器和其他属性,不仅可以通过直接针对对象进行测试,还可以通过测试它在组件级测试中的功能来测试。

接下来,我们将看到如何处理使用 Vue.js 路由的应用程序。

测试 Vue 路由

我们目前有一个渲染我们博客主页或feed 视图的应用程序。

接下来,我们应该有帖子页面。为此,我们将使用 Vue Router,如前几章所述,并确保我们的路由通过单元测试按设计工作。

Vue Router 使用npm安装,具体来说,npm install vue-router,并在main.js文件中进行配置:

// other imports
import router from './router'
// other imports and configuration 
new Vue({
  render: h => h(App),
  router,
}).$mount(‹#app›)

router.js文件使用Vue.usevue-router注册到 Vue 中,并实例化一个VueRouter实例:

import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
export default new VueRouter({})

没有路由的路由器并不很有用。我们将在router.js文件中定义根路径(/),以显示PostList组件,如下所示:

// other imports
import PostList from './components/PostList.vue'
// registering of Vue router
const routes = [
  {
    path: '/',
    component: PostList
  }
]
export default new VueRouter({
  routes
})

现在我们已经有了初始路由,我们应该更新App.vue文件以利用由路由器渲染的组件。我们将渲染render-view而不是直接使用PostList。然而,posts绑定保持不变:

<template>
  <!-- rest of template -->
      <router-view
        :posts="posts"
      />
  <!-- rest of template -->
</template>

现在,App.vue文件中的帖子缺少一些核心数据来渲染SinglePost组件。我们需要确保有slugcontent属性,以便在我们的SinglePost页面上渲染有用的内容:

<script>
export default {
  data() {
    return {
      posts: [
        {
          slug: 'vue-react',
          title: 'Vue.js for React developers',
          description: 'React has massive popularity here are the             key benefits of Vue.js over it.',
          content:
            'React has massive popularity here are the key benefits               of Vue.js over it.
            See the following table, we'll also look at how the is               the content of the post.
            There's more, we can map React concepts to Vue and               vice-versa.',
          tags: ['vue', 'react'],
        },
        {
          slug: 'vue-angularjs',
          title: 'Migrating an AngularJS app to Vue.js',
          description: 'With many breaking changes, AngularJS developers             have found it easier to retrain to Vue.js than Angular 2',
          content:
            'With many breaking changes, AngularJS developers have               found it easier to retrain to Vue.js than Angular 2
            Vue.js keeps the directive-driven templating style while               adding a component model.
            It's performant thanks to a great reactivity engine.',
          tags: ['vue', 'angularjs']
        }
      ]
    }
  }
}
</script>

现在,我们可以开始工作在SinglePost组件上。目前,我们只是在模板中添加一些占位符。此外,SinglePost将接收posts作为属性,因此我们也可以填写这个属性:

<template>
  <div class="flex flex-col w-full md:w-1/2 m-auto">
    <h2
      class="font-semibold text-sm mb-4"
    >
      Post: RENDER ME
    </h2>
    <p>Placeholder for post.content</p>
  </div>
</template>
<script>
export default {
  props: {
    posts: {
      type: Array,
      default: () => []
    }
  }
}
</script>

接下来,我们将在router.js中注册SinglePost,使用/:postId路径(这将通过this.$route.params.postId在组件中可用):

// other imports
import SinglePost from './components/SinglePost.vue'
// vue router registration
const routes = [
  // other route
  {
    path: '/:postId',
    component: SinglePost
  }
]
// exports and router instantiation

如果我们切换回实现SinglePost组件,我们将能够访问postId,它将映射到posts数组中的 slug,并且我们也有posts的访问权限,因为它被App绑定到render-view。现在我们可以创建一个计算属性post,它根据postId查找帖子:

<script>
export default {
  // other properties
  computed: {
    post() {
      const { postId } = this.$route.params
      return posts.find(p => p.slug === postId)
    }
  }
}
</script>

从这个计算后的post属性中,我们可以提取titlecontent,如果post存在的话(我们必须注意那些不存在的帖子)。所以,在SinglePost中,我们可以添加以下计算属性:

<script>
export default {
  // other properties
  computed: {
    // other computed properties
    title() {
      return this.post && this.post.title
    },
    content() {
      return this.post && this.post.content
    }
  }
}
</script>

然后,我们可以用计算属性的值替换模板中的占位符。因此,我们的模板最终如下所示:

<template>
  <div class="flex flex-col w-full md:w-1/2 m-auto">
    <h2
      class="font-semibold text-sm mb-4"
    >
      Post: {{ title }}
    </h2>
    <p>{{ content }}</p>
  </div>
</template>

最后,我们应该在PostListItem.vue文件中使整个帖子项成为一个指向正确 slug 的router-link

<template>
  <router-link
    class="flex flex-col m-auto w-full md:w-3/5 lg:w-2/5 mb-4"
    :to="`/${slug}`"
  >
    <!-- rest of the template -->
  </router-link>
</template>

router-link是 Vue Router 特定的链接,这意味着在PostList页面上,点击帖子列表项时,我们将被带到正确的帖子 URL,如下面的截图所示:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_18.jpg

图 12.18:在浏览器中显示的帖子列表视图

我们将被重定向到正确的 URL,即文章的缩略语,这将通过 slug 渲染正确的文章,如图 图 12.19 所示。

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_19.jpg

图 12.19:单篇文章视图在浏览器中显示

要测试 vue-router,我们将探索一个更适合测试具有路由和 Vuex 存储的应用程序的新库,即 Vue 测试库,该库可在 npm 上作为 @testing-library/vue 访问。

我们可以使用 npm install --save-dev @testing-library/vue 来安装它。

要测试 SinglePost 路由和渲染,我们执行以下操作。首先,我们应该能够通过点击 PostList 视图中的文章标题来访问 SinglePost 视图。为了做到这一点,我们通过检查内容(我们将看到两个带有标题的文章)来确认我们处于主页。然后我们点击一个文章标题并检查主页的内容已消失,文章内容已显示:

import {render, fireEvent} from '@testing-library/vue'
import App from '../src/App.vue'
import router from '../src/router.js'
test('Router renders single post page when clicking a post title',   async () => {
  const {getByText, queryByText} = render(App, { router })
  expect(queryByText('The Vue.js Workshop Blog')).toBeTruthy()
  expect(queryByText('Vue.js for React developers')).toBeTruthy()
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
  await fireEvent.click(getByText('Vue.js for React developers'))
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeFalsy()
  expect(queryByText('Post: Vue.js for React developers')).    toBeTruthy()
  expect(
    queryByText(
      'React has massive popularity here are the key benefits of         Vue.js over it. See the following table, we'll also look at         how the is the content of the post. There's more, we can         map React concepts to Vue and vice-versa.'
    )
  ).toBeTruthy()
})

我们应该检查直接导航到有效的文章 URL 将产生正确的结果。为了做到这一点,我们将使用 router.replace('/') 来清除任何设置的状态,然后使用 router.push() 并带有一个文章缩略语。然后我们将使用前一个代码片段中的断言来验证我们是否在 SinglePost 页面,而不是主页:

test('Router renders single post page when a slug is set',   async () => {
  const {queryByText} = render(App, { router })
  await router.replace('/')
  await router.push('/vue-react')
  expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeFalsy()
  expect(queryByText('Post: Vue.js for React developers')).    toBeTruthy()
  expect(
    queryByText(
      'React has massive popularity here are the key benefits of         Vue.js over it. See the following table, we'll also look at         how the is the content of the post. There's more, we can map         React concepts to Vue and vice-versa.'
    )
  ).toBeTruthy()
})

当使用 npm run test:unit __tests__/SinglePost.test.js 运行这两个测试时,它们按预期工作。以下截图显示了所需的输出:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_20.jpg

图 12.20:SinglePost 的路由测试通过

我们已经看到了如何使用 Vue.js 测试库来测试一个使用 vue-router 的应用程序。

练习 12.03:构建标签页面并测试其路由

与我们构建的单篇文章页面类似,我们现在将构建一个标签页面,它与 PostList 组件类似,只是只显示具有特定标签的文章,并且每篇文章都是一个链接到相关单篇文章视图的链接。

要访问此练习的代码文件,请参阅 packt.live/39cJqZd

  1. 我们可以从在 src/components/TagPage.vue 中创建一个新的 TagPage 组件开始。我们知道它将接收 posts 作为属性,并且我们希望渲染一个 PostList 组件:

    <template>
      <div class="flex flex-col md:w-1/2 m-auto">
        <h3
        class="font-semibold text-sm text-center mb-6"
        >
          #INSERT_TAG_NAME
        </h3>
        <PostList :posts="[]" />
      </div>
    </template>
    <script>
    import PostList from './PostList'
    export default {
      components: {
        PostList
      },
      props: {
        posts: {
          type: Array,
          default: () => []
        }
      },
    }
    </script>
    
  2. 接下来,我们想在 src/router.js 中将 TagPage 组件连接到路由器。我们将导入它并将其添加到 routes 中,路径为 /tags/:tagName

    // other imports
    import TagPage from './components/TagPage.vue'
    // Vue router registration
    const routes = [
      // other routes
      {
        path: '/tags/:tagName',
        component: TagPage
      }
    ]
    // router instantiation and export
    
  3. 我们现在可以在计算属性中使用 $route.params.tagName 并创建一个 tagPosts 计算属性,该属性通过标签过滤文章:

    <script>
    // imports
    export default {
      // rest of component
      computed: {
        tagName() {
          return this.$route.params.tagName
        },
        tagPosts() {
          return this.posts.filter(p => p.tags.includes(this.tagName))
        }
      }
    }
    </script>
    
  4. 现在我们有了对 tagPoststagName 的访问权限,我们可以替换模板中的占位符。我们将渲染 #{{ tagName }} 并将 tagPosts 绑定到 PostListposts 属性:

    <template>
      <div class="flex flex-col md:w-1/2 m-auto">
        <h3
          class="font-semibold text-sm text-center mb-6"
        >
          #{{ tagName }}
        </h3>
        <PostList :posts="tagPosts" />
      </div>
    </template>
    

    现在,如果我们导航到例如 /tags/angularjs,页面将显示如下:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_21.jpg

    图 12.21:angularjs 的标签页面

  5. 下一步是将PostListItem中的标签锚点(a)转换为指向/tags/${tagName}router-link(在src/components/PostListItem.vue中):

    <template>
      <!-- rest of template -->
          <router-link
            :to="`/tags/${tag}`"
            v-for="tag in tags"
            :key="tag"
            class="flex text-xs font-semibold px-2 py-1 mr-2           rounded border border-blue-500 text-blue-500"
          >
            #{{ tag }}
          </router-link>
      <!-- rest of template -->
    </template>
    
  6. 现在是时候编写一些测试了。我们首先检查在主页上点击#angularjs会将我们带到angularjs标签页。我们将在__tests__/TagPage.test.js中如下编写:

    import {render, fireEvent} from '@testing-library/vue'
    import App from '../src/App.vue'
    import router from '../src/router.js'
    test('Router renders tag page when clicking a tag in the post     list item', async () => {
      const {getByText, queryByText} = render(App, { router })
      expect(queryByText('The Vue.js Workshop Blog')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).    toBeTruthy()
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      await fireEvent.click(getByText('#angularjs'))
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).toBeFalsy()
      expect(queryByText('React')).toBeFalsy()
    })
    
  7. 我们还应该测试直接访问标签 URL 是否按预期工作;也就是说,我们看不到不相关的内容:

    // import & other tests
    test('Router renders tag page when a URL is set', async () => {
      const {queryByText} = render(App, { router })
      await router.push('/')
      await router.replace('/tags/angularjs')
      expect(queryByText('Migrating an AngularJS app to Vue.js')).    toBeTruthy()
      expect(queryByText('Vue.js for React developers')).    toBeFalsy()
      expect(queryByText('React')).toBeFalsy()
    })
    

    测试通过,因为应用程序按预期工作。因此,输出将如下所示:

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_22.jpg

图 12.22:TagPage 路由测试通过命令行

我们已经看到了如何实现和测试一个包含vue-router的应用程序。在下一节中,我们将详细了解 Vuex 的测试。

测试 Vuex

为了展示如何测试依赖于 Vuex(Vue.js 的官方全局状态管理解决方案)的组件,我们将实现并测试新闻通讯订阅横幅。

首先,我们应该创建横幅模板。横幅将包含一个“订阅新闻通讯”的行动呼吁和一个关闭图标:

<template>
  <div class="text-center py-4 md:px-4">
    <div
      class="py-2 px-4 bg-indigo-800 items-center text-indigo-100
      leading-none md:rounded-full flex md:inline-flex"
      role="alert"
    >
      <span
        class="font-semibold ml-2 md:mr-2 text-left flex-auto"
      >
        Subscribe to the newsletter
      </span>
      <svg
        class="fill-current h-6 w-6 text-indigo-500"
        role="button"
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 20 20"
      >
        <title>Close</title>
        <path
          d="M14.348 14.849a1.2 1.2 0 0 1-1.697 0L10 11.819l-2.651
          3.029a1.2 1.2 0 1 1-1.697-1.697l2.758-3.15-2.759-3.152a1\.            2
          1.2 0 1 1 1.697-1.697L10 8.183l2.651-3.031a1.2 1.2 0 1 1
          1.697 1.697l-2.758 3.152 2.758 3.15a1.2 1.2 0 0 1 0 1\.            698z"
        />
      </svg>
    </div>
  </div>
</template>

我们可以在App.vue文件中如下显示NewsletterBanner组件:

<template>
  <!-- rest of template -->
    <NewsletterBanner />
  <!-- rest of template -->
</template>
<script>
import NewsletterBanner from './components/NewsletterBanner.vue'
export default {
  components: {
    NewsletterBanner
  },
  // other component properties
}
</script>

然后,我们将使用npm install --save vuex命令安装 Vuex。一旦安装了 Vuex,我们就可以在store.js文件中初始化我们的存储,如下所示:

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
  state: {},
  mutations: {}
})

我们的 Vuex 存储也已在main.js文件中注册:

// other imports
import store from './store'
// other configuration
new Vue({
  // other vue options
  store
}).$mount('#app')

为了决定是否显示新闻通讯横幅,我们需要在我们的存储中添加一个初始状态:

// imports and configuration
export default new Vuex.Store({
  state: {
    dismissedSubscribeBanner: false
  }
})

要关闭横幅,我们需要一个突变,该突变将dismissedSubscribeBanner设置为true

// imports and configuration
export default new Vuex.Store({
  // other store configuration
  mutations: {
    dismissSubscribeBanner(state) {
      state.dismissedSubscribeBanner = true
    }
  }
})

现在,我们可以使用存储状态和dismissSubscribeBanner突变来决定是否显示横幅(使用v-if)以及是否关闭它(绑定到close按钮的点击):

<template>
  <div v-if="showBanner" class="text-center py-4 md:px-4">
    <!-- rest of template -->
      <svg
        @click="closeBanner()"
        class="fill-current h-6 w-6 text-indigo-500"
        role="button"
        xmlns=http://www.w3.org/2000/svg
        viewBox="0 0 20 20"
      >
    <!-- rest of the template -->
  </div>
</template>
<script>
export default {
  methods: {
    closeBanner() {
      this.$store.commit('dismissSubscribeBanner')
    }
  },
  computed: {
    showBanner() {
      return !this.$store.state.dismissedSubscribeBanner
    }
  }
}
</script>

在这一点上,横幅在浏览器中的样子如下:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_23.jpg

图 12.23:浏览器中显示的新闻通讯横幅

要编写单元测试,我们将使用 Vue 测试库,它提供了一个注入 Vuex 存储的功能。我们需要导入存储和NewsletterBanner组件。

我们可以先进行一个合理性检查,即默认情况下,新闻通讯横幅是显示的:

import {render, fireEvent} from '@testing-library/vue'
import NewsletterBanner from '../src/components/  NewsletterBanner.vue'
import store from '../src/store'
test('Newsletter Banner should display if store is initialised   with it not dismissed', () => {
  const {queryByText} = render(NewsletterBanner, { store })
  expect(queryByText('Subscribe to the newsletter')).toBeTruthy()
})

下一个检查应该是,如果存储有dismissedSubscribeBanner: true,则横幅不应显示:

// imports and other tests
test('Newsletter Banner should not display if store is initialised with   it dismissed', () => {
  const {queryByText} = render(NewsletterBanner, { store: {
    state: {
      dismissedSubscribeBanner: true
    }
  } })
  expect(queryByText('Subscribe to the newsletter')).toBeFalsy()
})

我们将要进行的最后一个测试是确保点击横幅的关闭按钮会将突变提交到存储中。我们可以通过将存根作为dismissSubscribeBanner突变注入,并检查在点击关闭按钮时是否被调用来实现这一点:

// imports and other tests
test('Newsletter Banner should hide on "close" button click',   async () => {
  const dismissSubscribeBanner = jest.fn()
  const {getByText} = render(NewsletterBanner, {
    store: {
      ...store,
      mutations: {
        dismissSubscribeBanner
      }
    }
  })
  await fireEvent.click(getByText('Close'))
  expect(dismissSubscribeBanner).toHaveBeenCalledTimes(1)
})

当使用npm run test:unit __tests__/NewsletterBanner.test.js运行时,测试将通过,如下所示:

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_24.jpg

图 12.24:通过命令行执行的 NewsletterBanner 单元测试

我们已经看到了如何使用 Vue.js 测试库来测试由 Vuex 驱动的应用程序功能。

练习 12.04:构建和测试 cookie 免责声明横幅(Vuex)

我们现在将探讨如何使用 Vuex 实现 cookie 免责声明横幅,以及如何使用 Vue.js 测试库进行测试。

我们将在 Vuex 中存储 cookie 横幅是否显示(默认为true);当横幅关闭时,我们将将其存储在 Vuex 中。

使用模拟 Vuex 存储来测试此打开/关闭操作。要访问此练习的代码文件,请参阅packt.live/36UzksP

  1. 创建一个带有加粗标题Cookies Disclaimer、免责声明和I agree按钮的绿色 cookie 横幅。我们将在src/components/CookieBanner.vue中创建此组件:

    <template>
      <div
        class="flex flex-row bg-green-100 border text-center       border-green-400
        text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative"
        role="alert"
      >
        <div class="flex flex-col">
          <strong class="font-bold w-full flex">Cookies Disclaimer
          </strong>
          <span class="block sm:inline">We use cookies to improve your experience</span>
        </div>
        <button
          class="ml-auto align-center bg-transparent         hover:bg-green-500
          text-green-700 font-semibold font-sm hover:text-white         py-2 px-4 border
          border-green-500 hover:border-transparent rounded"
        >
          I agree
        </button>
      </div>
    </template>
    
  2. 接下来,我们将在src/App.vue中导入、注册并渲染CookieBanner组件到router-view下方:

    <template>
      <!-- rest of template -->
          <CookieBanner />
      <!-- rest of template -->
    </template>
    <script>
    // other imports
    import CookieBanner from './components/CookieBanner.vue'
    export default {
      components: {
        // other components
        CookieBanner
      },
      // other component properties
    }
    </script>
    
  3. 添加一个state切片来控制是否显示 cookie 横幅。在我们的 Vuex 存储中,我们将初始化此acceptedCookie字段为false

    // imports and configuration
    export default new Vuex.Store({
      state: {
        // other state fields
        acceptedCookie: false
      },
      // rest of vuex configuration
    })
    
  4. 我们还需要一个acceptCookie突变来关闭横幅:

    // imports and configuration
    export default new Vuex.Store({
      // rest of vuex configuration
      mutations: {
        // other mutations
        acceptCookie(state) {
          state.acceptedCookie = true
        }
      }
    })
    
  5. 接下来,我们将暴露存储状态作为acceptedCookie计算属性。我们将创建一个acceptCookie函数,该函数触发acceptCookie突变:

    export default {
      methods: {
        acceptCookie() {
          this.$store.commit('acceptCookie')
        }
      },
      computed: {
        acceptedCookie() {
          return this.$store.state.acceptedCookie
        }
      }
    }
    </script>
    
  6. 我们将使用v-if在尚未接受 cookie 时显示横幅。当点击I agree按钮时,通过切换acceptCookie来关闭横幅:

    <template>
      <div
        v-if="!acceptedCookie"
        class="flex flex-row bg-green-100 border text-center       border-green-400
        text-green-700 mt-8 px-4 md:px-8 py-3 rounded relative"
        role="alert"
      >
        <!-- rest of template -->
        <button
          @click="acceptCookie()"
          class="ml-auto align-center bg-transparent         hover:bg-green-500
          text-green-700 font-semibold font-sm hover:text-white         py-2 px-4 border
          border-green-500 hover:border-transparent rounded"
        >
          I agree
        </button>
      </div>
    </template>
    

    现在我们已经得到了一个 cookie 横幅,直到点击I agree才会显示,如下面的截图所示:

    ![图 12.25:浏览器中显示的 cookie 横幅]

    ![img/B15218_12_25.jpg]

    图 12.25:浏览器中显示的 cookie 横幅

  7. 现在,我们将编写一个测试来检查CookieBanner组件是否默认显示:

    import {render, fireEvent} from '@testing-library/vue'
    import CookieBanner from '../src/components/CookieBanner.vue'
    import store from '../src/store'
    test('Cookie Banner should display if store is initialised with   it not dismissed', () => {
      const {queryByText} = render(CookieBanner, { store })
      expect(queryByText('Cookies Disclaimer')).toBeTruthy()
    })
    
  8. 我们还将编写一个测试来检查如果存储中的acceptedCookietrue,则 cookie 横幅不会显示:

    test('Cookie Banner should not display if store is initialised   with it dismissed', () => {
      const {queryByText} = render(CookieBanner, { store: {
        state: {
          acceptedCookie: true
        }
      } })
      expect(queryByText('Cookies Disclaimer')).toBeFalsy()
    })
    
  9. 最后,我们希望检查当点击I agree按钮时,会触发acceptCookie突变:

    test('Cookie Banner should hide on "I agree" button click',   async () => {
      const acceptCookie = jest.fn()
      const {getByText} = render(CookieBanner, {
        store: {
          ...store,
          mutations: {
            acceptCookie
          }
        }
      })
      await fireEvent.click(getByText('I agree'))
      expect(acceptCookie).toHaveBeenCalledTimes(1)
    })
    

    当我们使用npm run test:unit __tests__/CookieBanner.test.js运行我们编写的三个测试时,它们都会通过,如下所示:

    ![图 12.26:cookie 横幅测试通过]

    ![img/B15218_12_26.jpg]

图 12.26:cookie 横幅测试通过

我们已经看到了如何测试依赖于 Vuex 进行状态和更新的组件。

接下来,我们将探讨快照测试,看看它是如何简化渲染输出的测试的。

快照测试

快照测试提供了一种为快速变化的代码片段编写测试的方法,而不需要将断言数据内联到测试中。它们存储快照。

快照的更改反映了输出的更改,这对于代码审查非常有用。

例如,我们可以在PostList.test.js文件中添加一个快照测试:

// imports and tests
test('Post List renders correctly', () => {
  const wrapper = mount(PostList, {
    propsData: {
      posts: [
        {
          title: 'Title 1',
          description: 'Description 1',
          tags: ['react', 'vue']
        },
        {
          title: 'Title 2',
          description: 'Description 2',
          tags: ['html', 'angularjs']
        }
      ]
    }
  })
  expect(wrapper.text()).toMatchSnapshot()
})

当我们再次运行此测试文件时,使用npm run test:unit __tests__/PostList.test.js,我们将得到以下输出:

![图 12.27:第一次运行快照测试]

https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_27.jpg

图 12.27:第一次运行快照测试

快照已写入__tests__/__snapshots__/PostList.test.js.snap,如下所示:

// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Post List renders correctly 1`] = `
"Title 1 Description 1 
      #react

      #vue
    Title 2 Description 2 
      #html

      #angularjs"
`;

这使得我们可以快速看到这些更改在具体输出方面的含义。

我们现在已经看到了如何使用快照测试。接下来,我们将把本章学到的所有工具结合起来,添加一个新页面。

活动十二.01:通过测试添加一个简单的按标题搜索页面

我们已经构建了一个帖子列表页面、单个帖子视图页面和按标签分类的帖子页面。

在博客上重新展示旧内容的一个好方法是通过实现良好的搜索功能。我们将向PostList页面添加搜索功能:

  1. 在新文件src/components/SearchForm.vue中创建一个带有输入和按钮的搜索表单。

  2. 现在,我们将通过导入、注册并在src/App.vue中渲染来使表单显示。

    现在,我们可以在应用程序中看到搜索表单,如下所示:

    ![图 12.28:带有搜索表单的帖子列表视图]

    https://github.com/OpenDocCN/freelearn-fe-framework-zh/raw/master/docs/fe-proj-vuejs/img/B15218_12_28.jpg

    图 12.28:带有搜索表单的帖子列表视图

  3. 我们现在准备好为搜索表单添加一个快照测试。在__tests__/SearchForm.test.js中,我们应该添加SearchForm should match expected HTML

  4. 我们希望使用v-model跟踪搜索表单输入的内容,以双向绑定searchTerm实例变量和输入内容。

  5. 当提交搜索表单时,我们需要更新 URL 以包含正确的参数。这可以通过this.$router.push()来完成。我们将把搜索存储在q查询参数中。

  6. 我们希望将q查询参数的状态反映在搜索表单输入中。我们可以通过从this.$route.query中读取q并将其设置为SearchForm组件状态中searchTerm数据字段的初始值来实现这一点。

  7. 接下来,我们希望过滤主页上传递给PostList的帖子。我们将使用this.$route.query.q在一个计算属性中过滤帖子标题。这个新的计算属性将替代src/App.vue中的posts

  8. 接下来,我们应该添加一个测试,更改搜索查询参数,并检查应用程序是否显示正确的结果。为此,我们可以导入src/App.vuesrc/store.jssrc/router.js,并使用存储和路由渲染应用程序。然后,我们可以通过使用字段的占位符为Search来更新搜索字段的内容。最后,我们可以通过点击test idSearch(即搜索按钮)的元素来提交表单。

    注意

    这个活动的解决方案可以通过这个链接找到。

摘要

在本章中,我们探讨了测试不同类型 Vue.js 应用程序的不同方法。

通常来说,测试对于从经验上证明系统正在正常工作是有用的。单元测试是最容易构建和维护的,应该是测试功能的基础。系统测试是测试金字塔的下一层级,它使你能够对大多数功能按预期工作充满信心。端到端测试表明整个系统的主流程正在正常工作。

我们已经看到了如何对组件、过滤器、组件方法和混入进行单元测试,以及如何通过层进行测试,以及以黑盒方式测试组件输出而不是检查组件内部以测试功能。使用 Vue.js 测试库,我们已经测试了利用 Vuex 的高级功能,如路由和应用程序。

最后,我们探讨了快照测试,并看到了它如何成为为模板密集型代码块编写测试的有效方式。

在下一章中,我们将探讨可以应用于 Vue.js 应用的端到端测试技术。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐