🌞

Viết Unit Test cho Vue component cho người mới bắt đầu

Sửa bài viết này

Khi viết unit test, mình thấy không cần bỏ ra quá nhiều thời gian để cover 100% các case sẽ có, nhưng vẫn đảm bảo đủ các trường hợp cần thiết. Vậy câu hỏi là: như thế nào là đủ? Đây là những quan điểm rất cá nhân, nếu bạn nào đã là master of unit test rồi thì mình hy vọng có được sử chỉ giáo.

Mình xem như bạn đã biết chút ít về JestVue Test Utils, đã chạy vue-cli để setup một dự án mới với Jest


Chúng ta sẽ học được gì

  • Tại sao viết test, mục đích của viết test
  • Xác định cái nào cần và không cần test
  • Ví dụ để thực hành

Xác định những gì cần test

Test cái gì?

Khi chúng ta viết test cho một component, bắt đầu với những public interface của component đó. Đừng nghe đến chứ public interface mà rung sợ, nó chỉ là những gì component đó tương tác với thế giới bên ngoài. Nếu bạn viết hướng dẫn sử dụng để người khác xài component đó, bạn viết những gì, đó là những thứ bạn sẽ test, component nhận vào những gì và output ra những gì.

Đầu vào của component

  • props
  • tương tác của user, click, kéo-thả
  • store
  • route params Đầu ra của component
  • render ra DOM
  • tạo ra sự kiện nào đó
  • thay đổi route
  • cập nhập lại store

Khi tập trung vào những public interface này nghĩa là chúng ta cũng không tập trung vào logic bên trong của component, từng dòng code của component đó chạy ra sao. Nghe có vẻ không hợp lý, nhưng unit test chỉ tập trung vào kết quả trả về, không quan tâm làm thế nào để có kết quả đó.

Component <RandomNumberGenerator/> bên dưới, nó sinh ra trong cuộc đời này là để tạo một con số ngẫu nhiên nằm trong khoảng minmax. Trước khi tiếp tục, bạn có thể xác định được input và output của component này chưa?

// RandomNumberGenerator.vue
<template>
  <div>
    <span class="number">{{ randomNumber }}</span>
    <button v-on:click="generateRandomNumber">Generate Random Number</button>
  </div>
</template>
    
    
<script>
export default {
  props: {
    min: {
      type: Number,
      default: 1
    },
    max: {
      type: Number,
      default: 10
    }
  },
  data() {
    return {
      randomNumber: 0
    }
  },

  methods: {
    generateRandomNumber() {
      this.randomNumber =
        Math.floor(Math.random() * (this.max - this.min + 1)) + this.min
    }
  }
}
</script>

Cái gì KHÔNG CẦN test

Chúng ta không cần biết nó đã làm như thế nào, cách làm đó đúng hay sai chúng ta không phải là người đi kiểm tra, ví dụ như <RandomNumberGenerator/>, chúng ta đưa vào 2 input minmax, hàm sẽ thực hiện việc đó là

generateRandomNumber() {
  this.randomNumber = Math.floor(Math.random() * (this.max - this.min + 1) ) + this.min;
}

Tất cả những gì chúng ta cần đảm bảo là con số trả về nằm giữa 2 giá trị minmax, nếu sau này có cập nhập hay thay đổi cách hiện thực của hàm này, dùng thư viện khác để random, dùng cách khác để random, chúng ta không cần kiểm tra cách làm bên trong.

Ví dụ

Component TestComponent.vue bên dưới, nó có 3 dependency là Vuex ($store), Vue Router ($router) và Vue Auth ($auth)

// TestComponent.vue
<template>
  <div>
    <h2>{{ item.title }}</h2>
    <button @click="addToCart">Add To Cart</button>
    <img :src="item.image" alt=””/>
  </div>
</template>
<script>
export default {
  name: "ProductItem",
  props: [ "id" ],
  computed: {
    item () {
      return this.$store.state.find(
        item => item.id === this.id
      );
    }
  },
  methods: {
    addToCart () {
      if (this.$auth.check()) {
        this.$store.commit("ADD_TO_CART", this.id);
      } else {
        this.$router.push({ name: "login" });
      }
    }
  }
};
</script>

Phân tích

Component này được sinh ra, nuôi dạy, cho ăn học để lớn lên là để

  • Hiển thị một sản phẩm dựa trên prop id (toàn bộ sản phẩm nằm trong store)
  • Nếu là chưa đăng nhập, click vào nút Add to Card sẽ đẩy về trang Login
  • Nếu user đã đăng nhập, click vào Add to Card, nó sẽ bắn ra sự kiện ADD_TO_CARD để Vuex cập nhập

Input của component này

  • id
  • state từ Vuex và Vue Auth
  • User click nút Add to Card

Output của component này

  • Render html
  • Vue Router Push (cho user chưa đăng nhập)
  • Data được gửi tới Vuex mutation (nếu user đã đăng nhập)

Unit test

Chúng ta sử dụng một function, trả về một object dùng để config, cho tiết kiệm thời gian phải viết đi viết lại ấy

item.spec.js

import { shallowMount } from '@vue/test-utils'
import TestComponent from '@/components/TestComponent'

