沐鳴註冊_理解前端自動化測試TDD + BDD

前言

在日常的開發中,整天趕需求的我們好像沒有時間顧及自動化測試,尤其是在敏捷開發的時候。但其實自動化測試可以幫助我們提高代碼和功能的健壯程度,大幅減少可能出現的bug。

尤其是在複雜系統中,自動化測試的作用不容忽視。本篇文章是我自己的學習記錄,使用測試框架jest和前端框架react來簡單梳理的自動化測試。

日常開發通常涉及到業務代碼的開發以及函數、組件庫的開發。針對這兩方面的自動化測試,在模式和流程上也有各自的要求與側重。這就衍生出了單元測試和集成測試兩種測試方法,以及TDD與BDD的測試開發流程。

單元測試

單元測試,見名知意,可以理解為對系統的某個單元進行測試,而這個單元,可以是某個函數,某個組件,對於這種測試形式來說,我們只關注這個獨立的單元的功能是否正常。測試用例以當前單元內的功能作為對象。

集成測試

將多個單元集成到一起,進行測試,重點關注各個單元串聯起來之後的系統整體功能是否正常。此時的測試用例以多個單元組成的某個獨立的系統為對象。

以上是兩種測試方法,但有時測試的細化程度與系統複雜的操作流程難以平衡,這就需要做出取捨,針對不同的開發主體以及業務場景採用不同的測試+開發的流程。

TDD: 測試驅動開發(Test-Driven Development)

這種模式中,先編寫測試用例,在測試用例的指導下去完善功能,當測試用例編寫完並且都通過測試之後,相應的功能也就做完了。TDD的模式適合於對系統代碼質量和測試覆蓋率有要求的開發主體,比如函數和組件庫。但通常在代碼發生變化的時候,測試用例也要進行相應的調整。

BDD: 行為驅動開發(Behavior Driven Development)

測試用例模擬用戶的操作行為,通常在完成業務代碼開發之後,以用戶的操作為指導編寫測試代碼。當測試用例跑通之後,就可以認為系統的整體流程已經流暢。BDD的模式適用於平時的業務代碼開發,因為業務的需求有可能變更頻繁,但操作流程有可能不會變化,當業務代碼發生變化的時候,可以使用原來的測試用例繼續跑代碼,節省了開發時間。

我認為在平時的項目中,通常使用TDD和BDD相結合來進行測試,TDD負責方法類、獨立組件的測試。BDD則負責整體業務模塊的測試。

從Demo入手來理解自動化測試

讓我們用一個demo來理解一下前端自動化測試,先從搭建環境開始,認識一下和jest以及react有關的配套工具和配置項。

搭建測試環境

如果是用create-react-app 創建的項目,內部會集成好一個jest的測試環境。npm run eject將配置項暴露出來后,在package.json的jest字段內可以看到jest的配置項,也可以將這些配置項複製出來,粘貼到新建的jest.config.js中。

create-react-app生成的jest配置項內容

* 是匹配任意文件夾,是匹配任意文件名

module.exports = {
    // 測試哪些目錄下的文件
    "roots": [
      "<rootDir>/src"
    ],

    // 生成測試覆蓋率報告的時候,統計哪些目錄下以哪些後綴為結尾的文件,前邊加!是不參与統計的意思,.d.ts是ts中的類型聲明文件,所以不用參与統計
    "collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}",
      "!src/**/*.d.ts"
    ],

    // 使用react-app-polyfill/jsdom 解決js兼容性的一些問題
    "setupFiles": [
      "react-app-polyfill/jsdom"
    ],

    // 測試環境建立好以後,會執行裏面的文件,在當前這個場景下,setupTests.js里做的事情就是引入了一些jsdom擴展的matchers。
    "setupFilesAfterEnv": [
      "<rootDir>/src/setupTests.js"
    ],

    // 當測試運行時,要執行一些測試文件,這個配置項內就是用正則匹配要被執行的文件。
    "testMatch": [
      "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
      "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
    ],

    // 因為測試環境是在node中執行的,沒有dom或者window的api,所以這個配置項的值會模擬window或者dom的一些api
    "testEnvironment": "jest-environment-jsdom-fourteen",
    
    // 當引入的文件符合transform這個配置項的key的正則的時候,用value去解析轉換該文件
    "transform": {
      "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
      "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
      "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
    },

    // 與上邊的transform是對應的,當引入的文件符合這個配置項的key的正則的時候,就忽略不做處理
    "transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
      "^.+\\.module\\.(css|sass|scss)$"
    ],

    // 當引入一個模塊在node_modules內找不到時,需要在自定義的路徑下去找,可以將路徑寫在這裏
    "modulePaths": [],

    // 針對css-module,使用identity-obj-proxy將樣式從 .selector: { width: 20px }轉換為 { .selector: '.selector' } 這樣的形式,
    // 目的是在測試中,忽略樣式,所以簡化處理
    "moduleNameMapper": {
      "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
    },

    // 在測試文件中引入文件的時候,如果引入的文件名沒有寫後綴,會依據下邊的後綴去找這個文件
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx",
      "json",
      "jsx",
      "node"
    ],

    // npm run test命令的時候,進入jest會進入監聽文件變動的模式。這些是監聽的插件,也可以直接使用jest自帶的監聽模式
    "watchPlugins": [
      "jest-watch-typeahead/filename",
      "jest-watch-typeahead/testname"
    ]
  }

