在Vue專案中使用snapshot測試

盪漾發表於2019-02-16

在Vue專案中使用snapshot測試

snapshot介紹

snapshot測試又稱快照測試,可以直觀地反映出元件UI是否發生了未預見到的變化。snapshot如字面上所示,直觀描述出元件的樣子。通過對比前後的快照,可以很快找出UI的變化之處。

第一次執行快照測試時會生成一個快照檔案。之後每次執行測試的時候,會生成一個快照,然後對比最初生成的快照檔案,如果沒有發生改變,則通過測試。否則測試不通過,同時會輸出結果,對比不匹配的地方。

jest中的快照檔案以為snap擴充名結尾,格式如下(ps: 在沒有了解之前,我還以為是快照檔案是截圖)。一個快照檔案中可以包含多個快照,快照的格式其實是HTML字串,對於UI元件,其HTML會反映出其內部的state。每次測試只需要對比字串是否符合初始快照即可。

exports[`button 1`] = `"<div><span class=\"count\">1</span> <button>Increment</button> <button class=\"desc\">Descrement</button> <button class=\"custom\">not emitted</button></div>"`;

snapshot測試不通過的原因有兩個。一個原因是元件發生了未曾預見的變化,此時應檢查程式碼。另一個原因是元件更新而快照檔案並沒有更新,此時要執行jest -u更新快照。

› 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with -u to update them.

結合Vue進行snapshot測試

生成快照時需要渲染並掛載元件,在Vue中可以使用官方的單元測試實用工具Vue Test Utils

Vue Test Utils 提供了mountshallowMount這兩個方法,用於建立一個包含被掛載和渲染的 Vue 元件的 Wrapper。component是一個vue元件,options是例項化Vue時的配置,包括掛載選項和其他選項(非掛載選項,會將它們通過extend覆寫到其元件選項),結果返回一個包括了一個掛載元件或 vnode,以及測試該元件或 vnode 的方法的Wrapper例項。

mount(component:{Component}, options:{Object})

shallowMountmount不同的是被存根的子元件,詳細請戳文件

Wrapper上的豐富的屬性和方法,足以應付本文中的測試需求。html()方法返回Wrapper DOM 節點的 HTML 字串。find()findAll()可以查詢Wrapper裡的DOM節點或Vue元件,可用於查詢監聽事件的元素。trigger可以在DOM節點/元件上觸發一個事件。

結合上述的方法,我們可以完成一個模擬事件觸發的快照測試。

細心的讀者可能會發現,我們平時在使用Vue時,資料更新後檢視並不會立即更新,需要在nextTick回撥中處理更新完成後的任務。但在 Vue Test Utils 中,為簡化用法,更新是同步的,所以無需在測試中使用 Vue.nextTick 來等待 DOM 更新。

demo演示

Vue Test Utils官方文件中提供了一個整合VTU和Jest的demo,不過這個demo比較舊,官方推薦用CLI3建立專案。

執行vue create vue-snapshot-demo建立demo專案,建立時要選擇單元測試,提供的庫有Mocha + ChaiJest,在這裡選擇Jest.安裝完成之後執行npm run serve即可執行專案。

本文中將用一個簡單的Todo應用專案來演示。這個Todo應用有簡單的新增、刪除和修改Todo項狀態的功能;Todo項的狀態有已完成和未完成,已完成時不可刪除,未完成時可刪除;已完成的Todo項會用一條線橫貫文字,未完成項會在滑鼠懸浮時展示刪除按鈕。

元件簡單地劃分為Todo和TodoItem。TodoItem在Todo項未完成且觸發mouseover事件時會展示刪除按鈕,觸發mouseleave時則隱藏按鈕(這樣可以在快照測試中模擬事件)。TodoItem中有一個checkbox,用於切換Todo項的狀態。Todo項完成時會有一個todo-finished類,用於實現刪除線效果。

為方便這裡只介紹TodoItem元件的程式碼和測試。

<template>
  <li
    :class="[`todo-item`, item.finished?`todo-finished`:``]"
    @mouseover="handleItemMouseIn"
    @mouseleave="handleItemMouseLeave"
  >
    <input type="checkbox" v-model="item.finished">
    <span class="content">{{item.content}}</span>
    <button class="del-btn" v-show="!item.finished&&hover" @click="emitDelete">delete</button>
  </li>
