技術是由一萬個細節組成的,哪怕一個這麼簡單的題目,也有如此多的點。我也不敢說自己是什麼高手,起碼寫了許多年代碼,也就把自己寫代碼的思維展示給大家,希望對有心人有所幫助。

非初學者向,雖然題是個簡單的題,但要求讀者有一定的敏捷工程實踐及DDD相關經驗。

FizzBuzz是一個經典的TDD入門題目,麻雀雖小,五臟……勉強算全吧。Stack Overflow創始人曾經在他的一本書裏寫到,“不要假設程序員都會寫程序,招一個程序員來先寫個FizzBuzz看看,結果可能會令你喫驚。”

我當時不信,於是在一個招聘活動上拿這個的一個完整版做了題目,結果也確實挺讓我喫驚的,喫驚在哪我先賣個關子,後面詳細說。後來教人寫程序也用了這個題幾百遍了,見識過各種各樣奇怪的錯誤。

我們今天就用這個題目爲例,來儘量闡述一些道理。這個題的需求有很多步,就好像軟件開發中很多需求是一個版本一個版本迭代出來的,所以我們這個題目也一個迭代一個迭代來。

迭代一

迭代一的需求如下:

你是一名體育老師,在某次課距離下課還有五分鐘時,你決定搞一個遊戲。此時有200名學生在上課。遊戲的規則是:

  1. 讓所有學生拍成一隊,然後按順序報數。
  2. 學生報數時,如果所報數字是3的倍數,那麼不能說該數字,而要說Fizz;如果所報數字是5的倍數,那麼要說Buzz。

不同於憑本能思考,這裏我們講一個套路:我們做軟件開發的時候可以刻意分三個問題域來考慮問題,我稱之爲業務域、方案域、實現域。這三個域有什麼用呢?

當我們在進行軟件開發的時候,有時會陷入無思路的狀態,一旦陷入這種狀態人容易焦慮,卡很久卻沒什麼進展。這個時候我們往往是處於一種所謂的unknow unknown的狀態。也就是不知道自己不知道什麼。新人最容易陷入到這種狀態,只好盯着屏幕看半天。這個時候就需要先意識到自己處於這個狀態,然後就可以借用這三個域作爲跳板跳出這個狀態。

首先來看看你的問題到底在哪個域,在不同的域要採用不同的方法來探尋問題到底是什麼,在這基礎上就逐漸有了思路。這就是這三個域的用處。

具體怎麼用呢?我們一個個來說。

業務域

首先說業務域,這裏的業務用以代指需求。

以這個題爲例,我們在讀需求的時候會發現一個問題,被3整除返回Fizz,被5整除返回Buzz,被3和5整除返回什麼?

這個問題很明顯,就屬於業務域的問題。

那麼業務域的問題,我們通常怎麼處理呢?

有的同學就直接腦補了:

  • 腦補一:能被3和5整除,那就是先被3整除唄,那就Fizz。
  • 腦補二:能被3和5整除,那就返回FizzBuzz唄。

那麼以上哪個腦補是對的呢?

答案是以上都不對,腦補本身就不對,腦補只是猜測,猜測不經驗證就是僞需求。當我們遇到業務域的問題,不要自己腦補,請去與需求方確認需求。

(題外話:當然,你帶着兩個腦補去找需求方是可以的,甚至於是很好的,因爲這樣需求方就能更容易的聽懂你的問題,比你問被3和5整除返回什麼要更具體。這個題目裏被3和5整除是很清楚的,但在工作中,提一個抽象的問題,然後跟上兩個可能的具體的解決方案也能幫助對方理解。)

確認需求時最重要的就是對概念的理解建立共識,識別概念的邊界。前者還好,後者容易疏忽,同一個名詞即便在需求當中,由於上下文的不同也有可能指的是兩個概念,這部分內容本篇不作詳細討論。

經過業務域的確認,我們得到了一個完善後的需求。

