嗨大家好,在写「鼠年全马铁人挑战-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就是
先写测试,再开发功能,步骤为:
先规划好测试快速开发feature,使feature通过测试依照完成的feature进行重构,重构时可以透过测试审视自己的code是否因重构导致错误,所以可以快速重构并且不怕搞烂自己的code
但在许多的RD眼裏,测试一直是拖慢开发速度的一环,主要的原因就是这些新feature测试起来,可能得事先调整很多配置,举个例子来说,髒沙发辨识AV女优的大致流程如下:
现在有个新功能要将髒沙发的讯息模板由这样:
改成这样:
那在开发上很直观会这样做:
「将女优资讯套用到讯息模板上」这段程式逻辑做调整将照片上传至髒沙发主-server髒沙发主-server传送照片至辨识-server辨识-server再传回髒沙发主-server我看看是不是最后回来的讯息正确正确就收工惹(。´∀`)ノ,如果不正确就重複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.jsconst 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.jsconst 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.jsconst 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来说即是
高层次的模组不应该依赖于低层次的模组,两者都应该依赖于抽象介面。抽象介面不应该依赖于具体实现。而具体实现则应该依赖于抽象介面。
听起来非常的複杂,但其实以这个例子广义来讲就是:
不要把searchFace function写死在applyTemplate function里面,不然我要怎么换他啦ヘ(゜Д、゜)ノ
另外也可以在require的时候引入test double,这也是一种做法
结论
我们可以靠unit test来加快我们写code验证迭代的速度,依照「重构|改善既有程式的设计」这本书来说
在重构之前,请先準备好坚实的测试程式,测试程式必须是自检的重构就是用小步骤修改程式,让轻鬆的找到bug的位置
可以理解到有配合unit test开发,因为减少了一定的debug成本,所以有许多机会是比没写测试的人开发来得更快,因为:
外部相依少,所以可以每save一次程式码就跑一次,因为测试程式是自检的因为跑得快,你可以改一行小code就save一下,看看测试有没有过,如果测试错了就代表这一行小code有问题,所以可以很轻鬆找到bug的位置但也可以发现,重构此书很多地方都是以「重构」为前提来探讨,这代表此feature已经很确定了。
所以,对于功能不明确的prototype产品,是否要先写测试这就值得思考了,因为feature变动即测试也要修改,所以TDD也不是万灵丹,任何东西都不是银弹,我们必须适时且动态的做应对,才是一个好RD
后记
以前一直看不懂SOLID到底在说什么,而实际碰到之后才发现是一些很直观的东西,但我也能理解WIKI为什么要写得那么複杂,因为他必须把所有的情况都用两三句所诉说,我想后面的文章也可以依照我实务碰到的一些问题来描述SOLID,或许可以让大家更了解~