上一篇文章《測試你的前端程式碼 – part2(單元測試)》中,我介紹了關於單元測試的基本知識,從本文介紹端到端測試(E2E 測試)。
端到端測試
在第二部分中,我們使用 Mocha 測試了應用中最核心的邏輯,calculator 模組。本文中我們將使用端到端測試整個應用,實際上是模擬了使用者所有可能的操作進行測試。
在我們的例子中,計算器展示出來的前端即為整個應用,因為沒有後端。所以端到端測試就是說直接在瀏覽器中執行應用,通過鍵盤做一系列計算操作,且保證所展示的輸出結果都是正確的。
是否需要像單元測試那樣,測試各種組合呢?並不是,我們已經在單元測試中測試過了,端到端測試不是檢查某個單元是否 ok,而是把它們放到一起,檢查還是否能夠正確執行。
需要多少端到端測試
首先給出結論:端到端測試不需要太多。
第一個原因,如果已經通過了單元測試和整合測試,那麼可能已經把所有的模組都測試過了。那麼端到端測試的作用就是把所有的單元測試綁到一起進行測試,所以不需要很多端到端測試。
第二個原因,這類測試一般都很慢。如果像單元測試那樣有幾百個端到端測試,那執行測試將會非常慢,這就違背了一個很重要的測試原則——測試迅速反饋結果。
第三個原因,端到端測試的結果有時候會出現 flaky 的情況。Flaky 測試是指通常情況下可以測試通過,但是偶爾會出現測試失敗的情況,也就是不穩定測試。單元測試幾乎不會出現不穩定的情況,因為單元測試通常是簡單輸入,簡單輸出。一旦測試涉及到了 I/O,那麼不穩定測試可能就出現了。那可以減少不穩定測試嗎?答案是肯定的,可以把不穩定測試出現的頻率減少到可以接受的程度。那能夠徹底消除不穩定測試嗎?也許可以,但是我到現在還沒見到過[笑著哭]。
所以為了減少我們測試中的不穩定因素,儘量減少端到端測試。10 個以內的端到端測試,每個都測試應用的主要工作流。
寫端到端測試程式碼
好了,廢話不多說,開始介紹寫端到端程式碼。首先需要準備好兩件事情:1. 一個瀏覽器;2. 執行前端程式碼的伺服器。
因為要使用 Mocha 進行端到端測試,就和之前單元測試一樣,需要先對瀏覽器和 web 伺服器進行一些配置。使用 Mocha 的 before 鉤子設定初始化狀態,使用 after 鉤子清理測試後狀態。before 和 after 鉤子分別在測試的開始和結束時執行。
下面一起來看下 web 伺服器的設定。
設定 Web 伺服器
配置一個 Node Web 伺服器,首先想到的就是 express 了,話不多說,直接上程式碼:
1 2 3 4 5 6 7 8 9 10 |
let server before((done) = > { const app = express() app.use('/', express.static(path.resolve(__dirname, '../../dist'))) server = app.listen(8080, done) }) after(() = > { server.close() }) |
程式碼中,before
鉤子中建立一個 express 應用,指向 dist
資料夾,並且監聽 8080 埠,結束的時候在 after
鉤子中關閉伺服器。
dist
資料夾是什麼?是我們打包 JS 檔案的地方(使用 Webpack打包),HTML 檔案,CSS 檔案也都在這裡。可以看一下 package.json
的程式碼:
1 2 3 4 5 6 7 |
{ "name": "frontend-testing", "scripts": { "build": "webpack && cp public/* dist", "test": "mocha 'test/**/test-*.js' && eslint test lib", ... }, |
對於端到端測試,要記得在執行 npm test
之前,先執行 npm run build
。其實這樣很不方便,想一下之前的單元測試,不需要做這麼複雜的操作,就是因為它可以直接在 node 環境下執行,既不用轉譯,也不用打包。
出於完整性考慮,看一下 webpack.config.js
檔案,它是用來告訴 webpack 怎樣處理打包:
1 2 3 4 5 6 7 8 |
module.exports = { entry: './lib/app.js', output: { filename: 'bundle.js', path: path.resolve(__dirname, 'dist') }, ... } |
上面的程式碼指的是,Webpack 會讀取 app.js
檔案,然後將 dist
資料夾中所有用到的檔案都打包到 bundle.js
中。dist
資料夾會同時應用在生產環境和端到端測試環境。這裡要注意一個很重要的事情,端到端測試的執行環境要儘量和生產環境保持一致。
設定瀏覽器
現在我們已經設定完了後端,應用已經有了伺服器提供服務了,現在要在瀏覽器中執行我們的計算器應用。用什麼包來驅動自動執行程式呢,我經常使用 selenium-webdriver
,這是一個很流行的包。
首先看一下如何使用驅動:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
const { prepareDriver, cleanupDriver } = require('../utils/browser-automation') //... describe('calculator app', function () { let driver ... before(async() = > { driver = await prepareDriver() }) after(() = > cleanupDriver(driver)) it('should work', async function () { await driver.get('http://localhost:8080') //... }) }) |
before
中,準備好驅動,在 after
中把它清理掉。準備好驅動後,會自動執行瀏覽器(Chrome,稍後會看到),清理掉以後會關閉瀏覽器。這裡注意,準備驅動的過程是非同步的,返回一個 promise,所以我們使用 async/await 功能來使程式碼看起來更美觀(Node7.7,第一個本地支援 async/await 的版本)。
最後在測試函式中,傳遞網址:http:/localhost:8080
,還是使用 await,讓 driver.get
成為非同步函式。
你是否有好奇 prepareDriver
和 cleanupDriver
函式長什麼樣呢?一起來看下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const webdriver = require('selenium-webdriver') const chromeDriver = require('chromedriver') const path = require('path') const chromeDriverPathAddition = `: $ { path.dirname(chromeDriver.path) }` exports.prepareDriver = async() = > { process.on('beforeExit', () = > this.browser && this.browser.quit()) process.env.PATH += chromeDriverPathAddition return await new webdriver.Builder() .disableEnvironmentOverrides() .forBrowser('chrome') .setLoggingPrefs({ browser: 'ALL', driver: 'ALL' }) .build() } exports.cleanupDriver = async(driver) = > { if (driver) { driver.quit() } process.env.PATH = process.env.PATH.replace(chromeDriverPathAddition, '') } |
可以看到,上面這段程式碼很笨重,而且只能在 Unix 系統上執行。理論上,你可以不用看懂,直接複製/貼上到你的測試程式碼中就可以了,這裡我還是深入講一下。
前兩行引入了 webdriver 和我們使用的瀏覽器驅動 chromedriver。Selenium Webdriver 的工作原理是通過 API(第一行中引入的 selenium-webdriver
)呼叫瀏覽器,這依賴於被調瀏覽器的驅動。本例中被調瀏覽器驅動是 chromedriver
,在第二行引入。
chrome driver 不需要在機器上裝了 Chrome,實際上在你執行 npm install
的時候,已經裝了它自帶的可執行 Chrome 程式。接下來 chromedriver
的目錄名需要新增進環境變數中,見程式碼中的第 9 行,在清理的時候再把它刪掉,見程式碼中第 22 行。
設定了瀏覽器驅動以後,我們來設定 web driver,見程式碼的 11 – 15 行。因為 build
函式是非同步的,所以它也使用 await
。到現在為止,驅動部分就已經設定完畢了。
測試吧!
設定完驅動以後,該看一下測試的程式碼了。完整的測試程式碼在這裡,下面列出部分程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// ... const retry = require('promise-retry') // ... it('should work', async function () { await driver.get('http://localhost:8080') await retry(async() = > { const title = await driver.getTitle() expect(title).to.equal('Calculator') }) //... |
這裡的程式碼呼叫計算器應用,檢查應用標題是不是 “Calculator”。程式碼中第 6 行,給瀏覽器賦地址:http://localhost:8080
,記得要使用 await
。再看第 9 行,呼叫瀏覽器並且返回瀏覽器的標題,在第 10 行中與預期的標題進行比較。
這裡還有一個問題,這裡引入了 promise-retry
模組進行重試,為什麼需要重試?原因是這樣的,當我們告訴瀏覽器執行某命令,比如定位到一個 URL,瀏覽器會去執行,但是是非同步執行。瀏覽器執行的非常快,這時候對於開發人員來講,確切地知道瀏覽器“正在執行”,要比僅僅知道一個結果更重要。正是因為瀏覽器執行的非常快,所以如果不重試的話,很容易被 await
所愚弄。在後面的測試中 promise-retry
也會經常使用,這就是為什麼在端到端測試中需要重試的原因。
測試 Element
來看測試的下一階段,測試元素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const { By } = require('selenium-webdriver') it('should work', async function () { await driver.get('http://localhost:8080') //... await retry(async() = > { const displayElement = await driver.findElement(By.css('.display')) const displayText = await displayElement.getText() expect(displayText).to.equal('0') }) //... |
下一個要測試的是初始化狀態下所顯示的是不是 “0”,那麼首先就需要找到控制顯示的 element,在我們的例子中是 display
。見第 7 行程式碼,webdriver 的 findElement
方法返回我們所要找的元素。可以通過 By.id
或者 By.css
再或者其他找元素的方法。這裡我使用 By.css
,它很常用,另外提一句 By.javascript
也很常用。
(不知道你是否注意到,By
是由最上面的 selenium-webdriver
所引入的)
當我們獲取到了 element 以後,就可以使用 getText()
(還可以使用其他操作 element 的函式),來獲取元素文字,並且檢查它是否和預期一樣,見第 10 行。對了,不要忘記:
測試 UI
現在該來從 UI 層面測試應用了,點選數字和操作符,測試計算器是不是按照預期的執行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const digit4Element = await driver.findElement(By.css('.digit-4')) const digit2Element = await driver.findElement(By.css('.digit-2')) const operatorMultiply = await driver.findElement(By.css('.operator-multiply')) const operatorEquals = await driver.findElement(By.css('.operator-equals')) await digit4Element.click() await digit2Element.click() await operatorMultiply.click() await digit2Element.click() await operatorEquals.click() await retry(async() = > { const displayElement = await driver.findElement(By.css('.display')) const displayText = await displayElement.getText() expect(displayText).to.equal('84') }) |
程式碼 2 – 4 行,定義數字和操作;6 – 10 行模擬點選。實際上想實現的是 “42 * 2 = ”。最終獲得正確的結果——“84”。
執行測試
已經介紹完了端到端測試和單元測試,現在用 npm test
來執行所有測試:
一次性全部通過!(這是當然的了,不然怎麼寫文章。)
想說點關於使用 await 的一些話
你在可能網路上其他地方看到一些例子,它們並沒有使用 async/await
,或者是使用了 promise
。實際上這樣的程式碼是同步的。那麼為什麼也能 work 的很好呢?坦白地說,我也不知道,看起來像是在 webdriver 中有些 trick 的處理。正如 selenium 文件中說道,在 Node 支援 async/await
之前,這是一個臨時的解決方案。
Selenium 文件是 Java 語言。它還不完整,但是包含的資訊也足夠了,你做幾次測試就能掌握這個技能。
總結
本文中主要介紹了什麼:
- 介紹了端到端測試中設定瀏覽器的程式碼;
- 介紹瞭如何使用 webdriver API 來呼叫瀏覽器,以及如何獲取 DOM 中的 element;
- 介紹了使用 async/await,因為所有 webdriver API 都是非同步的;
- 介紹了為什麼端到端測試中要使用 retry。