你是一名體育老師,在某次課距離下課還有五分鐘時,你決定搞一個遊戲。此時有200名學生在上課。遊戲的規則是:

  1. 讓所有學生拍成一隊,然後按順序報數。
  2. 學生報數時,如果所報數字是3的倍數,那麼不能說該數字,而要說Fizz;如果所報數字是5的倍數,那麼要說Buzz。
  3. 學生報數時,如果所報數字同時是兩個特殊數的倍數,也要特殊處理,比如3和5的倍數,那麼不能說該數字,而是要說FizzBuzz。

方案域

方案域就是想想代碼寫完之後什麼樣,我們這裏講一個簡單的,我稱之爲 上下文圖

上下文圖表達的是代碼的靜態關係。比如,如果我的代碼要這麼寫:

那圖就是這麼畫的:

如果代碼要這麼寫:

那麼圖就是這麼畫的:

整個這個畫圖的過程,是對程序的一個拆解,這個拆解的過程實際上也是伴隨着設計的。這個圖的主要目的就是畫出一個一個只有數據依賴、沒有過程依賴的小型的上下文。

什麼叫過程依賴?如下圖所示:

上面的代碼每一段if的邏輯執行完都調用了一個continue(類似的還有break和return),這使得每一個if的block都是與外面的for block耦合的,我無法單獨把其抽取成一個函數。也就沒法單獨測試,它的可測試性一點都不高。

如果我不把程序拆成只有數據依賴沒有過程依賴的小型上下文,那麼無論是從調試還是測試的角度都會變得很複雜,因而實現也會變得複雜。

這段程序另一個可測試性差的地方在於直接輸出到了標準輸出流裏,如果標準庫沒有給我們提供打樁的方法,那麼這個程序就只能人肉測。即便提供了,測試成本也提升了,遠不如直接測一個字符串來的容易。所以我們在考慮輸入輸出的時候,也要考慮輸入是否容易準備、輸出是否容易獲得。

比起畫上下文讀這個圖,得到可測試性高的程序設計,這個狀態纔是我們想要的。當你有足夠多的經驗後,其實你並不需要畫這麼簡單的一個圖,但是你腦子裏還是會浮現出這樣的結構,按這樣的方式去思考程序,我們的目的就達到了。

當我們得到一個可測試的程序設計後,最後再理清一下,看看每個小型限界上下文的輸入和輸出,考慮輸出的每個數據項是否都能從輸入中得到所有的計算因子。如果這一步做不好,那麼下層實現域實現的時候就會沒思路。

實現域

任務列表

從方案域落到實現域的第一步是得到實現域所需的任務列表。

從圖落到實現還是有些變化,我們的圖實際上有點像Inception當中的用戶故事地圖。用戶故事地圖是站在用戶使用軟件的角度列出軟件有什麼功能。但是軟件畢竟還是需要一步一步做出來。所以上面的故事卡,還要重新搬到看板上去,變成看板上的任務卡,按照實現的角度排列順序調整優先級,並且補充相應的技術卡等卡片。

同理,上下文圖也需要經過這樣一次映射轉化爲任務列表。任務列表並不跟上下文圖裏的圖一一對應。就是說我有一個技術不會,我可能要查一查,這也是一個任務。查完之後要做一個試驗驗證,確定我想要的方式能實現,這也是一個任務。試驗完了之後,在真實的產品代碼中使用這個技術把需求實現出來也是一個任務。

通常我們把任務就分爲這幾類:溝通協調(技術類的,非需求類的)、技術調研、原型測試、編碼實現。隨着團隊的配合度越來越高,技術越來越熟悉,前三個就會越來越少,任務就會越發趨向於更多的編碼實現。

TDD

在編碼實現方面,我們前面在做方案域設計的時候,已經把程序設計的可測試性很高,所以很自然我們在落地實現的時候,就可以通過打印的方式肉眼調試,隨着我們代碼越來越多,每寫完一段新的代碼塊,應該就考慮把所有的都打印出來看看有沒有變化,這就叫回歸。而用肉眼看的方式做人肉迴歸實在是效率太低,頻率也不會高。我們需要把肉眼看轉換爲自動化的方式,這就是自動化測試。既然我們可以通過自動化測試的方式來進行迴歸,校驗的輸入輸出在開始之前也已經分析清楚了,那不妨在開始寫代碼之前就先把測試寫出來,於是就得到了TDD。

