長沙哪個網(wǎng)站建設最好重慶可靠的關鍵詞優(yōu)化研發(fā)
一、前端自動化測試需要測什么
1. 函數(shù)的執(zhí)行邏輯,對于給定的輸入,輸出是否符合預期。
2. 用戶行為的響應邏輯。
-? 對于單元測試而言,測試粒度較細,需要測試內部狀態(tài)的變更與相應函數(shù)是否成功被調用。
-? 對于集成測試而言,測試粒度較粗,一般測試ui展示上的變更(文本內容改變、組件類別改變等)。
3. 快照測試。對于不需要經常修改dom結構的組件,我們會存儲一個快照,如果在后續(xù)的版本中修改了dom結構,測試用例會不通過,需要確認更新快照。
二、為什么需要自動化測試
你或許會疑惑,如果我們做的是一些業(yè)務的開發(fā),而不是工具類函數(shù)的開發(fā),似乎手動測試就可以滿足需求。對于用戶行為的響應邏輯可以通過點擊來測試,dom結構的變更也可以通過肉眼觀察。測試用例的代碼可能甚至比業(yè)務代碼量大,那前端有必要耗時耗力的進行自動化測試嗎?
長期來看,集成自動化測試是有必要的。
1. 有利于回歸測試。在公司項目中產品是經常迭代的,當我們修改了A功能,就需要測試相關聯(lián)的B功能不受影響。如果沒有自動化測試,每一次回歸測試都需要手動進行,且并不能保證你沒納入考慮范圍的C功能是不受影響的。自動化測試有利于降低回歸測試的成本,并提高程序員的安全感。
2. 有利于代碼重構。跟上一點類似,我們需要保證重構前后的預期是一致的,這時候我們就可以先對于老代碼編寫測試用例,使測試用例能夠全部通過。再重構業(yè)務代碼,如果重構后的代碼也能通過全部的測試用例,那么代碼的可靠性是較高的。如果采用手動測試的方案,那么你的執(zhí)行流可能是:重構A功能->測試A功能->重構B功能->測試A和B功能。因為重構前的代碼架構通常會混亂一點,所以為了確保后重構的功能不影響先重構的功能,手動測試的工作量會越來越大。
3. 有利于代碼優(yōu)化。當測試用例通過后就可以放心的進行代碼優(yōu)化了,省去了每次代碼優(yōu)化完手動測試的成本。
4. 前端開發(fā)與后端接口解耦。假設后端接口還未開發(fā)完成,當我們和后端約定好數(shù)據(jù)結構后,就可以模擬后端接口返回的數(shù)據(jù)并進行測試。當然,我們也可以選擇在業(yè)務代碼里寫死數(shù)據(jù)并進行手動測試,但這就意味著后續(xù)需要修改業(yè)務代碼;或者選擇用抓包工具模擬響應結果進行手動測試,這種不需要修改業(yè)務代碼,但是需要權衡手動測試和自動化測試的成本。
三、TDD與BDD
測試驅動開發(fā)(Test-Driven Development)的流程如下:
1. 根據(jù)要實現(xiàn)的功能編寫測試用例,測試用例不通過
2. 實現(xiàn)相關功能,測試用例通過
3. 優(yōu)化代碼,完成開發(fā)
由于測試用例不通過時會顯示紅色,測試用例通過后會顯示綠色,所以測試驅動開發(fā)又稱Red-Green-Development。
測試驅動開發(fā)的優(yōu)點如下:
1. 實現(xiàn)代碼前先編寫測試用例,確保代碼一定是易于測試的,在開發(fā)視角的基礎上擴展了測試視角,代碼的組織架構會更好。
2. 如果測試用例有誤,可能在實現(xiàn)功能前后均可以通過測試。由于我們先編寫測試用例后開發(fā),如果測試用例在開發(fā)前就通過,那么大概率測試用例是有問題的,我們就可以及時發(fā)現(xiàn)修改。降低了編寫出錯誤測試代碼的可能性。
3. 自動化測試的通用優(yōu)點。
行為驅動開發(fā)(Behavior-Driven Development)一般是測試驅動開發(fā)的自然延伸,它的核心在于根據(jù)用戶行為來設計測試用例,對于是否需要在開發(fā)前編寫測試用例沒有強制要求。
四、單元測試與集成測試
單元測試的優(yōu)點:?測試粒度細,代碼覆蓋率高,運行速度快。
單元測試的缺點:
1. 代碼量大 。
2. 關注代碼實現(xiàn)細節(jié),與業(yè)務代碼耦合度高。
3. 每個單元的單元測試通過,也無法保證集成后能正常運行。比如說A組件給B組件傳入data,A組件測試傳入的data結構為對象,B組件測試接收到的data為數(shù)組,兩個組建的測試用例都能通過,但是集成后運行就會由于數(shù)據(jù)結構不一致報錯。
單元測試的適用場景:工具庫。
集成測試的優(yōu)點:
1. 測試粒度沒那么細,比所有單元的單元測試代碼量總和小。
2. 不關注代碼實現(xiàn)細節(jié),只關心展示給用戶的結果,業(yè)務代碼耦合度較低。
3. 集成測試能確保單元能夠協(xié)作運行,通過集成測試通常來講系統(tǒng)對于用戶能夠正常運行。
集成測試的缺點:
1. 集成測試測試可能不如單元測試細致。
2.?集成測試需要運行多個組件,測試速度會慢一些。
集成測試的適用場景:業(yè)務系統(tǒng)。
五、具體實現(xiàn)
單元測試、集成測試、TDD、BDD之間要怎么集成其實沒有標準答案,而且BDD和TDD本身也不是對立的概念。只是BDD本身是以用戶的“故事”為導向的,這些故事通常涉及不止一個單元所以BDD通常和集成測試結合,單元測試與TDD結合。
1. 單元測試與TDD
用react腳手架創(chuàng)建出項目后,由于enzyme官方沒有適配react17及以上版本,需要將react版本降級為16。如果需要用react17及以上的版本,可以用非官方的適配器,或者改用react-testing-library。但是react-testing-library本身不關注代碼實現(xiàn)細節(jié),而是以用戶視角觸發(fā)的,所以我個人感覺不是很適合做單元測試。
npm install react@16 react-dom@16 --save
npm install enzyme enzyme-adapter-react-16 --save-dev
?我們做一個簡單的todoList項目,當我們在輸入框中輸入內容,按下回車后就會展現(xiàn)在下方。點擊項尾的刪除鍵可以進行刪除。
我們將這個項目拆分為Header組件和UndoList組件。
我們在src目錄下創(chuàng)建如下結構,__tests__/unit目錄下編寫單元測試代碼,TodoList目錄下編寫業(yè)務代碼。
因為采用TDD的模式開發(fā),所以先來編寫測試代碼。
環(huán)境準備
enzyme與react集成需要配置適配器,由于這個配置需要在每個測試文件最開始引入,所以我們可以將其抽離到一個單獨的文件中,然后對jest進行配置,讓jest在測試環(huán)境準備好后執(zhí)行該文件。
react內部其實已經對jest進行了配置,我們需要做的就是將其暴露出來。
// npm run eject的執(zhí)行前提是沒有未追蹤的文件,所以我們需要先初始化git倉庫并提交
git init
git add .
git commit -m "init resposity"
npm run eject
?在運行完npm run eject后,我們可以發(fā)現(xiàn)package.json文件有新增的配置項Jest,配置項中有一個屬性是setupFilesAfterEnv。
"jest": {// ..."setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],// ...},
?可以看出,setupTests文件會在測試環(huán)境準備好后執(zhí)行,所以我們只需要新增文件setupEnzyme.js,修改jest配置項,并將enzyme的適配器配置填入該文件即可。
"jest": {// ..."setupFilesAfterEnv": ["<rootDir>/src/setupTests.js","<rootDir>/src/setupEnzyme.js"],// ...},
// src/setupEnzyme.jsimport Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';Enzyme.configure({ adapter: new Adapter() });
由于jest本身不支持TextEncoder、TextDecoder、ReadableStream,而在enzyme內部又會調用到相應的方法,所以我們需要在import Enzyme前將方法掛載在global上。
// setupTests.jsimport { TextEncoder, TextDecoder, ReadableStream } from 'util';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
global.ReadableStream = ReadableStream;
測試用例編寫
- Header
header組件的功能是點擊回車鍵時,能將數(shù)據(jù)傳送給TodoList,我們將header組件設計成受控組件(即組件內的狀態(tài)會根據(jù)用戶輸入實時更新)。對功能進行拆解如下:
1. 輸入框的展示值為state.inputData,state.inputData初始化為空。
2. input框輸入內容時,state.inputData隨之改變。
3. 當輸入框不為空時,用戶敲擊回車后,調用props.addUndoItem,state.inputData清空。
4. 當輸入框為空時,用戶敲擊回車后,不調用props.addUndoItem.
4. 快照測試。
test('輸入框的展示值為state,state.inputData初始化為空', () => {const wrapper = shallow(<Header />);const input = wrapper.find("[data-test-id='input']");expect(input.prop('value')).toBe(wrapper.state('inputData'));expect(wrapper.state('inputData')).toBe('');
})
由于我們還未編寫業(yè)務代碼,運行npx jest時,測試用例是不通過的。
業(yè)務代碼編寫
由于enzyme只能追蹤到類組件里的狀態(tài),所以這里我們創(chuàng)建類組件。
如果你需要用函數(shù)組件,可以參考Testing React Hook State Changes - DEV Community,他的核心思路是相信react,只要我們調用了setState方法,傳入了正確的參數(shù),就認為狀態(tài)可以被正確的修改。通過mock setState方法,來判斷調用了函數(shù)并傳入了正確的參數(shù)。
import React, { Component } from "react";export default class Header extends Component {state = {inputData: "",};render() {return (<div><input data-test-id="input" value={this.state.inputData} /></div>);}
}
再次運行測試,測試通過。
?然后是第2-4個測試用例及業(yè)務代碼。
test('輸入框輸入字符時,state.inputData隨之改變', () => {const wrapper = shallow(<Header />);const input = wrapper.find("[data-test-id='input']");const inputData = "hello world";input.simulate('change', { target: { value: inputData } });expect(wrapper.state('inputData')).toBe(inputData);
})
import React, { Component } from "react";export default class Header extends Component {state = {inputData: "",};render() {return (<div><inputdata-test-id="input"value={this.state.inputData}onChange={(e) => this.setState({ inputData: e.target.value })}/></div>);}
}
test('當輸入框不為空時,用戶敲擊回車后,調用props.addUndoItem,state.inputData清空', () => {const func = jest.fn();const wrapper = shallow(<Header addUndoItem={func} />);const inputData = "hello world";wrapper.setState({ inputData });const input = wrapper.find("[data-test-id='input']");input.simulate('keyUp', { keyCode: 13 });expect(func).toHaveBeenCalled();expect(func).toHaveBeenLastCalledWith(inputData);expect(wrapper.state('inputData')).toBe('');
})test('當輸入框為空時,用戶敲擊回車后,調用props.addUndoItem,state.inputData清空', () => {const func = jest.fn();const wrapper = shallow(<Header addUndoItem={func} />);wrapper.setState({ inputData: '' });const input = wrapper.find("[data-test-id='input']");input.simulate('keyUp', { keyCode: 13 });expect(func).not.toHaveBeenCalled();
})
import React, { Component } from "react";export default class Header extends Component {state = {inputData: "",};handleKeyUp = (e) => {if (e.keyCode === 13 && this.state.inputData !== "") {this.props.addUndoItem(this.state.inputData);this.setState({ inputData: "" });}};render() {return (<div><inputdata-test-id="input"value={this.state.inputData}onChange={(e) => this.setState({ inputData: e.target.value })}onKeyUp={this.handleKeyUp}/></div>);}
}
header組件的邏輯編寫完成,接下來我們補充完樣式以后,就可以進行快照測試了。
先引入一下header。
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'))// App.jsx
import TodoList from './container/TodoList';
export default function App() {return <TodoList />
}// container/TodoList/index.jsx
import React, { Component } from "react";
import Header from "./Header";
export default class index extends Component {render() {return (<div><Header /></div>);}
}
運行npm run start,頁面展示如下。
我們對樣式進行優(yōu)化,優(yōu)化后頁面展示如下。
// container/TodoList/style.css.header-wrapper {display: flex;align-items: center;justify-content: center;height: 100px;background-color: #e1d3d3;gap: 20px;
}.header-input {outline: none;line-height: 24px;width: 360px;border-radius: 5px;text-indent: 10px;
}.header-span {font-size: 24px;font-weight: bold;
}
// container/TodoList/Header.jsx
import React, { Component } from "react";export default class Header extends Component {state = {inputData: "",};handleKeyUp = (e) => {if (e.keyCode === 13 && this.state.inputData !== "") {this.props.addUndoItem(this.state.inputData);this.setState({ inputData: "" });}};render() {return (<div className="header-wrapper"><span className="header-span">TodoList</span><inputdata-test-id="input"className="header-input"value={this.state.inputData}placeholder="請輸入待辦項"onChange={(e) => this.setState({ inputData: e.target.value })}onKeyUp={this.handleKeyUp}/></div>);}
}
// container/TodoList/index.jsx
import "./style.css";
// ...
?快照測試
test('快照測試', () => {const wrapper = shallow(<Header />);expect(wrapper).toMatchSnapshot();
})
?運行測試用例后,會生成__snapshot__文件夾,后續(xù)修改樣式/dom結構都會導致測試不通過。
剩下兩個組件的流程不做贅述,編寫完代碼如下。
// __tests__/TodoList.jsimport { shallow } from "enzyme";
import TodoList from '../../index';let wrapper;
beforeEach(() => {wrapper = shallow(<TodoList />);
})test('快照測試', () => {expect(wrapper).toMatchSnapshot();
})test('state.undoList初始化為空', () => {expect(wrapper.state('undoList')).toEqual([]);
})test('向header傳入addUndoItem方法,當該方法被調用時,更新state.undoList', () => {const header = wrapper.find("[data-test-id='header']");expect(header.prop('addUndoItem')).toBeTruthy();expect(header.prop('addUndoItem')).toEqual(wrapper.instance().addUndoItem);const prevUndoList = ['hello'];wrapper.setState({ undoList: prevUndoList });const inputData = 'world';wrapper.instance().addUndoItem(inputData);expect(wrapper.state('undoList')).toEqual([...prevUndoList, inputData]);
})test('向undoList組件傳入list屬性,屬性值為state.undoList', () => {const undoList = wrapper.find("[data-test-id='undo-list']");expect(undoList.prop('list')).toBeTruthy();expect(undoList.prop('list')).toEqual(wrapper.state('undoList'));
})test('向undoList組件傳入deleteUndoItem方法,當該方法被調用時,更新state.undoList', () => {const undoListData = ['hello', 'world'];wrapper.setState({ undoList: [...undoListData] });const undoList = wrapper.find("[data-test-id='undo-list']");expect(undoList.prop('deleteUndoItem')).toBeTruthy();expect(undoList.prop('deleteUndoItem')).toEqual(wrapper.instance().deleteUndoItem);wrapper.instance().deleteUndoItem(0);expect(wrapper.state('undoList')).toEqual([undoListData[1]]);
})
// TodoList/index.js
import React, { Component } from "react";
import Header from "./Header";
import "./style.css";
import UndoList from "./UndoList";export default class index extends Component {state = {undoList: [],};addUndoItem = (item) => {this.setState({undoList: [...this.state.undoList, item],});};deleteUndoItem = (index) => {const newUndoList = this.state.undoList;newUndoList.splice(index, 1);this.setState({ undoList: newUndoList });};render() {return (<div><Header data-test-id="header" addUndoItem={this.addUndoItem} /><UndoListdata-test-id="undo-list"list={this.state.undoList}deleteUndoItem={this.deleteUndoItem}/></div>);}
}
// __tests__/Undolist.js
import { shallow } from "enzyme";
import UndoList from "../../UndoList";test('快照測試', () => {const wrapper = shallow(<UndoList list={[]} />);expect(wrapper).toMatchSnapshot();
})test('props.list為空時,列表展示為空', () => {const wrapper = shallow(<UndoList list={[]} />);// console.log(wrapper.find("[data-test-id='list-item']"));expect(wrapper.find("[data-test-id='list-item']").length).toBe(0);
})test('props.list為不為空時,列表展示對應項', () => {const list = ['hello', 'world'];const wrapper = shallow(<UndoList list={list} />);const listItem = wrapper.find("[data-test-id='list-item']");expect(listItem.length).toBe(2);expect(listItem.at(0).text()).toBe('hello');expect(listItem.at(1).text()).toBe('world');
})test('點擊刪除按鈕時,調用props.deleteUndoItem', () => {const list = ['hello', 'world'];const func = jest.fn();const wrapper = shallow(<UndoList list={list} deleteUndoItem={func} />);const deleteBtn = wrapper.find("[data-test-id='delete-btn']");deleteBtn.at(0).simulate('click');expect(func).toHaveBeenCalled();expect(func).toHaveBeenLastCalledWith(0);
})
// UndoList.jsx
import React, { Component } from "react";export default class UndoList extends Component {render() {return (<ul className="undo-list-wrapper">{this.props.list.map((item, index) => {return (<li key={index} className="undo-list-item"><div data-test-id="list-item">{item}</div><divdata-test-id="delete-btn"className="undo-delete-btn"onClick={() => this.props.deleteUndoItem(index)}>-</div></li>);})}</ul>);}
}
頁面展示效果如下
我們運行npx jest --coverage來看一下測試覆蓋率。
運行完命令后會新生成coverage文件夾,我們在瀏覽器打開index.html。
由于我們沒給src/index.js和src/App.jsx編寫測試用例,所以第一條顯示為0。但是對于我們編寫了測試用例的TodoList組件,可以看到測試的覆蓋率是百分百,所以TDD這種開發(fā)模式的代碼覆蓋率是非常高的。
2. 集成測試與BDD
可以看出,在進行單元測試時,測試代碼量是比較大的,如果單元內部的邏輯很復雜,那么測試代碼量還會大幅增加。
而且,我們在單元測試中用了大量業(yè)務代碼內的屬性,像state、props等,這些其實對于用戶來說是不可見的。那么,我們可不可以站在用戶視角,以模擬用戶行為的方式來進行黑盒測試呢?答案是肯定的。
站在用戶角度,Todolist無非就干了三件事。
1. 待辦項初始化為空。
2. 輸入待辦項后回車,待辦項會被展示在最下方。
3. 點擊刪除按鈕,對應的待辦項被刪除。
根據(jù)以上的用戶故事,我們編寫對應的測試代碼。
import { mount } from 'enzyme';
import TodoList from '../../index';let wrapper;// 對于集成測試而言,我們需要渲染子組件,所以調用mount方法
// 對于mount方法,元素會被真正掛載在頁面上,所以如果我們在兩個測試用例里面分別創(chuàng)建了wrapper
// 且每個wrapper中有一個undoListItem
// 那么在不調用卸載方法的情況下,頁面上會存在兩個undoListItem
// 所以在集成測試里,我只創(chuàng)建了一次wrapper
beforeAll(() => {wrapper = mount(<TodoList />);
});test(`1. 用戶進入網(wǎng)站2. 待辦項顯示為空`, () => {const undoListItem = wrapper.find("[data-test-id='list-item']");expect(undoListItem.length).toBe(0);
})test(`1. 用戶輸入待辦項2. 用戶敲擊回車3. 待辦項展示在下方`, () => {const input = wrapper.find("[data-test-id='input']");const inputData = "hello";input.simulate('change', { target: { value: inputData } });input.simulate('keyUp', { keyCode: 13 });const undoListItem = wrapper.find("[data-test-id='list-item']")expect(undoListItem.length).toBe(1);expect(undoListItem.text()).toBe(inputData);
});test(`1. 用戶輸入待辦項2. 用戶敲擊回車3. 在原待辦項下方新增待辦項`, () => {const input = wrapper.find("[data-test-id='input']");const inputData = "world";input.simulate('change', { target: { value: inputData } });input.simulate('keyUp', { keyCode: 13 });const undoListItem = wrapper.find("[data-test-id='list-item']");expect(undoListItem.length).toBe(2);expect(undoListItem.at(1).text()).toBe(inputData);
});test(`1. 用戶點擊第一項的刪除按鈕2. 第一項被刪除`, () => {const deleteBtn = wrapper.find("[data-test-id='delete-btn']");deleteBtn.at(0).simulate('click');const undoListItem = wrapper.find("[data-test-id='list-item']");expect(undoListItem.length).toBe(1);expect(undoListItem.text()).toBe("world");
})test(`1. 用戶點擊第一項的刪除按鈕2. 第一項被刪除`, () => {const deleteBtn = wrapper.find("[data-test-id='delete-btn']");deleteBtn.at(0).simulate('click');const undoListItem = wrapper.find("[data-test-id='list-item']");expect(undoListItem.length).toBe(0);
})
因為我們已經實現(xiàn)了對應的業(yè)務代碼,所以測試用例均可以正常通過。
可以看出,相比較對于一個個單元進行單元測試,整體編寫集成測試的代碼量是會更少的。如果后續(xù)我們修改了state里的數(shù)據(jù)結構,或者是props的屬性名,只要最終展現(xiàn)在頁面上的結果不變,那么集成測試都可以通過。