iT邦幫忙

7

Week4 - 寫測試的RD竟然比沒寫測試的RD開發得更快!?這是不是搞錯了什麼 [Server的終局之戰系列]

嗨大家好,在寫「鼠年全馬鐵人挑戰-NodeJs轉Golang的爆炸之旅系列」時,其實有時候也會想寫其他東西,所以以後會依照每週不同的想法來撰寫,畢竟40週如果一直寫一個系列,說實在的有點難受,因為這麼長的時間會一直接觸到不同的東西,有時候碰到有趣的東西不能分享是有些痛苦的XD

所以另開了一個系列「鼠年全馬鐵人挑戰-Server的終局之戰系列」


文章也同時發表於medium(`・ω・´)”

關於測試,一直有一張有名的meme圖

為什麼改一下code,會導致debug那麼久呢?最主要是因為副作用-side effect,白話來說就是你改的這些code其實會影響到你預期以外的地方,舉個例子,以下是一個讀json文件的function,並且可以自訂json文件裡面的ps欄位:

const jsonFile = JSON.parse(fs.readFileSync('./文件.json'))

function readFile(ps) {
    jsonFile.ps = ps
    return jsonFile
}

const one = readFile('第一次讀')
const two = readFile('第二次讀')

console.log(one) //ps欄位也變成'第二次讀'
console.log(two) //ps欄位為'第二次讀'

因為jsonFile是由「外部」引入的,所以在第二次呼叫readFile function的時候其實不知不覺把第一次呼叫的結果one給改了,這就是副作用的一種,那要怎麼解決呢?我們可以把程式碼改成如下:

const jsonFile = JSON.parse(fs.readFileSync('./文件.json'))

function readFile(ps, jsonFile) {
    return {...jsonFile, ps: ps}
}

const one = readFile('第一次讀', jsonFile)
const two = readFile('第二次讀', jsonFile)

console.log(one) //ps欄位為'第一次讀'
console.log(two) //ps欄位為'第二次讀'

readFile的jsonFile改為由外部帶入,而readFile裡面的jsonFile也重新copy了這個jsonFile,並重新賦值,這使jsonFile與外部的jsonFile不相依,就不會改到同個變數。

而自從我發生了無數次副作用又在那邊找很久後...

於是就有了TDD

TDD就是

先寫測試,再開發功能,步驟為:

  1. 先規劃好測試
  2. 快速開發feature,使feature通過測試
  3. 依照完成的feature進行重構,重構時可以透過測試審視自己的code是否因重構導致錯誤,所以可以快速重構並且不怕搞爛自己的code

但在許多的RD眼裏,測試一直是拖慢開發速度的一環,主要的原因就是這些新feature測試起來,可能得事先調整很多配置,舉個例子來說,髒沙發辨識AV女優的大致流程如下:

現在有個新功能要將髒沙發的訊息模板由這樣:

改成這樣:

那在開發上很直觀會這樣做:

  1. 「將女優資訊套用到訊息模板上」這段程式邏輯做調整
  2. 將照片上傳至髒沙發主-server
  3. 髒沙發主-server傳送照片至辨識-server
  4. 辨識-server再傳回髒沙發主-server
  5. 我看看是不是最後回來的訊息正確
  6. 正確就收工惹(。´∀`)ノ,如果不正確就重複1~5步(。ヘ°)

因為2~5步其實都是「重複性」的測試工作,所以會有人提出「把這幾步寫成自動化測試啊!」的說法,但坦白說,這是件非常麻煩的事情。

首先你要將辨識-server部署好,並且將辨識-server的資料庫與辨識-server連結,最後要確保辨識-server的運作一切正常,才能開始寫「將女優資訊套用到訊息模板上」這段程式。

這導致很多RD會放棄測試,因為不斷重複2~5步的開發雖然攏,但開發上還是比寫測試的時間來的快,甚至覺得所謂「先寫測試在寫程式功能的TDD是個謬論」,但為什麼測試會那麼麻煩呢?主要就是:

測試的所相依的事物太多了

我們回到最一開始,其實我們這個feature的目標是「AV女優面板做調整」,那要測試時我就測「將女優資訊套用到訊息模板上」這段邏輯就好了,辨識-server是可以排除掉的,所以可以把辨識-server的輸入輸出用「假資料」替換掉,即會變成這樣:

而流程就會變成這樣:

可以看到,因為跟其他server都沒有相依,所以寫測試更為容易,這就是我們所謂的單元測試-unit test,大家可以看看測試金字塔,


         photo by lawrey medium

越往金字塔的頂端即:

  • 測試的所整合的範圍更多
  • 測試所帶來的成本越大

越往金字塔的底部即:

  • 測試的所整合的範圍越少
  • 測試所帶來的成本越小

