在設計一個新的軟體專案時,經常會面臨如何結構化的眾多選擇。核心抽象應該是什麼?它們應該如何相互作用?
在這篇文章中,我想主張一個我發現對於回答或影響許多這些問題的有用設計啟發:
優化你的代碼以便於測試
具體來說,這意味著當你編寫新代碼時,在設計它及其與系統其他部分的關係時,問自己這個問題:“我將如何測試這段代碼?我將如何編寫自動化測試來驗證這段代碼的正確性,並對環境或系統其他部分的假設保持最小的無關假設?” 如果你對這個問題沒有好的答案,請重新設計你的抽象或介面,直到你有為止。
我發現這個啟發在兩個不同的方面是有價值的,我會在這裡討論這兩點。
你會得到好的測試#
這一點或許是顯而易見的:如果你以擁有好的測試為目標,你很可能會得到好的測試。
然而,我認為值得多說一些。
首先,我想強調在擁有良好測試的代碼庫中工作是多麼美妙。驗證變更的過程很簡單:只需運行測試。無需設置複雜的開發環境,手動或交互式地操作系統;只需 make test
,如果不是保證,至少可以高信心地確認你的代碼做了你認為它做的事情,並且沒有破壞任何重要的東西。
在大型和成熟的軟體系統中,進行變更最困難的部分不是變更本身,而是以不回退其他重要行為的方式進行變更。因此,良好、快速的測試能夠快速提供基本保證,這是一個巨大的生產力提升。
其次,為了真正實現良好測試的好處,你需要能夠快速運行測試 —— 或者至少是涵蓋你變更的測試子集,以保持快速的開發週期。
為了在系統擴展時保持這一特性,你需要有良好的 單元 測試。關於 “單元”、“功能”、“集成” 及其他類型測試之間的邊界有很多宗教式的爭論;在這裡我所指的 “單元” 測試是指以最小的依賴或調用其他代碼的方式,對代碼庫中的某個特定模塊或組件進行測試。
你還需要一些集成測試,這些測試對應用程序進行端到端的測試,以便發現組件之間不可避免的微妙交互。但主要依賴單元測試帶來了顯著的優勢:
- 單元測試快速:通過測試有限的代碼,它們的運行速度比必須調用整個應用程序的測試更快。
- 更重要的是,單元測試可擴展:在主要由單元測試組成的代碼庫中,測試速度應該與應用程序大小線性擴展。隨著更多功能的端到端測試,你的擴展風險更接近於二次方,因為每個組件需要的測試反過來又偶然地測試幾乎每個其他組件。
- 由於單元測試與明確的代碼片段相關聯,因此很容易僅運行與特定變更相對應的測試,為開發提供更快的反饋循環。
然而,良好的單元測試不是偶然產生的。沒有良好的 “單元”,很難擁有良好的單元測試:代碼的部分具有狹窄且明確的介面,可以在這些介面上進行測試。
擁有這樣的邏輯單元的最佳方法,從而允許優秀的測試,進而允許快速的反饋循環,同時保持一個成熟的專案,就是直接這樣做:以此為目的編寫代碼。
你會得到更好的代碼#
以測試為考量編寫代碼的第二個原因是,它實際上會產生更好的代碼!
讓我們考慮一下易於測試的代碼應該具備的一些特徵:
偏好純函數而非不可變數據#
純函數相對於不可變數據結構是 令人愉快 的易於測試:你可以僅僅創建一個(示例輸入,預期輸出)對的表格。它們通常也容易使用像 QuickCheck 這樣的工具進行模糊測試,因為輸入容易描述。
小模塊與明確的介面#
如果一段代碼具有小而明確的介面,我們可以根據該介面編寫黑盒測試,測試所描述的介面契約,而不過度關心模塊的內部實現或我們系統的其他部分。
IO 與計算的分離#
IO 通常比純代碼更難測試。因此,可測試的系統將其與代碼的純邏輯隔離,以便可以單獨測試。
明確聲明依賴#
隱式從全局環境中提取數據庫名稱的代碼比接受句柄作為參數的代碼更難測試 —— 你可以在測試過程中使用不同的句柄調用後者,以便每次都在乾淨的數據庫上進行測試,或在多個線程中同時測試,或其他你需要的任何操作。
一般來說,可測試的代碼在某個時刻會將依賴作為明確的參數接受,而不是假設它們的隱式可用性。
如果我查看這些特徵,我發現它們也是 良好、結構良好的代碼 的特徵!通過以測試為考量來架構系統,我們通常會得到比否則更好結構的系統。
此外,通過間接地接近這些特性 —— 通過測試的視角 —— 我們使它們變得更具體。無限爭論一個軟體系統的正確模塊、介面和結構是多麼容易;但面對具體的框架 “什麼使得測試這段代碼變得容易?” 時,我們更容易評估我們的選擇並決定何時已經足夠。
結論#
對於一個複雜的軟體系統來說,能夠自信地進行變更是極其重要的,而高質量的自動化測試是我們為此目的擁有的最重要工具之一。良好的測試不是偶然發生的,甚至不是通過蛮力努力得來的:它們是通過設計而來的,因為應用程序是為了使其能夠進行測試而編寫的。
在編寫軟體時,始終問自己 “我將如何測試這個軟體的正確性?” 並願意以此為目標進行設計。作為回報,你將獲得一個你可以 —— 並且能夠 —— 更有信心的系統,並且在其他方面結構更佳。