如果是完全自己配置的項目,可以在項目內安裝jest,然後npx jest –init,初始化一個jest.config.js文件來配置測試環境,當然完全可以參考create-react-app生成的jest配置項。

以上是配置了一個基本的jest測試環境,對於react項目的測試還是完全不夠的。

使用Enzyme測試react組件

react中組件是一個重要的概念,所以,方便靈活地對組件進行測試也非常重要。

測試組件,涉及到組件的props,state,內部方法。針對這種場景,可以使用enzyme來對組件進行測試。

enzyme是Airbnb公司推出的一款針對React測試的工具,組件可以通過enzyme提供的方法在測試環境中被渲染出來,再通過其餘的API可以獲取或者驗證組件的狀態、行為。

以一個簡單的組件為例:

import React from 'react';

function App() {
  return (
    <div className="App" >'container'>
      hello world
    </div>
  );
}

export default App;

如果對這個組件進行測試,需要首先安裝enzyme。安裝enzyme的同時,也需要安裝enzyme針對react的一個適配器enzyme-adapter-react-16,
適配器最後的数字需要與你當前項目中的react版本一致。

npm i --save-dev enzyme enzyme-adapter-react-16

安裝好之後,在測試用例的文件中引入並配置enzyme。

import React from 'react';
import App from './App';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

test('驗證App組件是否被正確掛載', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[]').length).toBe(1)
});

這段測試代碼驗證這個容器是否存在。為了和業務代碼解耦,測試用例的選擇器(find)不應該使用與業務相關的標記,這裡在需要測試的容器上加上了一個屬性:。

測試用例的意思是用shallow將組件渲染出來,被渲染之後的組件就可以調用一些enzyme提供的方法,這裏的find就是找到的集合,集合的長度如果為1,那就說明該容器存在,測試通過。

當然,不可能寫一個測試文件就引入一次enzyme。可以將enzyme的引入和配置工作放到測試環境準備好的時候,也就是jest.config.js中setupFilesAfterEnv配置項配置的文件中,在該文件中引入。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

shallow 和 mount

在測試用例中,我們也是需要將組件渲染出來的,只不過是這樣寫的:

const wrapper = shallow(<App />)

這裏的shallow,是enzyme提供的方法,可以理解為淺渲染,也就是如果被shallow包裹的組件有嵌套其他組件的話,嵌套的組件會用一個標記來替代。所以只會渲染出組件的第一層,這樣做的目的是為了在對組件做單元測試的時候,只關注當前組件,同時可以大幅度提升性能。

與之對應的還有一個mount方法,這個方法會將所有嵌套的組件都渲染出來,不再對組件進行淺渲染,相當於關注多個組件結合在一起的運行情況。

擴展matchers

在上面的測試用例中,調用的是jest提供的原生的matcher,其實可以使用jest-enzyme提供的一些針對React組件的matchers,更方便地進行測試。

首先,安裝jest-enzyme:

npm install jest-enzyme --save-dev

然後,需要在jest.config.js中,setupFilesAfterEnv中加上jest-enzyme主體文件的路徑,目的是在測試環境準備好之後,初始化jest-enzyme。

"setupFilesAfterEnv": ["./node_modules/jest-enzyme/lib/index.js"'"]

使用了jest-enzyme之後,我們的測試用例的代碼可以改成

test('驗證App組件是否被正確掛載', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[]')).toExist()
});

toExist方法就是jest-enzyme提供的matcher,完成的matchers列表在這裏,隨查隨用。

Demo實戰

環境準備好之後,分別使用TDD與BDD結合單元測試與集成測試開發一個簡單的demo來理解這兩種流程下的自動化測試。