很多人抱怨TDD學不會,其實據我觀察,大部分學生之所以不能使用TDD的方式寫代碼,核心原因還是不會把程序從輸入輸出角度進行拆解。一旦拆解開了,後面的就簡單了。

我也發現在編程的時候,很多問題不是智力問題,而是心理問題。

我看見很多同學很喜歡一口氣寫一大堆代碼,然後慢慢調試。如果他們真的有過人的才能,可以一次性寫對,我覺得也沒什麼。然而事實是並不能,反而浪費很多時間。

究其原因還是不會改程序,所以想着一次性寫好,爲什麼這麼說呢?你會發現他們基本上不考慮輸入輸出的具體格式,腦子裏有一個模模糊糊的感覺,就開始寫實現了,到實現完爲止,程序都執行不起來,執行起來之後,因爲函數已經很長了,中間出了錯誤,準備數據也不好準備,於是要改半天,於是更害怕執行了,於是更想一次性寫好,函數就更長了。

由於不會思考輸入輸出,也就不會拆子函數,因爲大的都沒好好想,小的子函數就更別說了,函數的輸入輸出沒有分析清楚,拆了子函數因爲作用域的問題沒想清楚,所以想一個函數寫完。或者亂拆了子函數,然後就開始各種加全局變量。總之就是因爲不敢改,所以把犯錯的範圍越積越大,故障點越壘越多。越是這樣就越不敢執行。因爲一執行就更肯定是報錯的,一旦查錯呢,因爲代碼太長又害怕查錯查的把寫代碼的思路忘了,於是又強化了一次性寫完的行爲。

整個這套我們稱之爲基於本能的行爲模式並不是一個理性的結果,反而是一個感性的結果。所以我們教的這些實踐並不是單純的解決智力問題,相當多的部分也是在解決心理問題。

與這套基於本能的行爲模式相反,我們教的這套以TDD思想爲核心的行爲模式,有意識把代碼拆成小塊,自然可以小步試錯,可以小塊驗證,也就可以保證實現的過程中即便出了問題也可以快速的定位。哪怕不寫測試,你打印也比別人調試快,單步調試也知道每一塊幹什麼,另一塊跟這個不相關,就可以快速跳過,到了你關心的部分,分析過輸入輸出,也就能更快速的知道哪裏錯了。 所以不能從輸入輸出角度進行思考是人們沒有辦法寫出高質量程序的一個原因。

而每一塊的編碼實現我們還是會再分任務,以本問題單個數的轉換爲例,接口是非常清楚的——輸入是個整數,輸出是個字符串。

但是你實現的過程要分幾步。

我要先實現可以被3整除的,再實現可以被5整除的,最後實現可以被3和5整除的,這算是一個驅動的意思。從簡單的入手,然後再往復雜的去寫。很多人可能會覺得比較無聊。但如果你測試的人足夠多,你會發現很多人哪怕是在這樣一個無聊的題上,也會把自己坑進去。舉個例子我們第3步:可以被3和5整除。當我們實現的時候,我們if裏那個表達式模3模5在上還是在下。每次我都會故意寫在下面問有沒有問題,如下圖所示:

每次都會有人意識不到。這麼簡單的題目都會被繞暈,到底要多有自信,纔會覺得複雜的需求不會出錯呢?所以還是老老實實的給自己加測試防護網吧。測試一個很重要的原則,是防止低級錯誤,而不是惡意欺騙。

先確認需求,再實現,需求以測試的形式寫出來,然後再去實現,這就是TDD了。如果實現的時候只需要關注其中一種可能性,這樣思維負擔比較輕。如果你腦力強勁,覺得步子大一點沒事,你就步子大一點,我是沒有此等自信。有些人問我,TDD的時候測試有沒有階段性,測試是否有要分批寫?我大概會分三批:

  • 第一批測試只有一個測試,意義是:定義輸入輸出,確定函數在哪。
  • 第二批測試的意義是:建立主幹框架,把程序的主幹走通。
  • 然後再寫第三批測試,把各種分支和異常都考慮到。