</template>

<script>
export default {
  name: "TodoItem",
  props: {
    item: Object
  },
  data() {
    return {
      hover: false
    };
  },
  methods: {
    handleItemMouseIn() {
      this.hover = true;
    },
    handleItemMouseLeave() {
      this.hover = false;
    },
    emitDelete() {
      this.$emit("delete");
    }
  }
};
</script>
<style lang="scss">
.todo-item {
  list-style: none;
  padding: 4px 16px;
  height: 22px;
  line-height: 22px;
  .content {
    margin-left: 16px;
  }
  .del-btn {
    margin-left: 16px;
  }
  &.todo-finished {
    text-decoration: line-through;
  }
}
</style>

進行快照測試時,除了測試資料渲染是否正確外還可以模擬事件。這裡只貼快照測試用例的程式碼,完整的程式碼戳我

describe(`TodoItem snapshot test`, () => {
    it(`first render`, () => {
        const wrapper = shallowMount(TodoItem, {
            propsData: {
                item: {
                    finished: true,
                    content: `test TodoItem`
                }
            }
        })
        expect(wrapper.html()).toMatchSnapshot()
    })

    it(`toggle checked`, () => {
        const renderer = createRenderer();
        const wrapper = shallowMount(TodoItem, {
            propsData: {
                item: {
                    finished: true,
                    content: `test TodoItem`
                }
            }
        })
        const checkbox = wrapper.find(`input`);
        checkbox.trigger(`click`);
        renderer.renderToString(wrapper.vm, (err, str) => {
            expect(str).toMatchSnapshot()
        })
    })
    
    it(`mouseover`, () => {
        const renderer = createRenderer();
        const wrapper = shallowMount(TodoItem, {
            propsData: {
                item: {
                    finished: false,
                    content: `test TodoItem`
                }
            }
        })
        wrapper.trigger(`mouseover`);
        renderer.renderToString(wrapper.vm, (err, str) => {
            expect(str).toMatchSnapshot()
        })
    })
})

這裡有三個測試。第二個測試模擬checkbox點選,將Todo項從已完成切換到未完成,期待類todo-finished會被移除。第三個測試在未完成Todo項上模擬滑鼠懸浮,觸發mouseover事件,期待刪除按鈕會展示。

這裡使用toMatchSnapshot()來進行匹配快照。這裡生成快照檔案所需的HTML字串有wrapper.html()Renderer.renderToString這兩種方式,區別在於前者是同步獲取,後者是非同步獲取。

測試模擬事件時,最好以非同步方式獲取HTML字串。同步方式獲取的字串並不一定是UI更新後的檢視。

儘管VTU文件中說所有的更新都是同步,但實際上在第二個快照測試中,如果使用expect(wrapper.html()).toMatchSnapshot(),生成的快照檔案中Todo項仍有類todo-finished,期待的結果應該是沒有類todo-finished,結果並非更新後的檢視。而在第三個快照測試中,使用expect(wrapper.html()).toMatchSnapshot()生成的快照,按鈕如期望展示,是UI更新後的檢視。所以才不建議在DOM更新的情況下使用wrapper.html()獲取HTML字串。

下面是兩種對比的結果,1是使用wrapper.html()生成的快照,2是使用Renderer.renderToString生成的。

exports[`TodoItem snapshot test mouseover 1`] = `<li class="todo-item"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="">delete</button></li>`;

exports[`TodoItem snapshot test mouseover 2`] = `<li class="todo-item"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn">delete</button></li>`;

exports[`TodoItem snapshot test toggle checked 1`] = `<li class="todo-item todo-finished"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="display: none;">delete</button></li>`;

exports[`TodoItem snapshot test toggle checked 2`] = `<li class="todo-item"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="display:none;">delete</button></li>`;

這裡使用vue-server-renderer提供的createRenderer來生成一個Renderer例項,例項方法renderToString來獲取HTML字串。這種是典型的回撥風格,斷言語句在回撥中執行即可。

    // ...
    wrapper.trigger(`mouseover`);
    renderer.renderToString(wrapper.vm, (err, str) => {
        expect(str).toMatchSnapshot()
    })