功能點有三個

  • 輸入文字,回車,列表添加一條記錄
  • 回車的同時輸入框內容清空
  • 點擊刪除會刪除該條記錄

代碼結構:Input組件負責輸入內容,List組件負責展示數據並提供刪除的功能。兩個組件嵌套在一個父組件(App)之內。

    <div className="App">
      <Input
        onAddData={onAddData}
      />
      <List
        list={list}
        onDelete={onDelete}
      />
    </div>

TDD + 單元測試

TDD需要在測試的指導下寫代碼,關注點稍微偏重於測試。使用單元測試結合測試驅動開發的流程,應該逐一梳理功能,編寫的測試用例應聚焦在某個單元上。

回到demo上,針對上述的三個功能點和組件各自的職責,先寫測試代碼,然後寫業務代碼,讓業務最後通過測試,完成開發。同時採用單元測試的方式,要保證所編寫的測試用例,只針對組件本身的功能。

先從Input組件入手,梳理組件的功能。

  • 輸入內容后回車,傳入的onAddData方法應該被調用,並且接收到的參數就是最終輸入的內容
  • 輸入內容后回車,輸入框的內容應清空

從第一條開始,編寫測試代碼:

test('輸入內容,點擊回車,Input組件的onAddData應該被調用並且接收到正確的參數', () => {
  const fn = jest.fn()
  const wrapper = shallow(<Input
    onAddData={fn}
  />)
  const input = wrapper.find('[]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(fn).toHaveBeenCalledWith('hello')
})

測試代碼驗證輸入內容回車后,傳入Input組件的函數會不會被調用,並且驗證是否可以接收到正確的值。

這裏用到了jest的Mock Functions功能。使用enzyme提供的shallow將組件渲染出來后,找到input並模擬keyup事件,在接下來的流程中驗證fn是否被調用並接收到了正確的值。

現在因為還沒有寫業務代碼,測試是不會通過的。接下來看一下Input組件此時的實現:

const Input = (props) => {
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    >"input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

App.js補充onAddData的函數

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
    </div>
  );
}

再繼續,當回車后,輸入框的內容應該被清空,針對這個點編寫測試代碼

test('點擊回車,Input組件的輸入框內容應該清空', () => {
  const wrapper = shallow(<Input onAddData={() => {}} />)
  const input = wrapper.find('[]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(input.text()).toBe('')
})

然後,在Input組件中將這個邏輯補上

const Input = (props) => {
  const [ value, setValue ] = useState('') // 針對測試用例新加的代碼
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    >"input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        // 針對測試用例新加的代碼
        setValue('')
      }
    }}
  />
}

跑一下測試,兩個測試用例都通過了,就說明Input組件已經基本開發完了,下面分析一下List組件:

  • 接收到列表數據,可以正確的渲染出來
  • 點擊刪除按鈕,onDelete應該被調用,並且接收到當前列表項的索引

從第一條開始編寫測試用例

import React from 'react'
import { shallow } from 'enzyme'
import List from './List'
test('列表組件接收到列表數據,應該渲染出對應數量的列表項', () => {
  const list = ['hello', 'world']
  const wrapper = shallow(<List
    list={list}
  />)
  const items = wrapper.find('[]')
  expect(items.length).toBe(2)
  expect(items.at(0).text()).toBe('hello')
  expect(items.at(1).text()).toBe('world')
})

向List組件傳入了一個數組,之後找到應該渲染出來的元素,判斷其長度和各自的內容。接下來實現它

const List = (props) => {
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item}>
          <span >"list-item">{item}</span>
          <button>刪除</button>
        </p>
      })
    }
  </div>

}

App.js中將list數據傳入List組件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
      <List list={list}/>
    </div>
  );
}

再看第二條:點擊刪除按鈕,onDelete應該被調用,並且接收到當前列表項的索引。測試代碼與Input組件的第一個測試用例大同小異:

test('點擊刪除按鈕,List組件的onDelete方法應該被調用,並且接收到正確的參數', () => {
  const list = ['hello', 'world']
  const fn = jest.fn()
  const wrapper = shallow(<List
    list={list}
    onDelete={fn}
  />)
  const deleteBtn = wrapper.find('[]')
  deleteBtn.at(1).simulate('click')
  expect(fn).toHaveBeenCalledWith(1)
})

然後補齊這個功能的代碼

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span >"list-item">{item}</span>
          <button
            onClick={() => onDelete(index)}
           
          >刪除</button>
        </p>
      })
    }
  </div>
}