這樣寫出來的程序就是一個比較健壯的程序。反過來看,當你時間不夠的時候。你要減的測試是哪個?肯定是第三批測試,不是整組幹掉,而是在這組當中減少量。有很多人會說自己沒有時間寫測試,或者說測試很浪費時間。但是如果你打開代碼的時候,發現前兩組測試都不存在,就很說不過去了,因爲前兩組幾乎不花什麼時間。而且如果做得好還會提高效率,減少時間花費。一個最簡單的道理,當我有一天出了bug,我能以多快的速度建立一個可運行的程序現場,以多短的週期反覆重現這個bug,並且對新解決方案進行嘗試,決定了修bug的速度。前兩組測試完全可以爲這個場景服務,而這個場景不完全發生在測試測出來bug,在我們日常寫代碼的時候,我們不能保證我們寫的代碼是一次就能寫對的,那麼在沒有寫對之前就等於代碼中存在了bug,也是要反覆調試的,那這個對實驗的週期時間的要求是一樣的。有那個調試的功夫,直接看測試不是一樣嗎?

過度設計

到此爲止,我們寫出來帶的代碼如下所示:

實現並不複雜,仔細看看這代碼還可以,夠用,不難懂,那就行了,我們就先不請重構登場了。天下設計都講究一個不要過度設計,軟件設計也不例外,做到這裏是很好懂的,那我們也不要畫蛇添足。

很多人一看到可能的擴展點,就想了一大堆可能的需求,再有個9呢?或者是所有的素數,比如11啊,13啊……

這方面我們要有耐心一點,比起可能降臨的擴展給我們帶來的困擾,我們自己亂添加的擴展機制更可能會坑死自己。

有個段子是這麼講的,有個人請來了一個新手,一個老手,一個高手,給他們佈置了一個任務,穿過一片農田到對面的房子去,這片農田就隱喻我們的代碼,問要多長時間。

新手看了一眼距離說估計15分鐘就能過去。老手看了一眼,說要半天。高手也看了一眼,說15分鐘。新手進到農田,不停的掉到坑裏,踩爆了幾個雷,最後被埋在田裏了。老手小心翼翼,過程中填了幾個坑,排了幾個雷,花了半天的時間,終於到達了房子。發現高手早就已經在那兒等了他很久了。老手不解,問爲什麼你可以這麼快?你怎麼幹掉那些雷的?高手說,因爲從一開始我就沒有埋雷。

這個段子告訴我們,程序員自己給自己埋的雷往往會成爲未來的負擔,好的程序員會盡量少的給自己埋雷。這所謂的雷,可能一開始就是一個精心設計的機制。

不要以爲這只是一個段子,在曾經工作的一個項目上,我接了一個特別簡單的任務,以爲一會兒就能做完,打開代碼之後,我發現之前的代碼竟然是用反射機制設計的一個極其複雜的擴展機制。爲了搞懂這個機制,我竟然花了一個禮拜。最後我覺得這個機制實在不利於擴展,我現在對它知根知底,爲了防止後人再進這個坑,我就把它刪掉了。刪之前我就很好奇,這麼複雜的機制,有沒有起到易於擴展的作用啊,於是我就打開版本控制的歷史記錄,我發現他是兩年前添加的,在過去的兩年之中,從來沒有進行過一次擴展,直到今天被我刪掉。想想也對,這麼複雜的代碼,別人讀都讀不懂,爲什麼會選擇在這兒擴展呢?所以不要盲目追求易於擴展的設計,絕大多數時候剛剛好的設計纔是最好的設計。

迭代2

前面的做完,新的需求來了:

你是一名體育老師,在某次距離下課還有五分鐘時,你決定搞一個遊戲。此時有200名學生在上課。遊戲的規則是:

  1. 讓所有學生拍成一隊,然後按順序報數。
  2. 學生報數時,如果所報數字是3的倍數,那麼不能說該數字,而要說Fizz;如果所報數字是5的倍數,那麼要說Buzz;如果所報數字是第7的倍數,那麼要說Whizz。
  3. 學生報數時,如果所報數字同時是兩個特殊數的倍數情況下,也要特殊處理,比如3和5的倍數,那麼不能說該數字,而是要說FizzBuzz, 以此類推。如果同時是三個特殊數的倍數,那麼要說FizzBuzzWhizz。