如果不想使用這個庫,也可以使用VTU中提供的非同步案例。由於wrapper.html()是同步獲取,所以獲取操作及斷言語句需要在Vue.nextTick()返回的Promise中執行。

    // ...
    wrapper.trigger(`mouseover`);
    Vue.nextTick().then(()=>{
        expect(wrapper.html()).toMatchSnapshot()
    })

觀察測試結果

執行npm run test:unityarn test:unit執行測試。

初次執行,終端輸出會有Snapshots: 3 written, 3 total這一行,表示新增三個快照測試,並生成初始快照檔案。

 › 3 snapshots written.
Snapshot Summary
 › 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   3 written, 3 total
Time:        2.012s
Ran all test suites.
Done in 3.13s.

快照檔案如下示:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`TodoItem snapshot test first render 1`] = `<li class="todo-item todo-finished"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="display: none;">delete</button></li>`;

exports[`TodoItem snapshot test mouseover 1`] = `<li class="todo-item"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn">delete</button></li>`;

exports[`TodoItem snapshot test toggle checked 1`] = `<li class="todo-item"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="display:none;">delete</button></li>`;

第二次執行測試後,輸出中有Snapshots: 3 passed, 3 total,表示有三個快照測試成功通過,總共有三個快照測試。

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   3 passed, 3 total
Time:        2s
Ran all test suites.
Done in 3.11s.

修改第一個快照中傳入的content,重新執行測試時,終端會輸出不匹配的地方,輸出資料的格式與Git類似,會標明哪一行是新增的,哪一行是被刪除的,並提示不匹配程式碼所在行。

    - Snapshot
    + Received

    - <li class="todo-item todo-finished"><input type="checkbox"> <span class="content">test TodoItem</span> <button class="del-btn" style="display: none;">delete</button></li>
    + <li class="todo-item todo-finished"><input type="checkbox"> <span class="content">test TodoItem content change</span> <button class="del-btn" style="display: none;">delete</button></li>

      88 |             }
      89 |         })
    > 90 |         expect(wrapper.html()).toMatchSnapshot()
         |                                ^
      91 |     })
      92 |
      93 |     it(`toggle checked`, () => {

      at Object.toMatchSnapshot (tests/unit/TodoItem.spec.js:90:32)

同時會提醒你檢查程式碼是否錯誤或重新執行測試並提供引數-u以更新快照檔案。

Snapshot Summary
 › 1 snapshot failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.

執行npm run test:unit -- -uyarn test:unit -u更新快照,輸出如下示,可以發現有一個快照測試的輸出更新了。下次快照測試對照的檔案是這個更新後的檔案。

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total
Snapshots:   1 updated, 2 passed, 3 total
Time:        2.104s, estimated 3s
Ran all test suites.
Done in 2.93s.

其他

除了使用toMatchSnapshot()外,還可以使用toMatchInlineSnapshot()。二者不同之處在於toMatchSnapshot()從快照檔案中查詢快照,而toMatchInlineSnapshot()則將傳入的引數當成快照檔案進行匹配。

配置Jest

Jest配置可以儲存在jest.config.js檔案裡,可以儲存在package.json裡,用鍵名jest表示,同時也允許行內配置。

介紹幾個常用的配置。

rootDir

查詢Jest配置的目錄,預設是pwd

testMatch

jest查詢測試檔案的匹配規則,預設是[ "**/__tests__/**/*.js?(x)", "**/?(*.)+(spec|test).js?(x)" ]。預設查詢在__test__資料夾中的js/jsx檔案和以.test/.spec結尾的js/jsx檔案,同時包括test.jsspec.js

snapshotSerializers

生成的快照檔案中HTML文字沒有換行,是否能進行換行美化呢?答案是肯定的。

可以在配置中新增snapshotSerializers,接受一個陣列,可以對匹配的快照檔案做處理。jest-serializer-vue這個庫做的就是這樣任務。

如果你想要實現這個自己的序列化任務,需要實現的方法有testprinttest用於篩選處理的快照,print返回處理後的結果。

後記

在未了解測試之前,我一直以為測試是枯燥無聊的。瞭解過快照測試後,我發現測試其實蠻有趣且實用,同時由衷地感嘆快照測試的巧妙之處。如果這個簡單的案例能讓你瞭解快照測試的作用及使用方法,就是我最大的收穫。

如果有問題或錯誤之處,歡迎指出交流。

參考連結

相關文章