function createConfig (overrides) {
    const id = 1
    const mocks = {
        // Vue Auth
        $auth: {
          check: () => false
        },
        // Vue Router
        $router: {
          push: jest.fn()
        },
        // Vuex
        $store: {
          state: [ { id } ],
          commit: jest.fn()
        }
    }
    const propsData = { id }
    return Object.assign({ mocks, propsData }, overrides)
}

describe('TestComponent.vue', () => {
    
})

Test case 1: Render HTML

Có ai đó vô tình đổi tên biến title thành name và quên mất cập nhập trong file template. Có vẻ là một tình huống rất cần để viết test đúng không? Nhưng viết thế nào, số lượng biến như vậy trong template là nhiều vô số kể, viết test từng biến một thì chắc hết cả tuổi thanh xuân.

Cách tốt nhất để test trong trường hợp trên là dùng snapshot test. Nó sẽ không chỉ kiểm tra title mà còn gồm luôn cả image, button text, class,...

test('TEST CASE 1: Render HTML', () => {
    const wrapper  = shallowMount(Item, createConfig())
    expect(wrapper).toMatchSnapshot()
})

Viết unit test cho Vue Component

Chúng ta không kiểm tra đoạn text bên trong có render đúng như input không, như thế này là thừa thải

test('render correct', () => {
    const wrapper  = shallowMount(Item, createConfig())
    expect(wrapper.find('h2').text()).toBe(item.title)
})

Test case 2: router login được gọi khi click button mà chưa đăng nhập

test('TEST CASE 2: router login được gọi khi click button mà chưa đăng nhập', () => {
    const config = createConfig()
    const wrapper = shallowMount(Item, config)
    wrapper.find('button').trigger('click')
    /// thêm expect ở bên dưới
})

Mình sẽ không quan tâm, <Login.vue/> có được mount vào sau khi click hay không, chúng ta chỉ expect khi click $router sẽ push vào object { name: "login" }

const spy = jest.spyOn(config.mocks.$router, 'push')
expect(spy).toHaveBeenCalledWith({ name: 'login' })

Test case 3: vuex được gọi khi user đã đăng nhập và click button

Cũng tương tự như trên, chúng ta sẽ test mutation cập nhập đúng giá trị chúng ta mong muốn khi viết test cho store, còn ở component, chúng ta cần biết component có commit lên cho Vuex chưa

Sửa lại $auth.check thành true để giả lập đăng nhập thành công rồi, chúng ta kiểm tra phương thức commit của store

test('TEST CASE 3: vuex được gọi khi user đã đăng nhập và click button', () => {
    const config = createConfig({
        mocks: {
            $auth: {
              check: () => true
            },
            $store: {
              state: [{ id: 2 }],
              commit: jest.fn()
            }
        }
    })
    const wrapper = shallowMount(TestComponent, config)
    wrapper.find('button').trigger('click')
    const spy = jest.spyOn(config.mocks.$store, 'commit')
    expect(spy).toHaveBeenCalled()
})

Toàn bộ file spec lúc này

import { shallowMount } from '@vue/test-utils'
import TestComponent from '@/components/TestComponent.vue'

function createConfig (overrides) {
  const id = 1
  const mocks = {
    $auth: {
      check: () => false
    },
    $router: {
      push: jest.fn()
    },
    $store: {
      state: [{ id }],
      commit: jest.fn()
    }
  }
  const propsData = { id }
  return Object.assign({ mocks, propsData }, overrides)
}

describe('TestComponent', () => {
    test('TEST CASE 1: Render HTML', () => {
        const wrapper = shallowMount(TestComponent, createConfig())
        expect(wrapper).toMatchSnapshot()
    })
    test('TEST CASE 2: router login được gọi khi click button mà chưa đăng nhập', () => {
        const config = createConfig()
        const wrapper = shallowMount(TestComponent, config)
        wrapper.find('button').trigger('click')
        const spy = jest.spyOn(config.mocks.$router, 'push')
        expect(spy).toHaveBeenCalledWith({ name: 'login' })
    })

    test('TEST CASE 3: vuex được gọi khi user đã đăng nhập và click button', () => {
        const config = createConfig({
        mocks: {
          $auth: {
            check: () => true
          },
          $store: {
            state: [{ id: 2 }],
            commit: jest.fn()
          }
        }
        })
        const wrapper = shallowMount(TestComponent, config)
        wrapper.find('button').trigger('click')
        const spy = jest.spyOn(config.mocks.$store, 'commit')
        expect(spy).toHaveBeenCalled()
    })
})

Kết

Mindset khi chúng ta viết unit test component là: mọi unit test đều dư thừa, trừ khi bạn có lý do cho việc unit test đó

Các câu hỏi chúng ta đặt ra trước khi viết

  • Component sinh ra trên trái đất này để làm gì
  • Public interface của component là gì, input, output nó là gì
  • Đoạn test đó để kiểm tra code của mình, hay code của người ta?

Các bài viết đã tham khảo

📜 Unit Testing in Vue: What to Test?

📜 Knowing What To Test - Vue Component Unit Testing

Initializing...