迭代2的需求改動不多,這個需求對我們的業務域造成的變化是加入了一個新的特殊數字7。如果我們還是按照迭代1的方式去實現,我們寫出來的代碼可能很可能如下所示:

代碼有什麼問題嗎?它最大的問題叫做圈複雜度太高。

圈複雜度

圈複雜度的計算方式是這樣的,每當你看到一個for或者if或者while,總之,每看到一個判斷分支或者是一個循環分支,圈複雜度就加1。每當看到continue和break也加1,return也加1。加完了就是這一部分代碼的圈複雜度了。

圈複雜度高了會怎麼樣呢?圈複雜度高低與bug率高低有強相關性。在各種測試指標當中,很少有像圈複雜度這樣與千行代碼bug率強相關的指標,相關度高達百分之九十幾。也就是每千行代碼,圈複雜度越高BUG率就越高。雖然不是因果性,但是對於一個工程學科來說,相關性也有足夠多的指導意義了,所以當我們看到圈複雜度比較高的代碼的時候,就要考慮重構掉。

重構與十六字箴言

這回我們就真的需要重構了,具體重構要怎麼做呢?難道是把這塊刪了重寫嗎?那就有點糙了。但精細的講,重構是有60多種手法的,也沒誰都能記住啊。不過好在總的來說手法的模式是很相近的,我們有個同事總結了四句話,我們戲稱爲“16字箴言”,內容如下:

  • 舊的不變
  • 新的創建
  • 一步切換
  • 舊的再見

什麼意思呢?首先不要着急改掉舊的代碼,先讓舊的保持不變,不過因爲intelliJ這種利器的存在,使得抽取函數本身不再是一件危險的事(起碼在Java裏是這樣),所以我們通常會先把要重構的舊的代碼抽個函數,讓重構的目標顯性化。做這一步的時候,你會發現可能已經要改變代碼結構了,起碼要改造成我前面所說消除過程依賴,讓代碼之間只有數據依賴,這樣纔好提取嘛。提取之後寫個新實現,然後在調用點調用新實現,舊的調用點先註釋掉,測試通過了,在把舊的調用點代碼刪掉,打掃戰場把舊的實現也刪掉。

具體到這個題呢,我的做法會是如下:

先消除過程依賴。

然後抽取函數,把要重構的代碼塊先通過函數封起來,劃定重構的邊界,把輸入輸出浮現出來。

接着寫一個新函數。

然後把函數調用點換掉。

然後把舊的函數刪掉,打掃現場,該改名改名,該去註釋去註釋。

上面每一步結束的時候都要保證測試是通過的。軟件工程當中有一句很重要的理念:一個問題發現的越晚修正它的成本就越高。本着這個思想,我們重構的時候也要是這個樣子,每做一次修改都要看一看有沒有問題,如果有問題就立刻修正。如此小步前進,纔是我們所謂的重構。

一旦你重構掉之後,有一天你想看看原始實現是什麼樣子。或者乾脆你就想切換回原始實現。這個時候這一步切換可以最大限度的保留當時的代碼全景,讓你很容易的看清當時的實現,也讓你的回滾變得容易,畢竟誰也不敢保證自己能一次性做對,改着改着發現低估了這個複雜性,還不如以前的設計方便,也是常有的事兒。

迭代3