而單元測試即是最底部的,他把許多的外部相依都靠測試替身-test double來排除。

以程式碼來說:

// main.js
const request = require('./request')
const fs = require('fs')

async function searchFace(imagePath) {
    return await request({
        url: '辨識-server',
        formdata: {image: fs.createReadStream(imagePath)}
    })
}

async function applyTemplate(imagePath, faceProvider) {
    const faceResult = await faceProvider(imagePath)
    return {
        name: faceResult.name,
        description: faceResult.detail.description,
        similarity: faceResult.detail.similarity
    }
}

// test.js
const main = require('./main')

test('當套用模板時,會回傳正確值', () => {
    const mock辨識server = jest.fn().mockReturnValue({
        name: '橋本有菜',
        detail: {
            description: '很可愛',
            similarity: '83%'
        }
    });
    expect(main.applyRecognitionTemplate('./test.jpg',mock辨識server).toStrictEqual({
        name: '橋本有菜',
        description: '很可愛',
        similarity: '83%'
    })
})

可以看到其實我們利用faceProvider這個參數,讓去call辨識-server的角色有了彈性,我們可以編造假的mock辨識server,它的輸入輸出是由我們測試所定好,並不會因為辨識-server自己出現了什麼問題,而導致我們測試上的誤判。

所以這個測試我們可以focus在applyTemplate function的這段code:

return {
        name: faceResult.name,
        description: faceResult.detail.description,
        similarity: faceResult.detail.similarity
    }

因為如果出錯了,一定只有這邊有問題,不可能是辨識server的部分。

這邊我們發現到一個很有趣的現象,就是似乎我們的「程式碼要配合unit test來撰寫」,是的沒錯,假設我們將searchFace function寫在applyTemplate function裡面,

// main.js
const request = require('./request')
const fs = require('fs')

async function searchFace(imagePath) {
    return await request({
        url: '辨識-server',
        formdata: {image: fs.createReadStream(imagePath)}
    })
}

async function applyTemplate(imagePath) {
    const faceResult = await searchFace(imagePath)
    return {
        name: faceResult.name,
        description: faceResult.detail.description,
        similarity: faceResult.detail.similarity
    }
}

這時候我們就會面臨無法替換searchFace的現象,因為searchFace被寫死了,這導致與applyTemplate強耦合。

所以程式在寫的時候,其實也要考慮程式的可測試性-testable,這代表:

先上程式碼開發的車再候補測試的票,成本是會越來越大的

因為一開始一直沒考慮可測試性所以後來要測unit test就會遇到頻頻無法隔離的狀況,

當然如果是開發很有經驗的人,就算沒有測試也可以讓各個程式碼的耦合降低,但事先有配合測試,廣義上來說會因測試被動解耦,這是我在寫的時候遇到的有趣現象,

而這邊使用faceProvider是以物件導向SOLID法則依賴反轉原則來進行設計,依照wiki來說即是

  1. 高層次的模組不應該依賴於低層次的模組,兩者都應該依賴於抽象介面。
  2. 抽象介面不應該依賴於具體實現。而具體實現則應該依賴於抽象介面。

聽起來非常的複雜,但其實以這個例子廣義來講就是:

不要把searchFace function寫死在applyTemplate function裡面,不然我要怎麼換他啦ヘ(゜Д、゜)ノ

另外也可以在require的時候引入test double,這也是一種做法

結論

我們可以靠unit test來加快我們寫code驗證迭代的速度,依照「重構|改善既有程式的設計」這本書來說

  1. 在重構之前,請先準備好堅實的測試程式,測試程式必須是自檢的
  2. 重構就是用小步驟修改程式,讓輕鬆的找到bug的位置

可以理解到有配合unit test開發,因為減少了一定的debug成本,所以有許多機會是比沒寫測試的人開發來得更快,因為:

  1. 外部相依少,所以可以每save一次程式碼就跑一次,因為測試程式是自檢的
  2. 因為跑得快,你可以改一行小code就save一下,看看測試有沒有過,如果測試錯了就代表這一行小code有問題,所以可以很輕鬆找到bug的位置

但也可以發現,重構此書很多地方都是以「重構」為前提來探討,這代表此feature已經很確定了。

所以,對於功能不明確的prototype產品,是否要先寫測試這就值得思考了,因為feature變動即測試也要修改,所以TDD也不是萬靈丹,任何東西都不是銀彈,我們必須適時且動態的做應對,才是一個好RD

後記

以前一直看不懂SOLID到底在說什麼,而實際碰到之後才發現是一些很直觀的東西,但我也能理解WIKI為什麼要寫得那麼複雜,因為他必須把所有的情況都用兩三句所訴說,我想後面的文章也可以依照我實務碰到的一些問題來描述SOLID,或許可以讓大家更了解~


尚未有邦友留言

立即登入留言