App.js中添加刪除的邏輯

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

到此,這個demo就使用TDD+單元測試的模式開發完畢了。TDD由於是先寫測試用例再進行開發,所以會保證每個功能的代碼都是經過測試的,bug自然就少了很多。同時在編寫測試代碼的時候,很自然地要去思考這個功能的代碼如何組織,也在一定程度上提高了代碼的可維護性。

單元測試會保證測試覆蓋率非常高,但在業務開發的場景下,帶來了幾個問題:

  • 代碼量增多,demo中為了測試功能編寫了很多的測試用例,有時單元測試代碼甚至會比業務代碼多。
  • 業務耦合度高,測試用例中使用了業務中一些模擬的數據,當業務代碼變更的時候,要去重新組織測試用例。
  • 關注點過於獨立,由於單元測試只關注這一個單元的健康狀況,無法保證多個單元組成的整體是否正常。

這幾個問題說明用單元測試來進行業務測試或許不是一個明智的做法,下面就介紹一種適合業務場景的測試方法。

BDD + 集成測試

BDD實際上是模擬用戶的行為,在業務代碼完成后,用測試用例模擬用戶的操作行為,由於關注點上升到了整個系統的層面,所以使用集成測試,應該忽略組件個體的行為,保證系統行為的流暢。

由於是先完成業務代碼,再做測試,所以看一下最終的代碼:

App組件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

Input組件

const Input = (props) => {
  const [ value, setValue ] = useState('')
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    >"input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

List組件

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span >"list-item">{item}</span>
          <button
             onClick={() => onDelete(index)}
             
          >刪除</button>
        </p>
      })
    }
  </div>
}

現在梳理demo的功能,有兩點:

  • 輸入內容回車之後,列表應該展示輸入的內容
  • 點擊列表項的刪除按鈕,應該把這一項刪除

針對兩個功能來編寫各自的測試用例。與單元測試不同的是,我們的測試對象是Input、List、App這三個組件組成的系統,App組件內包含了所有邏輯,要在在測試用例中將App組件以及內部的嵌套組件都渲染出來,所以不再使用enzyme的shallow方法,轉而使用mount方法做深度渲染。

下面寫出這兩個功能的測試代碼:

import React from 'react'
import App from './App'
import { mount } from 'enzyme'

test('Input組件輸入內容后回車,List組件應該將內容展示出來', () => {
  const appWrapper = mount(<App />)
  const input = appWrapper.find('[]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const items = appWrapper.find('[]')
  expect(items.length).toBe(1)
  expect(items.at(0).text()).toBe('hello')
})

test('點擊列表項的刪除按鈕,List組件內相應的記錄應被刪除', () => {
  const appWrapper = mount(<App />)
  // 先添加一條數據,便於刪除
  const input = appWrapper.find('[]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const deleteBtn = appWrapper.find('[]')
  deleteBtn.at(0).simulate('click')
  const items = appWrapper.find('[]')
  expect(items.length).toBe(0)
})

第一個測試用例將App渲染出來后,找到輸入框,模擬回車事件,傳入相應的內容。之後找到列表項,如果列表的長度為1並且內容是hello,則測試通過。

第二個測試用例要先加1條數據,再找到刪除按鈕,模擬點擊事件,如果此時列表項長度為0,則測試通過。

通過上面這個demo可以明白集成測試相對於單元測試,更多側重多組件的協同,假如一個組件本身沒有問題,但與其他組件配合的時候出問題了,那整個流程是不會通過測試的。再結合BDD,使開發時更加關注業務代碼,不必先寫繁瑣的測試用例。而且只要操作流程不會變,那測試用例也基本不用動,更加適合平時業務的開發。

總結

自動化測試確實會在一定程度上增加開發的工作量,但經過測試的系統,穩定性的提升會讓我們更有信心。文中介紹的兩種開發+自動化測試的組合模式可以應對不同的開發場景,希望大家可以針對自己的場景,選擇合適的方式來引入自動化測試,無論是對提升系統健壯程度還是深化前端工程化,都非常有幫助。

來自:https://segmentfault.com/a/1190000022503519

站長推薦

1.雲服務推薦: 國內主流雲服務商,各類雲產品的最新活動,優惠券領取。地址:阿里雲騰訊雲華為雲

2.廣告聯盟: 整理了目前主流的廣告聯盟平台,如果你有流量,可以作為參考選擇適合你的平台點擊進入

鏈接: http://www.fly63.com/article/detial/8785