你是一名體育老師,在某次距離下課還有五分鐘時,你決定搞一個遊戲。此時有200名學生在上課。遊戲的規則是:

  1. 讓所有學生拍成一隊,然後按順序報數。
  2. 學生報數時,如果所報數字是3的倍數,那麼不能說該數字,而要說Fizz;如果所報數字是5的倍數,那麼要說Buzz;如果所報數字是第7的倍數,那麼要說Whizz。
  3. 學生報數時,如果所報數字同時是兩個特殊數的倍數情況下,也要特殊處理,比如3和5的倍數,那麼不能說該數字,而是要說FizzBuzz, 以此類推。如果同時是三個特殊數的倍數,那麼要說FizzBuzzWhizz。
  4. 學生報數時,如果所報數字包含了3,那麼也不能說該數字,而是要說相應的單詞,比如要報13的同學應該說Fizz。
  5. 如果數字中包含了3,那麼忽略規則2和規則3,比如要報35的同學只報Fizz,不報BuzzWhizz。

到這個版本,就是我們拿去做面試題的版本了,揭曉前面喫驚的地方,明明沒有說包含5和包含7,結果有近1/3的人自己就寫了包含5直接返回Buzz,包含7直接返回Whizz。還有一部分人走向腦補的反面,我們權且叫它腦刪,比如這個題就是明明寫了包含3,他沒做,這種情況也都在那1/3裏。

同學們,我需要再強調一遍,腦補是不對的。我去很多學校進行過人才的篩選,我發現一個基本的標準就可以篩掉大多數的人,那就是這個人會不會腦補或者腦刪。這個比例有多高,恐怕高出你的想象。有一個研究貧困家庭到底會在學習上遇到什麼問題的書裏講,貧困家庭的孩子,最大的能力缺失是閱讀能力不行,由於在小時候缺乏了閱讀能力的鍛鍊,絕大多數窮人家的孩子閱讀能力都不好,他們不習慣看長文,閱讀也經常會理解錯誤,於是導致了他們的收入的天花板。

從這幾個獨立的案例裏可以看到一個道理,那就是機器的輸入輸出很重要,人的輸入輸出同樣重要,有的時候很多人並不是比別人笨,只是因爲輸入出了問題,智商的部分根本沒有機會參與競爭。對於這些沒過的同學,他們的問題就出在了題沒有讀清楚。也就是我們前面講的業務域沒搞清楚,所以最後結果不好。

我們當然也可以說,是你需求沒有講清楚,裏面的重點沒有標記清楚,沒有用一種更容易理解的方式來呈現。但現實中真的存在完全清楚的需求描述嗎?由於人類的語言存在強烈的上下文相關性,同一個詞語換了上下文都會有不同的含義,換句話說,兩個上下文不同的人聽到同一個詞的理解也是不一樣的。我們的工作就處在這樣的一種場景下,澄清需求本就是程序員的工作之一,因此溝通表達能力也是程序員需要去關注的能力之一。不然就會發生業務域的問題沒搞清楚然後試圖通過方案域和實現域辦法來解決不屬於這兩個問題域的問題,這是很多難題得不到解決的關鍵所在,也是很多亂相的根因,具體比例咱不好說,讀者可以自行對號入座。

測試景深

到了這個版本的需求裏,我們需要處理包含3,這裏面有個測試就不太好了,那是什麼呢?

輸入3得到Fizz這個,如下圖所示:

這個測試我們測試了什麼呢?是測試的被357整除還是測試的包含3?很明顯,我們測試的是包含3。

一個場景告訴我們,即便是一個測試用例,從入口進去,從出口出來,也不表示他測的是整個邏輯,是更多的關注了其中的一部分邏輯。

這就好像照相的時候一樣。即便整個景色都被我照了下來,但是我的一張照片總有一個聚焦的焦點,這個地方會特別的清楚。測試也是一樣。如下圖所示:

所以我們看待測試用例的時候不能一視同仁。這種雖然是端到端的測試數據,但實際上只關注部分邏輯的思路,在系統重構的時候有更多的使用場景。

一般等價類

從這個場景下我們也可以發現,如果僅寫一個輸入的值在測試用例的名字上,我們是不知道這個測試用例在測什麼的。

測試代碼也是代碼,也要追求可讀性。

所以比起之前寫3或者現在寫6。用一個更具有表義性的詞來稱呼會更好,比如像下面這樣:

這種更具有表義性的詞,我們稱之爲一般等價類。我們寫測試的時候會發現,測試數據經常是無窮無盡的,難道我無窮無盡的測下去嗎?肯定是不行的。但是我還是希望能夠測的儘量全一點。我測了哪些東西之後,就可以認爲我測的比較全了呢,如何來得到一個性價比較高的測試用例集合呢。這時候我們要做一般等價類的分析,在我們這個題裏面大概有下面幾個等價類:被3整除,被5整除,被7整除,包含3,包含5,包含7。只要是一類的數據,我們只需要一個數據就算是覆蓋了這一類的情況。這一類就叫一般等價類,所以我們改完後的代碼應該是下面這樣的:

執行了之後就能看到這個:

我們經常講敏捷是工作的軟件勝過面面俱到的文檔。這並不是說我們不寫文檔,而是說我們的文檔也是一種可以工作的軟件。就像這個測試一樣。我們稱之爲測試即文檔,也叫活的文檔。代碼同樣,也叫代碼即文檔。所以我們前面講測試也要追求可讀性。實際上測試的可能性比實現代碼的可能性要求還要高一些。不過通常來講也是有一些套路可循的。

首先我們看名字,should開頭,表示輸出,given表示輸入,有時候也寫個when表示被測函數。

對應的,我們的名字的結構搬到我們的代碼上,三段式表達,given部分還是輸入,when部分就是被測函數,然後then部分寫各種assertion來校驗。

然後就是粒度問題,通常一個測試只測一個case,這樣一旦報錯了,我們就可以立刻知道是哪裏的問題,從而減少尋錯時間。

迭代4

你是一名體育老師,在某次課距離下課還有五分鐘時,你決定搞一個遊戲。此時有200名學生在上課。遊戲的規則是:

  1. 讓所有學生拍成一隊,然後按順序報數。
  2. 學生報數時,如果所報數字是3的倍數,那麼不能說該數字,而要說Fizz;如果所報數字是5的倍數,那麼要說Buzz;如果所報數字是第7的倍數,那麼要說Whizz。
  3. 學生報數時,如果所報數字同時是兩個特殊數的倍數情況下,也要特殊處理,比如3和5的倍數,那麼不能說該數字,而是要說FizzBuzz, 以此類推。如果同時是三個特殊數的倍數,那麼要說FizzBuzzWhizz。
  4. 學生報數時,如果所報數字包含了3,那麼也不能說該數字,而是要說相應的單詞,比如要報13的同學應該說Fizz。
  5. 如果數字中包含了3,那麼忽略規則2和規則3,比如要報35的同學只報Fizz,不報BuzzWhizz。
  6. 如果數字中包含了5,那麼忽略規則4和規則5,並且忽略被3整除的判定,比如要報35的同學不報Fizz,報BuzzWhizz。
  7. 如果數字中包含了7,那麼忽略規則6中忽略被3整除的判定,並且忽略被5整除的判定,比如要報75的同學只報Fizz,其他case自己補齊。

簡單設計以及測試的量

有很多人看到業務複雜到這個程度,總算該設計個機制了吧。我見過很多人在這個環節把代碼寫得特別複雜。然而我寫的代碼非常簡單,如下所示:

這種代碼有什麼好處呢?就是它的邏輯跟我們需求描述的邏輯幾乎一模一樣,我沒有新增什麼額外的機制。這看起來不是很高大上的樣子,很多人就想加個設計。我們要明白,所謂的設計就是加入一些約束,使得做一系列事的方便程度高於了做另外一系列事。我們經常性的想約束想的都是代碼,但實際上對代碼的約束,本質上還是對人的約束。

Rails的架構師DHH曾經說過約束是你的朋友,什麼意思呢?就是說很多時候你對自己加了約束,那麼你做事的效率可能比胡亂做、憑本能做更高。從這個角度出發,我們加約束一定要提高我們的整體效率,所以我給自己加了一個約束,叫做讓我們的代碼邏輯和業務邏輯最大可能性的保持一致。雖然不是一種代碼的設計,但是是一種行爲的設計。我們刻意要求自己按照這樣的行爲行事,會帶來一個非常大的好處,那就是我們的業務邏輯的修改的難度和我們代碼的修改難度保持一致。業務上如果改起來邏輯很複雜的話,你跟他多要一點時間來改我們的代碼也比較容易要到,反之則不容易要到。

如果我們刻意做了一些設計。使得局面變成了,有時候業務上改起來很簡單的事情,我們這邊特別麻煩,而有時候業務上改起來特別麻煩的事情,我們改起來特別簡單。因爲我會覺得我們設計在一定程度上提高了他們的效率嗎?不會。他們會覺得你今天心情好,所以你說簡單,你哪天心情不好了就跟我說做不了。所以你沒有辦法形成業務與技術的協同效應。全局上來看,它是降低了效率的。

所以要記得我們前面說過的這個原則:讓我們的代碼邏輯和業務邏輯最大可能性的保持一致。細節上也要注意,要儘量採用業務名詞,業務概念。不要寫那種寫時候只有我和上帝懂,三個月之後只有上帝懂的代碼。我們想象一個場景啊,當項目上來了一個新人,他首先會學習業務知識,完了之後開始維護代碼,我們儘量使用業務名詞業務概念,對於這個新人來說,他的上手成本是最低的,如果業務邏輯又保持一致,那上手成本就更低了。而新人這個概念不一定是新招了一個人,沒有維護過你這塊代碼的,來維護過你這個代碼,三個月後的你,可能都是這個新人。所以善待新人就是善待自己。

我們這些代碼也是測試驅動出來的,下面是我新加的測試:

這些測試通過之後,由於實現的改變,會導致我們前面的測試用例會有很多的改變。無形中製造了測試的麻煩。這個時候我們可以採用測試替身技術。把3,5,7的部分再摘出來測試,這樣你就不需要關心3,5,7部分的輸入了。

不管我們是否使用了測試替身技術。我們可能還是不太放心。我想寫一個測試用例很全的測試,也就是所謂的細粒度的測試,於是我就寫了一個。

上面就是我用代碼生成的數據,這個時候你會發現測試用例一點都不好準備。測試成本很高。這個其實是正常的。測試代碼也是需要花心思去寫,花心思去維護的。

但是這裏面會延伸出一個問題。我寫測試來保證實現的正確性,我拿什麼來保證測試的正確性呢?

這事情要分兩頭看。你寫的一種測試代碼是一種類庫的形式,用於輔助測試的編寫。這種代碼本身跟一般的程序沒什麼區別,所以你可以針對它單獨測試。在這個題裏面,我根據不同的條件生成輸入數據,就是這樣一個類庫一樣的程序。另一種測試代碼就是我們平常的測試用例了,這種測試用例,它和實現是互相驗證的。所以我們在寫這個的時候有一個對自己的約束,那就是寫測試的時候絕對不寫實現,寫實現的時候也絕對不寫測試,兩者之間有一個非常明確的邊界:執行測試。也就是說我如果寫測試,執行完了測試之後,確保是我預期的結果,我再去改實現。反之我寫了實現,如果執行完測試是我預期的結果,我再考慮去改測試。這樣寫測試的時候實現就是我的驗證。寫實現的時候測試就是我的驗證。主要在修改的時候作用更大一些。

最後還有一個問題,我寫了這老多的細粒度的測試,是不是我原來的那個測試就可以刪掉了?我建議不要,一方面,可以當文檔存在,另一方面你原來的測試相當於一些主幹,而細粒度的測試相當於各種細節分支,當我們未來再引入新的功能的時候,你可以先用主幹測試來驅動新功能,然後用細節的測試來進行微調。

寫到這裏,我用fizzbuzz能講的道理也算講完了,還有很多道理沒講到,下次換個題目試試。

絮絮叨叨寫了這麼多,就是講一個事,技術是由一萬個細節組成的,哪怕一個這麼簡單的題目,也有如此多的點。之前寫過一篇文章叫《什麼值得背》,就是說了一個道理:高手的解題思路值得背。我也不敢說自己是什麼高手,起碼寫了許多年代碼,也就把自己的寫代碼的思維展示給大家,希望對有心人有所幫助。

更多精彩洞見,請關注微信公衆號:ThoughtWorks洞見

相關文章