前往失敗,心臟出血和單元測試文化

2014 年初發現了兩個計算機安全漏洞:蘋果的“goto fail”漏洞和 OpenSSL 的“心臟滲漏”漏洞。這兩個漏洞都有可能導致廣泛和嚴重的安全故障,我們可能永遠不會完全了解其程度。鑑於它們的嚴重性,軟件開發行業有必要反思如何檢測到這些漏洞,以便我們提高將來防止這些類型缺陷的能力。本文考慮了單元測試可能發揮的作用,展示了單元測試以及更重要的是單元測試文化如何能夠識別這些特定的漏洞。文章進一步探討了這種文化的成本和收益,並描述了如何在 Google 建立這種文化。

2014 年 6 月 3 日


Photo of Mike Bland

音樂學生、半退休程序員,曾在 Google 工作。

展開

目錄


在 2014 年初,互聯網的安全受到兩個嚴重漏洞的影響:蘋果的“goto fail”漏洞(CVE-2014-1266)和 OpenSSL 的“心臟滲漏”漏洞(CVE-2014-0160)。這兩個漏洞都存在於安全套接層技術中,該技術是互聯網上大多數安全通信所依賴的基礎。這些漏洞既具有教育意義,又具有破壞性:它們根植於程序員的樂觀主義、自信和匆忙,這種情況在各種大小和領域的項目中都會出現。

這些漏洞引起了我的熱情,因為我見證並體會了單元測試的好處,這種深刻的經歷促使我反思單元測試方法如何能夠防止像這樣對 SSL 的影響如此之大和如此高調的缺陷。單元測試是一種尋找方便應用自動化單元測試的代碼塊的過程,這些測試是設計用來驗證低級實現細節並早期檢測編碼錯誤的小型程序。這些缺陷的性質激發了我寫自己的概念驗證單元測試,以重現錯誤並驗證其修復。我撰寫這些測試來驗證我的直覺,並向其他人展示單元測試如何可以早期且不需要英勇努力地檢測到這些

編寫單元測試除了能檢測到低級編碼錯誤之外,還能帶來其他好處。在本文中,我探討了單元測試是否有助於防止「goto fail」和 Heartbleed 錯誤。在這樣做的過程中,我希望能為採用單元測試作為日常開發的一部分建立一個令人信服的案例,以便「自測代碼」的經驗普及化。我提供我的見解,希望它們能有助於未來避免類似的失敗,以事後分析或專案回顧的精神。我的經驗並不意味著我應該因為我的權威而受到尊重,但我希望能提出一個足夠令人信服的案例,讓更多人和組織考慮到單元測試文化的好處。

許多流行的技術媒體故事都解釋了這些缺陷是如何產生的,為什麼它們在被廣泛部署之前經過現有保護措施,以及應該採取什麼措施防止這些錯誤再次發生。讓我感到不安的是,大多數這類分析都倚賴著淺顯的藉口,沒有切中要點,並且促使人們對於這些錯誤的接受因現代軟件系統日益複雜而成為理所當然。這就像軟件行業和依賴它的公眾都急於將這些失敗視為不可避免的命運,是我們為了技術給我們帶來的現代便利而付出的代價。這是最簡單的解釋,讓我們能夠理解一個糟糕的情況並作為一個社會繼續前進。

我們不應該將這樣的缺陷視為不可避免。相反,我們必須抓住這個機會,反思我們開發人員如何做得比依賴命運、更多資金或其他外部因素更好,以防止由低級編碼錯誤導致的安全漏洞或其他高影響缺陷。程式錯誤是不可避免的,但是軟體開發人員和公眾都不應該對這樣一個規模巨大的缺陷作出滿足的回應。深刻、真誠的反思是困難的,並且會遇到很多阻力,因為它要求開發人員接受他們的人類局限性,這通常對程序員的自我形象構成挑戰。這使得深入研究這兩個特定的錯誤更加重要,以尋找真正的解決方案,並避免樹立一個危險的先例:如果在“goto fail”和Heartbleed之後短期內一切都沒問題,那為什麼要改變當前的軟體開發實踐呢?

前往失敗

“goto fail”錯誤首次出現在iPhone、iPad和AppleTV上於2012年9月,出現在iOS 7.0和OS X Mavericks中,直到2014年2月才得以修復——在引入後的十七個月。一個短路跳過了SSL/TLS握手算法的最後一步,使得使用者容易受到中間人攻擊的影響,即一個惡意系統在受影響系統和另一個系統之間中繼流量,並使用假憑證呈現出一個安全連接的假象,隨後攔截兩個系統之間的所有通信。

這個錯誤因這個現在臭名昭著的代碼片段而得名。

if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
    goto fail;
    goto fail;

一些人認為所有的goto語句都是不好的,基於艾德斯格·迪科斯特拉(Edsger Dijkstra)著名的文章《反對GO TO語句的案例》,由流行的公理“Goto被認為是有害的”總結。然而,goto fail語句表達了對C程序員來說熟悉的慣用法。在不可恢復的錯誤情況下,這些語句立即將控制權傳遞到函數末尾的恢復塊,其中局部分配的資源將被正確釋放。其他語言對於這種“中止子句”的支援是內建的,正如Dijkstra在他的文章結論中所稱:C++中的解構子;Java中的try/catch/finally;Go中的defer()/panic()/recover();Python中的try/except/finally和with語句。在C中,對於在這個情況下使用goto並沒有本質上的問題或混淆。換句話說,在這裡不應該將goto視為有害的。

C 程式設計師也會立即認出第一個 goto fail 陳述式與其前面的 if 陳述式綁定在一起,但第二個 goto fail 則不是:在 C 語言中,兩個陳述式的相匹配縮排在語法上沒有任何意義,因為需要使用周圍的大括號將多個陳述式與一個 if 條件綁定在一起。如果第一個 goto fail 沒有被執行,第二個肯定會被執行。這意味著握手協議的後續步驟將永遠不會被執行,但只要交換成功通過這一點,即使最終的驗證步驟失敗,它始終會產生成功的返回值。更明白地說:額外的 goto fail 陳述式使算法被短路了。

有人聲稱,一種需要對所有 if 陳述式使用大括號的程式碼風格,或啟用不可到達程式碼編譯器警告,可能有所幫助。然而,代碼存在著更深層次的問題,單元測試可以幫助解決這些問題。

單元測試如何有助於?

在尋找可以應用 “單元” 測試的 “單元” 時,包含有缺陷算法的整個程式碼塊,以及其集群式的條件邏輯,顯然就是一個這樣的單元(來自 Apple 的 Secure Transport library 第 55471 版本的 SSLVerifySignedServerKeyExchange() 函數)

if ((err = ReadyHash(&SSLHashSHA1, &hashCtx)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &clientRandom)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0)
    goto fail;
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
    goto fail;
    goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
    goto fail;

這樣的一個程式碼塊在提取到自己的函數中時更容易進行測試。像這樣提取程式碼塊的做法是寫單元測試的人的習慣性做法,可以幫助逐步將現有程式碼庫的部分放入測試中。仔細查看算法中使用的變數和數據類型,可以清楚地看出這個程式碼塊正在對哈希值進行握手。通過查找 SSLHashSHA1 的類型,我們還可以看到它是一個 HashReference跳轉表”,這是一個包含函數指針的結構,它使 C 程式設計師能夠實現虛擬函數的行為(即 可替換性 和運行時多態性)。我們可以將這個操作提取到一個名稱表明其意圖的函數中(省略額外的 goto fail

static OSStatus
HashHandshake(const HashReference* hashRef, SSLBuffer* clientRandom,
    SSLBuffer* serverRandom, SSLBuffer* exchangeParams,
    SSLBuffer* hashOut) {
  SSLBuffer hashCtx;
  OSStatus err = 0;
  hashCtx.data = 0;
  if ((err = ReadyHash(hashRef, &hashCtx)) != 0)
    goto fail;
  if ((err = hashRef->update(&hashCtx, clientRandom)) != 0)
    goto fail;
  if ((err = hashRef->update(&hashCtx, serverRandom)) != 0)
    goto fail;
  if ((err = hashRef->update(&hashCtx, exchangeParams)) != 0)
    goto fail;
  err = hashRef->final(&hashCtx, hashOut);
fail:
  SSLFreeBuffer(&hashCtx);
  return err;
}

現在,先前有缺陷的演算法所包含的一系列陳述可以被替換為

if ((err = HashHandshake(&SSLHashSHA1, &clientRandom, &serverRandom,
     &signedParams, &hashOut)) != 0) {
  goto fail;
}

這個函數在單獨情況下更容易理解。當面對這樣一個自包含的函數時,程序員可以開始專注於代碼的外部影響,考慮以下問題

  • 測試的代碼實現了什麼樣的契約?
  • 需要什麼前提條件,並且如何執行這些條件?
  • 保證了什麼後置條件?
  • 什麼樣的示例輸入會觸發不同的行為?
  • 哪個測試集會觸發每個行為並驗證每個保證?

對於HashHandshake(),契約可以描述為:五個步驟,全部必須通過。成功或失敗通過返回值傳播給調用者。預期HashReference將對一系列調用做出正確回應;無論它是否使用了HashHandshake()傳遞的任何函數或數據,都是HashHandshake()本身無法看到的實現細節。

對於這樣直觀的演算法,測試用例將相當密切地“反映”實現:一個成功案例,五個失敗案例。對於更高級別或更複雜的操作,這樣緊密的“反映”可能會導致脆弱的測試,通常應該避免。當使用模擬或其他測試替身來測試與合作夥伴隔離的代碼時,特別要注意這一點。

測試代碼不應該做的事情可能更重要。

無論測試的代碼範圍如何,都需要盡可能徹底地測試失敗案例。測試代碼是否執行其應有的功能並將其留在那裡是一件很誘人的事情,但測試代碼不應該做的事情可能更重要。

概念證明單元測試

儘管 C 不是一種面向對象的編程語言,但該演算法的現有代碼展現出清晰的面向對象設計,一旦代碼被提取到自己的函數中,這實際上會使得單元測試變得容易。 tls_digest_test.c 概念驗證單元測試展示了如何使用HashReference存根來有效地覆蓋通過提取的HashHandshake()演算法的每個路徑。 實際的測試用例看起來像這樣

static int TestHandshakeSuccess() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = SUCCESS;
  return ExecuteHandshake(fixture);
}

static int TestHandshakeInitFailure() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = INIT_FAILURE;
  fixture.ref.init = HashHandshakeTestFailInit;
  return ExecuteHandshake(fixture);
}

static int TestHandshakeUpdateClientFailure() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = UPDATE_CLIENT_FAILURE;
  fixture.client = FAIL_ON_EVALUATION(UPDATE_CLIENT_FAILURE);
  return ExecuteHandshake(fixture);
}

static int TestHandshakeUpdateServerFailure() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = UPDATE_SERVER_FAILURE;
  fixture.server = FAIL_ON_EVALUATION(UPDATE_SERVER_FAILURE);
  return ExecuteHandshake(fixture);
}

static int TestHandshakeUpdateParamsFailure() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = UPDATE_PARAMS_FAILURE;
  fixture.params = FAIL_ON_EVALUATION(UPDATE_PARAMS_FAILURE);
  return ExecuteHandshake(fixture);
}

static int TestHandshakeFinalFailure() {
  HashHandshakeTestFixture fixture = SetUp(__func__);
  fixture.expected = FINAL_FAILURE;
  fixture.ref.final = HashHandshakeTestFailFinal;
  return ExecuteHandshake(fixture);
}

HashHandshakeTestFixture保存了所有需要作為代碼輸入和檢查預期結果的變量

typedef struct
{
    HashReference ref;
    SSLBuffer *client;
    SSLBuffer *server;
    SSLBuffer *params;
    SSLBuffer *output;
    const char *test_case_name;
    enum HandshakeResult expected;
} HashHandshakeTestFixture;

SetUp()初始化HashHandshakeTestFixture的所有成員為默認值;每個測試用例僅覆蓋與該特定測試用例有關的成員

static HashHandshakeTestFixture SetUp(const char *test_case_name) {
  HashHandshakeTestFixture fixture;
  memset(&fixture, 0, sizeof(fixture));
  fixture.ref = SSLHashNull;
  fixture.ref.update = HashHandshakeTestUpdate;
  fixture.test_case_name = test_case_name;
  return fixture;
}

ExecuteHandshake()執行HashHandshake()函數並評估結果,如果結果與預期不同,則打印錯誤消息並返回錯誤值

/* Executes the handshake and returns zero if the result matches expected, one
 * otherwise. */
static int ExecuteHandshake(HashHandshakeTestFixture fixture) {
  const enum HandshakeResult actual = HashHandshake(
      &fixture.ref, fixture.client, fixture.server, fixture.params,
      fixture.output);

  if (actual != fixture.expected) {
    printf("%s failed: expected %s, received %s\n", fixture.test_case_name,
           HandshakeResultString(fixture.expected),
           HandshakeResultString(actual));
    return 1;
  }
  return 0;
}

final()調用之前的HashHandshake()算法中添加重複的goto fail語句將導致測試失敗。

此測試是在沒有測試框架的情況下編寫的,以展示可以使用項目中消耗的工具撰寫有效測試。即使沒有參考標準框架,上一段中的解釋應該相對容易理解:使用組織良好的測試用例,使用組織良好的對象和函數以及選擇良好的名稱意味著,如果測試失敗,通常可以僅通過測試用例中的信息而無需深入研究測試程序的完整實現來診斷失敗。測試框架可以幫助更有效地撰寫測試,但不是撰寫組織良好、徹底的單元測試的先決條件。

為這個函數編寫一組測試來測試它是直接的,因為現在我們在思考具體的例子而不是條件。此外,測試充當雙重檢查:使用條件邏輯很容易出錯,意外地在鏈中反轉一個測試;但當您編寫測試時,您正在兩次陳述行為,一次是使用示例,一次是使用邏輯。您必須在兩個不同的表示中犯同樣的錯誤,才能通過一個錯誤。

寫這個演算法的程式設計師第一次確實執行了該程序,以檢查新代碼中的錯誤。大多數程式設計師都會使用一些樣本輸入來運行程序,以驗證它是否正在執行他們認為應該執行的操作。問題在於這些運行通常是短暫的,一旦代碼運作正常就被丟棄;自動化測試將這些運行捕捉為永久的雙重檢查。

這種永久的雙重檢查在這裡很重要:我們不確定那個不良的第二個goto fail是如何進入代碼的;可能的原因是它是一個大型合併操作的結果。將一個分支合併到主幹時,可能會產生很大的差異。即使合併編譯,它仍然可能引入錯誤。檢查此類合併差異可能是耗時的、乏味的,甚至對經驗豐富的開發人員來說也容易出錯。在這種情況下,單元測試提供的自動化雙重檢查提供了一個快速且費心的(但無痛的!)代碼審查,從測試很可能在人類檢查合併代碼之前就捕捉到潛在的合併錯誤。原始作者不太可能將“goto fail”漏洞引入代碼,但一組測試不僅僅幫助您找到自己的錯誤:它有助於揭示將來遠程的程序員所犯的錯誤。

在“goto fail”漏洞的情況下,習慣性地尋找並提取可測試的函數的單元測試習慣有第二個好處。

一再感到似曾相識

在同一個函數中,立即在有缺陷的演算法上面出現了具有不同HashReference實例的相同演算法的副本。總之,在同一個文件中出現了六次相同的演算法(來自Security-55471的sslKeyExchange.c

  • SSLVerifySignedServerKeyExchange() 函數中出現了兩次該錯誤
  • SSLVerifySignedServerKeyExchangeTls12() 函數中出現了一次
  • SSLSignServerKeyExchange() 函數中出現了兩次
  • SSLSignServerKeyExchangeTls12() 函數中出現了一次

Security-55471.14 版本的 sslKeyExchange.c 已經從 SSLVerifySignedServerKeyExchange() 函數中刪除了重複的 goto fail 陳述,但重複的演算法仍然存在。

程式碼重複是一種已知會增加軟體錯誤可能性的程式碼異味(Code Smell)。從上述的函數名稱中也可明顯看出,除了核心的握手演算法外,還存在更多的重複。這種剪貼程式碼重複還支持了一種假設,即該 bug 可能是由於一次大型合併操作而導致的,因為重複的程式碼增加了合併時的“程式碼表面”,並增加了未檢測到的合併錯誤的可能性。

單元測試導致最小化剪貼的壓力,因為剪貼的程式碼也必須進行單元測試。它可以確保此演算法的唯一副本存在,因為進行測試會更容易。單元測試可以輕鬆驗證此演算法是否正確,無論是否合併,並可以防止首次撰寫“goto fail” bug。

此外,Security-55471 版本的 ssl_regressions.h 似乎列出了此庫的一些 SSL 回歸測試,與 Security-55471.14 版本的 ssl_regressions.h 沒有變化。這兩個版本庫之間唯一的實質性差異在於刪除了 goto fail 陳述本身,沒有添加測試或刪除重複

$ curl -O http://opensource.apple.com/tarballs/Security/Security-55471.tar.gz
$ curl -O http://opensource.apple.com/tarballs/Security/Security-55471.14.tar.gz
$ for f in Security-55471{,.14}.tar.gz; do gzip -dc $f | tar xf - ; done
# Since diff on OS X doesn't have a --no-dereference option:
$ find Security-55471* -type l | xargs rm
$ diff -uNr Security-55471{,.14}/libsecurity_ssl
diff -uNr Security-55470/libsecurity_ssl/lib/sslKeyExchange.c
Security-55471.14/libsecurity_ssl/lib/sslKeyExchange.c
--- Security-55471/libsecurity_ssl/lib/sslKeyExchange.c 2013-08-09
20:41:07.000000000 -0400
+++ Security-55471.14/libsecurity_ssl/lib/sslKeyExchange.c      2014-02-06
22:55:54.000000000 -0500
@@ -628,7 +628,6 @@
         goto fail;
     if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
         goto fail;
-        goto fail;
     if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
         goto fail;

diff -uNr Security-55471/libsecurity_ssl/regressions/ssl-43-ciphers.c
Security-55471.14/libsecurity_ssl/regressions/ssl-43-ciphers.c
--- Security-55471/libsecurity_ssl/regressions/ssl-43-ciphers.c 2013-10-11
17:56:44.000000000 -0400
+++ Security-55471.14/libsecurity_ssl/regressions/ssl-43-ciphers.c
2014-03-12 19:30:14.000000000 -0400
@@ -85,7 +85,7 @@
     { OPENSSL_SERVER, 4000, 0, false}, //openssl s_server w/o client side
auth
     { GNUTLS_SERVER, 5000, 1, false}, // gnutls-serv w/o client side auth
     { "www.mikestoolbox.org", 442, 2, false}, // mike's  w/o client side auth
-//    { "tls.secg.org", 40022, 3, false}, // secg ecc server w/o client side
auth - This server generate DH params we dont support.
+//    { "tls.secg.org", 40022, 3, false}, // secg ecc server w/o client side
auth

     { OPENSSL_SERVER, 4010, 0, true}, //openssl s_server w/ client side auth
     { GNUTLS_SERVER, 5010, 1, true}, // gnutls-serv w/ client side auth

文化含義

六個不同的演算法副本的存在清楚表明,此 bug 不是一次性的程式設計人員錯誤:這是一種模式。這表明存在一種容忍重複、未測試程式碼的開發文化。

我從未在蘋果公司工作過,也不認識任何蘋果開發人員。我不確切知道公司整體的開發文化是什麼,以及此代碼是否代表或特殊。即使這段代碼是例外而不是常態,它仍然是不可接受的。作為可能被此編碼錯誤侵犯隱私和安全的人,對於此特定錯誤的情況或文化的其他部分是什麼,“原諒”是什麼不重要。我想看到對此類錯誤更大的責任追究。不是羞辱,不是譴責等,而是責任追究和隨之而來的盡職調查。這是我們防止下一個“goto fail”發生的更深層策略。

我知道开发文化是可以改变的。像这样的错误给了我们一个机会来反思我们自己的开发文化,如果单元测试还不是一个至关重要的部分,那么开始欣赏为什么单元测试是如此重要的开发实践。我将在本文的后面部分详细讨论我改变开发文化的经验,并提供如何在其他开发文化中实现变革的建议,从一个团队到整个公司。

我针对“goto fail”的概念验证单元测试可能很容易被认为是事后诸葛亮的一次性测试。我更愿意它出现作为一种开发团队随时都可以应用到现有代码中的可访问的单元测试方法的例子,以避免类似尴尬(甚至可能是灾难性)的错误。一个重视单元测试并致力于提高其技艺的开发文化将产生测试,很可能在任何用户受到影响之前就会捕捉到类似“goto fail”的编程错误。

接下来,让我们看一下Heartbleed漏洞,以探讨在那种情况下如何应用单元测试。

心臟出血

Heartbleed是一个类似的令人心碎的案例,未经测试的安全关键代码出现在无处不在的OpenSSL库中。它是在2012年1月引入的,作为OpenSSL-1.0.1-beta1中实现TLS心跳的一个大型、未经测试的更改的一部分。该漏洞使攻击者能够发送一个空的握手请求,并声明发送了多达64k的数据;一个有漏洞的系统会读取但不验证所声明的大小,并以请求缓冲区附近的多达64k的内存中的任何内容进行响应。这种交换没有记录;绝对不会留下攻击的任何痕迹。

引入这个bug的更改经过了代码审查;显然审阅者并没有坚持要求该更改包括单元测试。直到2014年4月才发现并修复了这个bug,并作为1.0.1g的一部分发布。

它出现在dtls1_process_heartbeat() (ssl/d1_both.c)和tls1_process_heartbeat() (ssl/t1_lib.c)中。

int
dtls1_process_heartbeat(SSL *s)
  {
  unsigned char *p = &s->s3->rrec.data[0], *pl;
  unsigned short hbtype;
  unsigned int payload;
  unsigned int padding = 16; /* Use minimum padding */

本地指標變數*p被初始化為心跳請求緩衝區的開頭。第一個位元組將識別請求的類型,將存儲在hbtype中。接下來的兩個位元組指定客戶端提供的請求數據的大小,客戶端期望將其複製並作為響應發送回來;這個大小將存儲在payload中。(payload_sizepayload_len會更好,以符合變數的意圖。)其後是客戶端提供的數據或“有效負載”的開始,將被複製並返回給客戶端,這將由pl指向。(這個變數應該被命名為payload。)

數據被讀入相應的變數

/* Read type and payload length first */
hbtype = *p++;
n2s(p, payload);
pl = p;

n2s()是一個宏(來自ssl/ssl_locl.h),它讀取指針p的下兩個位元組,將值存儲在payload中,並將p前進兩個位元組。

如果提供的hbtypeTLS1_HB_REQUEST,則受影響的系統會分配一個響應緩衝區,並將payload位元組複製到其中(s2n()n2s()的配套宏,它將有效負載長度複製到響應緩衝區中)

unsigned char *buffer, *bp;
int r;

/* Allocate memory for the response, size is 1 byte
 * message type, plus 2 bytes payload length, plus
 * payload, plus padding
 */
buffer = OPENSSL_malloc(1 + 2 + payload + padding);
bp = buffer;

/* Enter response type, length and copy payload */
*bp++ = TLS1_HB_RESPONSE;
s2n(payload, bp);
memcpy(bp, pl, payload);

memcpy()存在問題,因為長度值payload未經驗證是否與實際從請求中讀取的長度相符。請求可能包含空字符串,但指示的長度高達64千位元組。因此,最多64千位元組的進程內存將作為響應返回,而不是請求緩衝區的內容。同樣,此事件沒有進行日誌記錄;它實際上並不留下任何痕跡。

修復提供了缺失的緩衝區大小檢查

/* Read type and payload length first */
if (1 + 2 + 16 > s->s3->rrec.length)
  return 0; /* silently discard */
hbtype = *p++;
n2s(p, payload);
if (1 + 2 + payload + 16 > s->s3->rrec.length)
  return 0; /* silently discard per RFC 6520 sec. 4 */
pl = p;

第一個檢查涵蓋了客戶端將空字符串發送為有效負載的情況,確保從套接字讀取的數據的實際大小與此最小請求大小匹配;第二個確保客戶端提供的有效負載大小不超過包含有效負載數據的緩衝區的大小。

dtls1_process_heartbeat()中,添加了一個檢查,以確認有效負載大小不超過響應的最大允許大小

unsigned int write_length = 1 /* heartbeat type */ +
          2 /* heartbeat length */ +
          payload + padding;
int r;

if (write_length > SSL3_RT_MAX_PLAIN_LENGTH)
  return 0;

單元測試如何有助於?

與“goto fail”漏洞不同,無需提取新函數:dtls1_process_heartbeat()tls1_process_heartbeat()都已經是足夠大的單元,不需要大量的複雜設置來進行測試。我們可以直接回答早期在“goto fail”上下文中提出的相同問題

  • 測試的代碼實現了什麼樣的契約?
  • 需要什麼前提條件,並且如何執行這些條件?
  • 保證了什麼後置條件?
  • 什麼樣的示例輸入會觸發不同的行為?
  • 哪個測試集會觸發每個行為並驗證每個保證?

考慮到心跳函數處理包含外部提供數據的請求緩衝區,一個習慣於自我測試的程序員會習慣於探測處理此類輸入時的弱點,特別是關於讀取和分配內存緩衝區的情況。

除了這種天生的單元測試直覺外,這裡是協議定義心跳請求的部分摘錄

payload_length:  The length of the payload.

[...snip...]

If the payload_length of a received HeartbeatMessage is too large,
the received HeartbeatMessage MUST be discarded silently.

在這種情況下,協議規範實際上為我們定義了適當的單元測試。它並沒有明確說明應該驗證 payload_length 是否與實際讀取的內容匹配,但提供了 payload_length 應該受到特別關注的強烈提示。

概念證明單元測試

這個heartbleed_test.c概念驗證單元測試比“goto fail”稍微複雜一點,但仍然遵循類似的結構。這裡是 dtls1_process_heartbeat() 的測試案例

static int TestDtls1NotBleeding() {
  HeartbleedTestFixture fixture = SetUpDtls(__func__);
  /* Three-byte pad at the beginning for type and payload length */
  unsigned char payload_buf[] = "   Not bleeding, sixteen spaces of padding"
          "                ";
  const int payload_buf_len = HonestPayloadSize(payload_buf);

  fixture.payload = &payload_buf[0];
  fixture.sent_payload_len = payload_buf_len;
  fixture.expected_return_value = 0;
  fixture.expected_payload_len = payload_buf_len;
  fixture.expected_return_payload = "Not bleeding, sixteen spaces of padding";
  return ExecuteHeartbeat(fixture);
}

static int TestDtls1NotBleedingEmptyPayload() {
  HeartbleedTestFixture fixture = SetUpDtls(__func__);
  /* Three-byte pad at the beginning for type and payload length, plus a NUL
   * at the end */
  unsigned char payload_buf[4 + kMinPaddingSize];
  memset(payload_buf, ' ', sizeof(payload_buf));
  payload_buf[sizeof(payload_buf) - 1] = '\0';
  const int payload_buf_len = HonestPayloadSize(payload_buf);

  fixture.payload = &payload_buf[0];
  fixture.sent_payload_len = payload_buf_len;
  fixture.expected_return_value = 0;
  fixture.expected_payload_len = payload_buf_len;
  fixture.expected_return_payload = "";
  return ExecuteHeartbeat(fixture);
}

static int TestDtls1Heartbleed() {
  HeartbleedTestFixture fixture = SetUpDtls(__func__);
  /* Three-byte pad at the beginning for type and payload length */
  unsigned char payload_buf[] = "   HEARTBLEED                ";

  fixture.payload = &payload_buf[0];
  fixture.sent_payload_len = kMaxPrintableCharacters;
  fixture.expected_return_value = 0;
  fixture.expected_payload_len = 0;
  fixture.expected_return_payload = "";
  return ExecuteHeartbeat(fixture);
}

static int TestDtls1HeartbleedEmptyPayload() {
  HeartbleedTestFixture fixture = SetUpDtls(__func__);
  /* Excluding the NUL at the end, one byte short of type + payload length +
   * minimum padding */
  unsigned char payload_buf[kMinPaddingSize + 3];
  memset(payload_buf, ' ', sizeof(payload_buf));
  payload_buf[sizeof(payload_buf) - 1] = '\0';

  fixture.payload = &payload_buf[0];
  fixture.sent_payload_len = kMaxPrintableCharacters;
  fixture.expected_return_value = 0;
  fixture.expected_payload_len = 0;
  fixture.expected_return_payload = "";
  return ExecuteHeartbeat(fixture);
}

static int TestDtls1HeartbleedExcessivePlaintextLength() {
  HeartbleedTestFixture fixture = SetUpDtls(__func__);
  /* Excluding the NUL at the end, one byte in excess of maximum allowed
   * heartbeat message length */
  unsigned char payload_buf[SSL3_RT_MAX_PLAIN_LENGTH + 2];
  memset(payload_buf, ' ', sizeof(payload_buf));
  payload_buf[sizeof(payload_buf) - 1] = '\0';

  fixture.payload = &payload_buf[0];
  fixture.sent_payload_len = HonestPayloadSize(payload_buf);
  fixture.expected_return_value = 0;
  fixture.expected_payload_len = 0;
  fixture.expected_return_payload = "";
  return ExecuteHeartbeat(fixture);
}

HeartbleedTestFixtureSetupDtls()ExecuteHeartbeat() 項目與“goto fail”概念驗證單元測試中的類似項目密切相關

typedef struct {
  SSL_CTX *ctx;
  SSL *s;
  const char* test_case_name;
  int (*process_heartbeat)(SSL* s);
  unsigned char* payload;
  int sent_payload_len;
  int expected_return_value;
  int return_payload_offset;
  int expected_payload_len;
  const char* expected_return_payload;
} HeartbleedTestFixture;

static HeartbleedTestFixture SetUp(const char* const test_case_name,
    const SSL_METHOD* meth) {
  HeartbleedTestFixture fixture;
  int setup_ok = 1;
  memset(&fixture, 0, sizeof(fixture));
  fixture.test_case_name = test_case_name;

  fixture.ctx = SSL_CTX_new(meth);
  if (!fixture.ctx) {
    fprintf(stderr, "Failed to allocate SSL_CTX for test: %s\n",
            test_case_name);
    setup_ok = 0;
    goto fail;
  }

  /* snip other allocation and error handling blocks */

fail:
  if (!setup_ok) {
    ERR_print_errors_fp(stderr);
    exit(EXIT_FAILURE);
  }
  return fixture;
}

static HeartbleedTestFixture SetUpDtls(const char* const test_case_name) {
  HeartbleedTestFixture fixture = SetUp(test_case_name,
                                        DTLSv1_server_method());
  fixture.process_heartbeat = dtls1_process_heartbeat;

  /* As per dtls1_get_record(), skipping the following from the beginning of
   * the returned heartbeat message:
   * type-1 byte; version-2 bytes; sequence number-8 bytes; length-2 bytes
   *
   * And then skipping the 1-byte type encoded by process_heartbeat for
   * a total of 14 bytes, at which point we can grab the length and the
   * payload we seek.
   */
  fixture.return_payload_offset = 14;
  return fixture;
}

static HeartbleedTestFixture SetUpTls(const char* const test_case_name) {
  HeartbleedTestFixture fixture = SetUp(test_case_name,
                                        TLSv1_server_method());
  fixture.process_heartbeat = tls1_process_heartbeat;
  fixture.s->handshake_func = DummyHandshake;

  /* As per do_ssl3_write(), skipping the following from the beginning of
   * the returned heartbeat message:
   * type-1 byte; version-2 bytes; length-2 bytes
   *
   * And then skipping the 1-byte type encoded by process_heartbeat for
   * a total of 6 bytes, at which point we can grab the length and the payload
   * we seek.
   */
  fixture.return_payload_offset = 6;
  return fixture;
}

static void TearDown(HeartbleedTestFixture fixture) {
  ERR_print_errors_fp(stderr);
  SSL_free(fixture.s);
  SSL_CTX_free(fixture.ctx);
}

static int ExecuteHeartbeat(HeartbleedTestFixture fixture) {
  int result = 0;
  SSL* s = fixture.s;
  unsigned char *payload = fixture.payload;
  unsigned char sent_buf[kMaxPrintableCharacters + 1];

  s->s3->rrec.data = payload;
  s->s3->rrec.length = strlen((const char*)payload);
  *payload++ = TLS1_HB_REQUEST;
  s2n(fixture.sent_payload_len, payload);

  /* Make a local copy of the request, since it gets overwritten at some
   * point */
  memcpy((char *)sent_buf, (const char*)payload, sizeof(sent_buf));

  int return_value = fixture.process_heartbeat(s);

  if (return_value != fixture.expected_return_value) {
    printf("%s failed: expected return value %d, received %d\n",
           fixture.test_case_name, fixture.expected_return_value,
           return_value);
    result = 1;
  }

  /* If there is any byte alignment, it will be stored in wbuf.offset. */
  unsigned const char *p = &(s->s3->wbuf.buf[
      fixture.return_payload_offset + s->s3->wbuf.offset]);
  int actual_payload_len = 0;
  n2s(p, actual_payload_len);

  if (actual_payload_len != fixture.expected_payload_len) {
    printf("%s failed:\n  expected payload len: %d\n  received: %d\n",
           fixture.test_case_name, fixture.expected_payload_len,
           actual_payload_len);
    PrintPayload("sent", sent_buf, strlen((const char*)sent_buf));
    PrintPayload("received", p, actual_payload_len);
    result = 1;
  } else {
    char* actual_payload = strndup((const char*)p, actual_payload_len);
    if (strcmp(actual_payload, fixture.expected_return_payload) != 0) {
      printf("%s failed:\n  expected payload: \"%s\"\n  received: \"%s\"\n",
             fixture.test_case_name, fixture.expected_return_payload,
             actual_payload);
      result = 1;
    }
    free(actual_payload);
  }

  if (result != 0) {
    printf("** %s failed **\n--------\n", fixture.test_case_name);
  }
  TearDown(fixture);
  return result;
}

tls1_process_heartbeat() 測試幾乎相同,只是它們調用 SetUpTls() 來初始化 HeartbleedTestFixture,並且不涵蓋 ExcessivePlaintextLength 情況。 ExecuteHeartbeat() 和其他測試輔助函數比“goto fail”測試稍微複雜一些,但只是稍微。

與“goto fail”測試一樣,這個測試是在沒有測試框架的幫助下編寫的。它可以直接複製到任何 OpenSSL 1.0.1-beta1 到 1.0.1g 版本的 test/ 目錄中而無需進行任何修改並執行。當執行版本 1.0.1g 時,測試通過並且不生成任何輸出。對於其他版本,帶有“Heartbleed”字樣的測試用例將失敗,並生成類似的輸出

TestDtls1Heartbleed failed:
  expected payload len: 0
  received: 1024
sent 26 characters
  "HEARTBLEED                "
received 1024 characters
  "HEARTBLEED                \xde\xad\xbe\xef..."
** TestDtls1Heartbleed failed **

失敗測試中返回緩衝區的內容將取決於執行測試的機器上的內存內容。在測試文件的頂部默認設置為 1024 的 kMaxPrintableCharacters 的值可以增加以查看更多的內存內容。

將其分解,分解

在 Heartbleed 示例中,我們可以解決另一個問題,這是在“goto fail”示例中無法解決的。對於“goto fail”,我們無法準確了解引入漏洞的具體更改;現有證據表明,這可能是一個大型合併操作,加上代碼重復。儘管如此,“複雜的合併”理論只是一個猜測。對於 Heartbleed,我們可以看到引入了 TLS 心跳功能和其中包含的 Heartbleed 漏洞的確切更改,並且它已經經過了代碼審查。

開發人員熟悉單元測試的情況下,會生成或堅持一系列小規模且經過良好測試的變更,以構建功能,而不是像問題中的單個、龐大的變更那樣。一個更小、經過良好測試的變更只包含上述功能,可能更有助於作者、審查人員或感興趣的旁觀者注意到使用外部提供的值來讀取一塊內存,並驗證這樣的值是否已被正確處理。對定義心跳請求結構和處理方式的協議的具體部分進行明確參考也可能有助於聚焦測試和審查。

編碼標準文件也可以幫助這個過程。除了指定命名、空格和大括號放置的細節外,這樣的標準還可以要求請求和緩衝區處理代碼應附帶測試,以驗證不存在緩衝區溢出問題。這將是要求所有提交進行審查的代碼都必須按照政策進行新的或現有的單元測試覆蓋的補充。

如果沒有經過測試,就無法修復

上述的概念測試顯示,如果有人嘗試對代碼進行單元測試,他們可能會發現並阻止史上最災難性的計算機錯誤之一。概念測試的存在消除了這樣的斷言,即這是不可能的。遺憾的是,用於修復該錯誤的修復程式碼也缺乏測試以驗證它並防止回歸。

沒有自動回歸測試的錯誤被認為沒有得到正確的修復。

在單元測試文化中,當發現錯誤時,自然反應是編寫一個暴露它的測試,然後修復代碼以消除它。擴展“goto fail”討論中提出的觀點,運行以驗證代碼更改的手動測試證明是暫時的,一個沒有測試的修復容易被撤銷。自動回歸測試像首次為代碼編寫的測試一樣,保護未來的錯誤。

考慮到現代版本控制系統的強大功能以及分叉、合併和挑選等越來越普遍的實踐,測試比以往任何時候都更加重要,以防止意外更改,特別是導致已知災難性錯誤回歸的更改。在挑選或合併過程中明顯移除回歸測試應該引起警報,如果該測試包含在與修復相同的變更中,則情況更為嚴重,因為修復也可能被撤銷。

一再感到似曾相識

最後一點要提的是:通過在單獨的瀏覽器標籤中打開每個dtls1_process_heartbeat()ssl/d1_both.c)和tls1_process_heartbeat()ssl/t1_lib.c)之間切換,我們再次看到明顯容忍重複、未經測試的代碼,就像在“goto fail”示例中一樣。有了概念測試,可以通過提取一個共同的函數來消除重複,並使用一組額外的參數——也許是一個小的“跳轉表”——來實現算法之間的輕微差異。

林納斯的法則再次受到考驗

現在應該清楚,“goto fail”和Heartbleed漏洞都是相當簡單的編程錯誤,這種錯誤是單元測試在早期捕捉時很擅長的錯誤類型之一。從上面的討論中,以及對兩個概念驗證單元測試的實施中可以清楚看出,這些漏洞很可能是可以預防的,只要製造每個漏洞的團隊接受單元測試的實踐。

這些災難性的缺陷也展示了“林納斯定律”的局限性——足夠多的眼睛,所有的漏洞都是淺薄的——同時也展示了這個定律的真正潛力。

足夠多的眼睛,所有可利用的漏洞都會被找到——但不一定是好人發現。

目前尚不清楚這兩個漏洞是否曾被成功利用,但這些代碼多年來一直作為Apple和OpenSSL的開放源代碼存在於其服務器上,這為惡意代理人提供了機會,發現其中任何一個漏洞並利用其知識來謀取自己的利益,而不通知其他人。鑑於這一認識,讓我們提出林納斯定律的一個推論

在開放源代碼代碼中發現漏洞的眼睛並不都屬於願意為了公眾利益而報告或修復它們的聖人。

同時,提供對源代碼的開放訪問意味著,在這兩種情況下,世界上任何有互聯網訪問權的人都可以事後檢查代碼,了解錯誤的性質和嚴重性,報告其技術細節和影響,並就所學到的教訓和適當的應對措施進行辯論,以防止再次發生。這些報告的質量自然有所不同,但開放源代碼軟件提供的透明度使得進行一場公開辯論成為可能,最終理想情況下應該會帶來對社會有益的客觀教訓。如果在封閉源代碼中發生了類似的漏洞,這種寶貴的討論將更難以進行——事實上,類似的漏洞很可能已經存在,而整個軟件開發社區可能永遠不會有機會從中學習。

有機會訪問開源代碼使我能夠將我的單元/回歸測試提交到中央OpenSSL源代碼存儲庫。

在這兩種情況下,訪問開源代碼也使我能夠深入研究每個代碼庫,並在幾小時內為每個漏洞撰寫出具有結論性的概念驗證單元測試。它還使我能夠與OpenSSL開發人員進行交流,並提交一個用於Heartbleed概念驗證的單元測試的拉取請求(當然是遵循Google到OpenSSL的編程風格進行調整),這最終被包括在中央OpenSSL源代碼存儲庫中的ssl/heartbeat_test.c中。

當然,這引起了一個問題:為什麼負責代碼的團隊多年前在引入錯誤時沒有編寫或堅持這樣的測試呢?

責任歸結於代碼審查過程,通過開發人員對主源代碼庫的訪問權進行接受變更以包含到代碼庫中。如果代碼審查者不要求單元測試,那麼無謂的代碼將會積累,增加了另一個 "goto fail" 或 Heartbleed 事件發生的機會。正如也許是 "goto fail" 的情況一樣,許多公司的開發團隊專注於高層次的業務目標,缺乏改進代碼質量的直接激勵,並認為投資於代碼質量與準時交付之間存在衝突。就像 Heartbleed 一樣,許多開源項目是由志願者推動的,中央開發人員缺乏執行每個代碼變更都應該附帶徹底、精心編寫的單元測試所需的時間或技能。沒有人支付、獎勵或施加壓力,要求他們保持高水準的代碼質量。

因此,產生這些錯誤的開發文化可能根本沒有考慮過單元測試,或者已經考慮過但基於某種「機會成本」而拒絕了它。這意味著單元測試被認為無法為投資帶來足夠的價值,從而從其他優先事項和機會中消耗了寶貴的資源。這可能不是一個有意識的決定,但團隊決定採用其他工具和實踐的選擇表明了這個選擇。

無論這些決定是如何做出的,開發和維持高效的單元測試文化確實並非沒有成本。在下一節中,我將探討這些成本並考慮它們是否值得。

單元測試文化的成本與效益

雖然單元測試可以大大減少低級別的缺陷數量,包括像 "goto fail" 和 Heartbleed 這樣的高可見度和高影響力的缺陷,並對代碼質量和開發過程的其他方面產生積極影響,但建立和維護一個單元測試文化是有成本的。沒有免費的午餐。

啟動成本

將會有一個學習曲線。與僅僅依賴機械過程的技能不同,學習編寫單元測試的程序員將不得不經歷學習和發展、試驗和錯誤、反思、實驗和整合的階段。這需要時間、精力和資金,這些都會從其他活動中抽取。這將導致開發初始速度變慢,因為人們習慣了這種做法。

雖然如此,這是一次性成本。如果團隊已經擁有良好的單元測試實踐,並且單元測試技能可從一個專案移植到另一個專案,那麼使某人掌握單元測試的成本相對較低。因此,對於根本沒有任何單元測試實踐的團隊來說,學習曲線是最陡峭的。

單元測試,像任何其他工具、語言或流程一樣,如果開始時沒有良好的示例可供參考,或者沒有導師指導,就可能被應用不當。易碎、龐大、緩慢、經常失敗(然後被忽視)或不穩定的單元測試會設置不良示例,這些示例可能像病毒一樣在整個測試套件中複製。編寫不好的測試實際上可能比沒有測試更糟,留下測試是浪費時間的印象。構建仍然失敗且被忽視,使得測試信號淹沒在不斷失敗的噪音中。對於與測試環境合作不感興趣的開發人員,他們更願意忍受進行緩慢而痛苦的更改的恐懼。最終結果是降低了生產力,增加了缺陷風險,並使團隊相信測試是為其他人而設。

培訓

為了彌補對知識和經驗的缺乏,積極的開發人員可以聚在一起,提高彼此的單元測試技能,並隨著時間的推移增加代碼庫的測試覆蓋率。在本節中,我將描述Google Web Server團隊是如何建立其測試覆蓋率並實現整體生產力的;在後面的章節中,我將解釋Google作為一個整體是如何採用單元測試文化,以及從那次經驗中得出的教訓可能如何適用於個別團隊。然而,自我培訓需要時間和精力,而且整體效益可能不會立即顯現出來,因此需要耐心、誠實的努力和承諾來一直堅持下去。隨著時間的推移,隨著代碼庫的增長和更多開發人員加入團隊,價值變得越來越清楚。一個兩人團隊可能可以沒有單元測試,但一個二十人團隊將更難,因為功能和溝通的複雜性被加劇。

如果開發人員缺乏動力去研究可用的資料並提升他們的技能,或者根本不知道如何開始,這可能意味著需要投資於內部培訓計劃或聘請外部幫助提供培訓。如果資源緊張、截止日期逼近,而未來的好處又不清晰,這可能會導致一些價格震撼。學習所需技能的時間不應該比訓練開發人員其他技能或技術所需時間更長;但如果開發人員抗拒,這個過程可能變得更加冗長、痛苦和昂貴。

把自己逼入死角

有時,測試本身可能成為一個維護負擔;它可能看起來像是把一個項目限制在一個角落,而不是最大化進展。對於缺乏單元測試經驗且不理解其價值的新團隊來說,這是一個特別危險的情況。模擬物件易於被缺乏經驗的從業者誤用,導致價值可疑的脆弱測試。隨著經驗的增加,這種情況變得不太可能發生。您最終會學會退一步,重新評估代碼和測試的目標,並重寫其中之一,或兩者都重寫。同時,有時取代一個過於限制性的測試而不是花費精力挽救它可能是必要的。

說到新項目、團隊、公司或領域,儘管一直嚴格遵循敏捷實踐並始終實踐純測試驅動開發可能是理想的,但有時開發人員或團隊需要探索、玩耍,然後才能開始嚴肅地定義期望和行為。(有人認為始終嚴格遵循所有敏捷實踐是表明您不了解敏捷的一種示範。)儘管在項目上盡早獲得測試經驗總是很好,但有時您只需要撰寫一次性、原型代碼;在這種情況下,徹底的單元測試可能是多餘的。對於試圖盡快推出產品的新創公司而言,這可能尤其如此。

另一方面,要注意這句話:“沒有比即興編寫的代碼更加永久的東西了。”這是一個折衷,如果沒有伴隨測試的實現更多功能,團隊將累積更多技術債務,以後必須償還。如果你不從一開始設計以便進行測試,比如使用依賴注入、撰寫專注於一件事的明確的類,等等,單元測試可能會變得困難。團隊必須衡量這種債務的可接受限度,以及必須在何時償還,以避免一旦維護和新功能開發變得太繁瑣,就必須進行更昂貴的重寫。

誰來測試測試?

單元測試本身並不能保證沒有錯誤。考慮這個例子(基於Google Test框架的類似C++的虛擬碼)

TEST_F(FooTest, IfAPresentFilterB) {
  setup input and add "A:" , "B:"
  run call
  EXPECT_TRUE(PresentInOutput("A:"))
  EXPECT_FALSE(PresentInOutput("B"))
}

這個測試中的第二個期望應該檢查"B:",帶有冒號,而不僅僅是"B"。如果被測試的代碼意外地過濾了沒有冒號的"B",則測試將通過,而應該失敗。

在這種情況下,有人認為測試會使情況變得更糟,提供了一種虛假的安全感。然而,即使沒有寫測試,錯誤仍然可能存在;鑑於存在有缺陷的測試,修復代碼和測試相當於為該錯誤提供了回歸測試。修復測試並從錯誤中學習是有價值的;責怪測試並刪除它是向後退了一步。為了避免將來出現有缺陷的測試,負責這種錯誤的團隊可以努力更仔細地查看未來代碼審查的一部分,給予它與“生產”代碼相同的優先級和關注。

實際上,有缺陷的單元測試往往是例外。如果練習純粹的測試驅動開發,在撰寫使其通過的代碼之前應該撰寫失敗的測試;這可能有助於防止這種錯誤。如果不練習純粹的TDD,暫時在被測試的代碼中添加一個錯誤以確保測試將失敗也有助於。無論哪種情況,撰寫多個測試用例來檢查代碼不應該做什麼(而不僅僅是檢查所有輸入都有效的快樂路徑)可能會揭示其他測試用例中的錯誤。然而,單元測試本身可能包含錯誤的可能性仍然存在,尤其是如果沒有注意確保它們在應該失敗時失敗。

測試是給傻瓜的

過去曾有成功的團隊或公司充滿了改變世界的技術大牛。谷歌在其存在的頭幾年確實符合這一描述。在那個時代,可以認為在單元測試上花費的時間是浪費的,因為這可能會不必要地拖慢那些頂尖開發人員的速度,特別是如果他們還沒有習慣撰寫單元測試的話。由於公司和代碼庫規模

問題隨之而來:為什麼這種情況沒有保持永久?

Google Web Server 故事

儘管存在風險和成本,但重要的是要意識到,單元測試的好處不僅僅在於最大程度地減少釋放災難性錯誤的機會。

當我在2005年加入Google時,它已經非常成功,許多“老手”相信這是因為我們做的一切都是對的。因此,當時和之後的一些年份,對於改變存在很大的阻力。然而,隨著用戶基數和潛在的災難性增加,以及成功和隨之而來的增長追上Google,變得越來越清楚,更多的“搖滾巨星”產生“搖滾巨星”代碼將只會在長期內產生一堆噪音和混亂。新的Google開發人員的湧入最終幫助加速了文化轉變,促使採用單元測試,這既是因為這些新開發人員對這個想法持開放態度,也因為測試最終證明在幫助這些新人迅速上手並避免犯錯方面是有效的。

作為一個具體的例子,讓我們看看可能是互聯網上最受歡迎的頁面:Google的主頁。Google Web Server(GWS)團隊的單元測試故事在整個公司中變得眾所周知。在2000年代中期,GWS團隊處於一個困難的境地,很難對Web服務器進行更改,這是一個C++應用程序,用於提供Google的主頁和許多其他Google網頁。儘管存在這種困難,但集成新功能對於Google作為一家企業的成功至關重要。阻止人們盡快進行更改的障礙與大多數成熟代碼庫上的變化速度相同:合理地擔心更改會引入錯誤。

恐懼是心靈的殺手。它阻止新團隊成員改變事物,因為他們不了解系統,同時阻止有經驗的人改變事物,因為他們太了解了。

Google Web Server團隊採取了強硬態度:沒有附帶單元測試的代碼是不被接受的。

為了克服這種恐懼,GWS團隊引入了測試文化。他們採取了強硬態度:沒有附帶單元測試的代碼是不被接受的,沒有單元測試的代碼審查是不被批准的。這通常會讓其他團隊的貢獻者感到沮喪,他們試圖推出他們的功能,但GWS團隊堅持自己的立場。

隨著時間的推移,單元測試覆蓋率和開發動力增加,而缺陷、生產回滾和緊急發布計數減少。新的團隊成員發現自己變得更加快速地變得更有生產力,因為這些測試允許他們逐個單元地更深入地瞭解系統,並且以現有測試很可能檢測到任何意外副作用的信心開始貢獻變更。在他們早期努力中造成的任何測試失敗加速了他們對系統的理解。團隊中經驗豐富的成員,他們對於進行更改和接受來自貢獻者的更改變得謹慎,因為他們能夠迅速進行更改並接受更改的原因相同,不再主要依賴於大型且昂貴的系統或手動測試,反饋周期為幾小時或幾天。添加更多新的開發人員實際上使團隊能夠更快地移動並做更多事情,避免了“布魯克斯定律”描述的情況,其中“向一個已經延遲的軟件項目增加人力會使它更加延遲”。

此外,對恐懼的減輕也促使他們對編程的喜悅擴大,因為他們可以看到朝著令人興奮的新里程碑取得具體進展,而不會被嚴重優先級錯誤的持續爆發所阻礙。基於保持創造性流程狀態的能力,高士氣對生產力的影響不可過分強調。在我在 Google 的時候,GWS 團隊展示了理想的測試文化,整合了來自外部貢獻者的大量複雜更改,同時進行自己的不斷改進。

感謝 GWS 的示例激勵了測試小組的努力(一個由開發人員組成的團隊,志願促進單元測試採用,本文的後面部分將對此進行描述),Google 的許多團隊能夠過渡到單元測試文化並受益於減少的恐懼和增加的生產力。克服慣性、漠不關心、過時工具的摩擦和阻力需要時間,因為一開始單元測試感覺像是一種成本,有些人擔心花費在撰寫第二個行為表現的時間可以用來撰寫新代碼(這會使他們晉升)。最終,當人們體驗到摒棄變革恐懼意味著什麼時,他們開始認為這種副作用在影響他們的幸福、他們的團隊幸福和生產性輸出底線方面輕鬆地超過那些代碼行。

緊密的反饋迴路

隨著時間的推移,單元測試紀律使 Google Web Server 團隊能夠更快地移動並完成更多工作。單元測試與捕捉錯誤一樣,都是關於提高生產力。

如果你錯過了,GWS 團隊故事的重要一點是,隨著時間的推移,單元測試紀律使團隊能夠更快地移動並完成更多工作。單元測試與捕捉錯誤一樣,都是關於提高生產力,因此適當的單元測試加速了他們的速度而不是減慢了他們的速度。讓我們強調幾個促成這一結果的因素。

單元測試與整合測試、系統測試或任何一種試圖僅基於介面契約來測試系統的對抗性「黑盒」測試不在同一類別中。這些類型的測試可以以與單元測試相同的風格自動化,甚至可能使用相同的工具和框架,這是件好事。然而,單元測試將特定低層級程式碼單元的意圖具體化。它們專注且迅速。在開發過程中,當自動化測試失敗時,可以快速識別和解決負責的程式碼變更。

這種快速反饋循環產生了在開發過程中所需的理想狀態:一種專注和動機的感覺,用於解決複雜問題。與相反現象相對比,使用了耳熟能詳的操作系統隱喻「上下文切換」。上下文切換要求必須以某種方式保存目前的操作狀態,然後才能在啟動新活動之前交換新的操作狀態;接著,必須投入時間和精力進行切換。此外,還有每個操作需要管理多少狀態的問題。如果沒有單元測試,我們必須使用更多的大腦記住奇怪的邊緣情況和奇異的副作用,這樣就會給我們留下更少的時間和精力去做我們比電腦更擅長的事情:將解決方案推進到新問題,而不是負責處理已經解決的所有問題。

換句話說,您可以更快地進行編碼迭代,因此您可以更有效率:如果只需運行單元測試,而不需要啟動某些沉重的伺服器,那就不需要啟動它。因此,如果需要幾次才能正確編寫某些程式碼,則每次重新啟動伺服器可能需要幾分鐘(或更長時間),而只需每次重新運行單元測試可能需要幾秒鐘。

改進的代碼質量

正如在產品層面上使用狗食是良好的實踐一樣,必須編寫使用自己程式碼的程式碼可以導致改進的設計。

與學術純潔相去甚遠,程式碼品質至關重要。糟糕的程式碼為 bug 提供了許多藏身之地;良好的程式碼增加了發現並迅速消除它們的機會。當一段程式碼的作者為該程式碼撰寫測試時,作者實際上成為了第一位使用者。正如「吃自己的狗糧」在整體產品層面上是良好的軟體開發實踐一樣,必須撰寫使用自己程式碼的程式碼可以導致改進設計,使其更易讀、易維護和易調試。

思考你正在撰寫的程式碼所要解決的問題;然後想想你希望撰寫的程式碼,作為客戶,來利用這個解決方案。這個理想的客戶程式碼可以被表述為使用你正在開發的程式碼的介面的單元測試案例。

當以這種方式來處理程式碼級設計時,構成較大系統的所有較小部分不僅變得更可靠,而且更容易理解。這使得每個人都更具生產力,因為理解特定程式碼片段所需的心智努力被最小化。

可執行文檔

單元測試的名稱可以作為程式碼行為的規範;測試本身則作為每個行為案例的程式碼樣本。為了實現這一點,對測試程式碼設定與產品程式碼相同的品質標準。

撰寫良好的單元測試可以提供兩種類型的文件:測試名稱充當程式碼行為的一種規範;測試本身則作為每個行為案例的程式碼樣本。比典型的應用程式介面 (API) 文件更好,維護良好的單元測試基本上是實際行為的最新表現。單元測試的作者有效地向其他開發人員傳達了程式碼應該如何使用以及可以從中期望什麼。這些「其他開發人員」可能是新加入團隊的人,或者甚至尚未被聘用(甚至出生)。這樣的文件幫助開發人員理解不熟悉的程式碼,甚至整個系統,而無需打擾其他人到可能比沒有單元測試時更大的程度。

寫得不好的單元測試缺乏這種品質,通常是因為對測試代碼的思考比對「生產」代碼少。解決方案:對測試代碼設定與生產代碼相同的品質標準。如果不這樣做,你的測試將變得難以維護,並拖慢團隊的進度。

加速理解

每當測試失敗時,這都是深入了解系統的機會。

這樣想吧:每當測試失敗時,這都是深入了解系統的機會。如果你是團隊的新成員,在開始對系統進行更改時,破壞許多測試可以幫助你更快地提高生產力,因為這些事件都將使你對系統的認識更貼近現實。如果你在團隊上已經待了很長一段時間,現有的測試將回答許多新貢獻者可能會有的問題,節省你的時間和注意力。它們還會提醒你關於過去可能已經寫過但已有一段時間沒有思考過的代碼的所有細微差別,如果你不得不重新投入其中,可以最小化切換到先前的思維狀態所需的時間。換句話說,當為代碼添加一套精心製作的測試時,你是在幫助你未來的自己,最小化重返先前思維狀態所需的時間。

想像一下相反的情況,就像 GWS 預單元測試時期的情況:當你在一個沒有足夠單元測試覆蓋的專案上工作時,你會害怕做任何事情,因為你不知道可能會破壞什麼。

更快的錯誤尋找

想像一下,在集成或系統測試中發現一個錯誤,或者在將新版本推送到數據中心後,或者在此之後一段時間由用戶發現一個錯誤。負責錯誤代碼的開發人員已經轉移到了其他任務上,很可能是在截止日期壓力下交付。如果錯誤足夠嚴重,至少其中一個開發人員將不得不停下來處理它,從而拖慢正在進行中的新開發工作的進度。

如果有一套完整的自動化測試套件,尤其是包含小單元測試的測試套件,那麼開發者修復錯誤程式碼的時間可能不會太長。現有的測試可以作為受影響程式碼意圖的文件。開發者新增一個新的測試來重現錯誤,確保在嘗試修復之前已充分了解缺陷。這個新測試驗證了錯誤的修復,而現有的測試提供了高度的信心,確保修復沒有意外的副作用。新的測試成為測試套件的永久一部分,以防止回歸,修復釋出後,新版本的開發繼續進行。中斷結束。

相較之下,如果錯誤的程式碼沒有受到足夠的單元測試覆蓋。開發者必須花更多時間來理解受影響的程式碼,更加小心地找出錯誤,確保修復不會產生副作用。修復的驗證可能需要幾天甚至更長的時間,取決於現有的預發行測試的性質,如果有的話。中斷將會延長,並從新版本中消耗更多的開發和測試時間。

或者更糟糕的是:團隊可能決定保留錯誤位置,因為擔心會搞砸其他東西。這絕對不會激發用戶的信任,更不用說開發者的信心和生產力了。

你有經驗嗎?

總之,經過這些文字,你對單元測試的價值和力量還保持懷疑嗎?我不怪你。說實話,就像生活中的其他好事一樣,直到你真正嘗試過之前,你是無法知道它是什麼感覺的。而且,可能在有人幫助你學會如何做好之前,你甚至不會喜歡它。

我對單元測試的經驗並不是始於一些廣泛的理性論證,或者令人信服的客觀證據說服我去嘗試。我所在的北羅普·格魯曼公司的團隊剛剛完成了一場為滿足必要的認證截止日期而進行的激烈推進;在接下來的幾個月裡,當為了性能和穩定性原因重新編寫一個子系統時,我嘗試了單元測試。兩者之間的差異再明顯不過,也再令人信服不過。我可以看到和感受到新系統的進展,每次新增功能都符合預期。當出現罕見的錯誤時,只需要幾個小時來找出、重現、修復並發布修復 - 而在此過程中並未添加任何新的缺陷。

單元測試的最大優勢就在於實際的單元測試經驗。最棒的是,單元測試技能跨領域、語言和公司都是可攜式的,就像任何其他基本的編程技能一樣。

我的意思是,單元測試的最大優勢就在於實際的單元測試經驗。你無法測量生產力,但你可以感受到它。即使你的第一個單元測試顯得難看、複雜而脆弱,相信我,你可以變得更加熟練,而這份回報將是非常值得的。

最棒的是,單元測試技能跨領域、語言和公司都是可攜式的,就像任何其他基本的編程技能一樣。這是一項終身受益的投資。記住:過去的單元測試經驗使我能夠很快地為“goto fail”和Heartbleed撰寫概念驗證的單元測試,雖然我對代碼不熟悉,並且多年來沒有定期編程。

動手動腦

本文的前兩節包含到“goto fail”單元測試包Heartbleed單元測試的鏈接。如果你還沒有做過,請下載代碼,構建並在你的系統上運行它。確保測試通過。然後,改變一些東西,無論是在測試代碼還是在被測試的代碼中,使其失效。看看輸出。消化它,反思。然後修復代碼,使測試再次通過。

你以為你已經理解了“goto fail”和Heartbleed代碼,但現在你實際上感受到了它的運作方式。

你(應該已經)體驗到的是從對實際系統的一部分進行更改中產生的智慧興奮,並且幾乎可以即時看到該更改的影響,而不需要構建和啟動整個產品並在用戶界面中進行探索。想想看:直到現在,你以為你通過閱讀它、本文早期的解釋,或者可能是你閱讀的其他來源,已經理解了“goto fail”和Heartbleed代碼。但現在你實際上感受到了代碼的運作方式。在Heartbleed測試的情況下,你實際上可以看到你機器內存的內容溢出到屏幕上。(在我的機器上,我可以清楚地看到我的PATH和其他環境變量。)

即時驗證您剛添加或更改的程式碼是否真正達到您的目的,是一種獨特的回報。確信您的程式碼將正確處理任何輸入的(相對)程度,是令人振奮的。當測試檢測到您剛剛編寫的程式碼中的錯誤時,即使您(或其他可憐的傢伙)以後不必花費數小時來除錯、修復、驗證和清理,也會讓人上癮。

而在您以前從未見過的程式碼中重現主要錯誤呢?無價。

即時滿足感確實是大多數堅持單元測試的人著迷的原因。沒有理性的爭論,沒有數據,也不需要圖表或金額。

這種即時滿足感確實是大多數堅持單元測試的人著迷的原因。對於其他人來說,這是對於回歸不會發生的高度信任。無論哪種情況,單元測試都創造了一種純粹的高潮,基於對前進的感覺,一種無畏的生產力感,沒有其他上癮的不良副作用。沒有理性的爭論,沒有數據,也不需要圖表或金額。

沒有孤立的測試

然而,儘管單元測試具有許多好處,但單元測試不應該是確保高質量、大多無錯誤程式碼的唯一工具。接下來,讓我們考慮一些其他可用的工具和實踐,可以與單元測試一起在日常開發中使用,以及為什麼值得在這些其他可用項目的背景下採用單元測試以及可以提前捕獲缺陷的原因。

其他有用的工具和實踐

儘管單元測試在早期檢測程式錯誤方面效果顯著,並且儘管它具有其他生產力益處,但它絕非銀彈,絕非一種可以保證在發布之前消除所有軟件缺陷的奇跡藥物。沒有工具可以有效地這樣做,因為開發一個工具來保證一個軟件是無錯誤的相當於解決停機問題。正如著名的名言所說:

... 程式測試可以非常有效地用來顯示錯誤的存在,但永遠不能用來顯示它們的不存在。

-- 艾茲格·W·迪科斯特拉

事實上,某些類別的錯誤在一般情況下極其難以有效地進行單元測試。這方面的一個典型例子是作為共享可變記憶體結果而產生的並行錯誤,例如競爭條件和死鎖。這可能涉及到在單個程序中的線程共享數據,同一台機器上的進程共享磁盤上的文件,或者必須確保數據庫中存儲的信息的一致性的分佈式系統。儘管在單線程上下文中對每一個邏輯位進行單元測試是確保多線程上下文中正確性的第一步,但這遠遠不足以確保並行錯誤的不存在。需要其他工具、測試層、分期環境以及監視和記錄形式來檢測和調試這類問題。(儘管,當發現這樣的錯誤時,最好提供一個單元測試來可靠地重現它並驗證其修復。)

基於這些事實,不僅建議,而且至關重要的是,對於早期檢測缺陷的問題,引入其他工具和實踐,以確保高度的程式碼品質,最大程度地提高產品成功的機會,並最小化其可能的失敗。然而,在考慮其他工具時,請記住這一點:正如本文前幾節中的概念驗證測試所證明的,單元測試可以應用於任何語言,使用現有的開發工具,在任何其他開發實踐的背景下,對現有的程式碼進行測試。某些工具、框架、語言和實踐可能會使單元測試更容易且更具生產力,但不是必要條件。所涉及的主要成本是對開發人員、管理人員和高管進行單元測試的教育,並說服開發人員進行測試。

真正的魔法發生在單元測試和其他工具共同使用時。使程式碼更易於編寫和維護的相同工具和實踐有助於使單元測試更易於編寫和維護。同時,良好的可測試性設計,通常不會推向邏輯極端,結果是使程式碼更容易進行審查、維護、擴展、調試、與其他工具進行分析和文件化。每個工具和實踐都有其優勢和劣勢;每個被整合到開發文化中的工具都降低了產品中出現錯誤的機會,並減少了解決仍然存在的錯誤所需的時間和精力。

編程是一門手藝,像大多數手藝一樣,專家是選擇適當工具的大師,並為每個單獨的產品創建所需的自定義工具。當建築師正在建造水泥基礎時,他首先創建的是將包含並塑造該基礎的木結構。一位熟練的木工可能會從構建一個能夠容納所有零件的框架開始。在軟體方面,也是如此。在製作應用程式時,我們選擇提供開發環境基礎的平台、語言和工具,然後我們建立我們需要的專門工具:允許我們隔離零件並專注於它們的存根和假物件;將一塊程式碼拿到光下檢查的單元測試。如果沒有適合的工具,那麼產生次品的風險仍然很高。

靜態分析/編譯器警告

靜態分析和編譯器警告是一種很好的工具,即使對於經過良好測試的代碼也是如此。從不同角度確保代碼質量的互補性保護措施總是一個好主意,因為這些工具可能會突顯現有測試目前忽略的問題點。即使如此,單元測試可以揭示機器可能永遠不會抱怨的潛在問題。 “goto fail”錯誤本可以被靜態分析或無法到達代碼編譯器警告捕獲; 然而,雖然警告或強制大括號可能已阻止此行代碼產生缺陷,但單元測試文化將鼓勵負責代碼的開發人員消除為其提供掩護的重複。提取新函數編寫測試以充分激活此關鍵算法,然後程序員可以用單獨的函數調用替換同一文件中出現的六個相同算法的副本,從而提高長期代碼質量並減少長期潛在錯誤的可能性。

靜態分析工具在檢測重複代碼方面正在變得越來越好,但編寫單元測試的程序員仍然是第一線和最有效的防線。同樣,儘管這些工具可以檢測死代碼,但如果“goto fail”確實是壞合併的結果,想像一下如果合併錯誤是這樣的

- if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
-     goto fail;

換句話說,如果合併的結果是意外刪除算法中的最後一步。相同的錯誤,但所有大括號和靜態分析和編譯器警告都不會有所幫助。單元測試可以捕捉到它。(您可以嘗試使用概念證明單元測試來自行查看。)

儘管如此,靜態分析和編譯器警告可以幫助檢測代碼本身和測試中的典型錯誤。特別是編譯器警告是最容易應用的工具之一,因為它們已經內建於現有工具鍊中。

有時,當首次應用這些工具時,程序員可能會抱怨“假警告”,因為這可能會導致一大堆輸出。的確,某些工具可能會抱怨諸如競爭條件或空指針之類的事情,經檢查後顯示為虛假的。可能是該工具尚未成熟,或者不適用於您的特定產品。通常,靜態分析工具可以使某些警告被抑制,允許團隊基於特定情況決定是否忽略特定警告或暫時將其消音。

當問題堆積起來時,著手解決問題。增量進展不僅僅適用於功能開發。

另一方面,許多工具,特別是編譯器警告,能夠以相對少的噪音檢測到合法問題; 當潛在問題長時間未被檢測時,它們往往會堆積起來,導致在應用工具時出現大量警告和錯誤。在這種情況下的真正解決方案是著手解決問題,就像對現有代碼添加單元測試一樣。修復一類警告,對一個文件進行修復。然後轉移到下一個。如果代碼尚未被覆蓋,請嘗試添加一個測試。增量進展不僅僅適用於功能開發。

現代語言

當開始一個不需要C甚至C++低級效率的新專案或應用程式時,“現代”語言如Python、Ruby、Java、Scala、C#或Go可能會更具吸引力,因為這些語言具備

  • 一流的物件導向程式設計功能(例如繼承/組合、封裝、多型性);
  • 自動記憶體管理;
  • 陣列邊界檢查;以及
  • 許多低級編程任勞任怨的常見庫。

這個決定在很大程度上與投資於建立單元測試文化的決定無關 - 大多數現代語言都具有強大的功能和庫,支援單元測試內建到它們的標準發行版中,這只會有所幫助!

對於現有專案來說,當涉及建立單元測試文化時,轉換到新語言基本上是不必要的。 “goto fail”和Heartbleed漏洞都屬於用C編寫的代碼; 但是,正如概念驗證單元測試所示,有效的單元測試可以捕捉到這類漏洞並防止它們的傳播,而無需求助於更“現代、更安全”的語言。在新語言中重新撰寫現有系統是一個昂貴且危險的過程,可能多年內都不會產生效益。這不是說它不值得,但是發展單元測試文化是您今天可以開始實現的事情,而且其好處遠遠超過了感知的成本和風險。這是因為單元測試可以遞增地應用於現有代碼,即使這樣的代碼必須逐個更新以支持改進的可測性,正如“goto fail”示例中所示的那樣。而且,正如本文的後面一節所描述的那樣,Google的測試小組幫助公司在大規模上實現了這一點,從而完全證明了向現有代碼添加單元測試是一個已解決的問題。

如果一個團隊決定冒險用一種新語言重寫系統,那麼這種語言不應被視為解決所有潛在缺陷的解決方案。不可達代碼和不安全的內存訪問並不是唯一等待發生的錯誤,重寫提供了一個絕佳的機會,在重新實現功能的同時添加單元測試。如果語言是動態類型的,擁有一套單元測試套件來記錄預期的類型並防止編譯器無法自動捕捉的錯誤尤為重要。如果將應用程序從一個平台移植到另一個平台需要在新語言中進行重寫,例如從iOS移植到Android,擁有一套單元測試套件也可以幫助平滑過渡並防止移植錯誤。

對於重寫低層次系統項目(如OpenSSL),除了C之外,C++和Go是少數實際的語言選擇。這兩種語言中可用的測試工具和框架比C語言更強大,使單元測試更加容易。Google Test和Google Mock可以與Java測試框架相媲美,Go內建了令人驚嘆的覆蓋率工具,ogletest則是受Google Test影響深重的框架。

開源

開源代碼並不意味著它一定是無錯誤的,這一點已被“goto fail”和Heartbleed所證明。如上所述關於“Linus's Law”的討論,開源為社會帶來了許多積極的好處。它還可以為開發社區、潛在客戶和員工帶來許多誠信和支持。然而,開源代碼並不自動保證高質量、無錯誤的代碼,儘管有一種流行的觀點認為它可以。

如果您決定開源您的代碼,最好還要遵循另一個與Linus's Law相關的推論,以及之前提出的推論。

如果您將代碼作為開源發布,請確保它已經過單元測試,並堅持要求附有高質量單元測試和文檔的貢獻更改。

如果單元測試與功能開發一樣被授予一級地位,“goto fail”和Heartbleed可能本可以避免。此外,人們可以更輕鬆地為項目的開發和長期健康狀況做出貢獻,發現缺少的測試用例,或者為未覆蓋代碼添加新的測試。新開發人員也將更容易理解系統,因為有測試作為安全網、一種形式的可執行文檔,以及一種加速理解的反饋機制。

風格指南/編碼標準

制定和遵守一套編碼標準從未太遲,這些標準可以提供編審者線索,表明一段代碼應該接受更嚴格的審查。編碼標準不僅可以避免對於空白使用、大括號放置和符號命名的無數爭論,它們還可以幫助程序員通過熟悉的視覺慣例檢測代碼與特定慣例偏離時可能表明存在缺陷的情況。編碼標準並不消除測試的必要性;這兩種做法相互強化。再次強調,在兩種不同的表示中犯同一個錯誤比僅犯一次錯誤更難。

然而,儘管樣式指南可以幫助避免許多錯誤,但它們無法捕捉不正確的邏輯條件或數學運算。大多數編譯器或靜態分析器也不能做到這一點,未經測試的代碼更難以審查此類錯誤。僅僅依靠樣式指南可能無法施加促進所謂 SOLID 設計原則的設計壓力;設計可測試性實際上與良好的面向對象設計幾乎是無法區分的。

正如本文早期討論 Heartbleed 的部分所提到的,OpenSSL 項目可以採用一個標準,根據該標準,任何處理請求緩衝區或分配並寫入輸出緩衝區的代碼必須伴隨著測試,以防止常見的緩衝區漏洞,此外,要求所有提交進行審查的代碼都必須由新的或現有的單元測試覆蓋。

代碼審查

程式碼審查是一個值得採納的實踐,理想情況下是在單元測試之外而不是取代它。它有助於揭示隱含的假設:我們都有一些視為理所當然的知識,我們通常並不意識到這對其他人可能並不明顯。在讓審查者理解代碼的過程中,作者通常被迫闡明自己的假設並使代碼的意圖更加明顯。此外,它增加了「做對事情」的動力,因為你的同行實際上會看到代碼並公開評論它的知識。這提高了代碼質量,有時還會暴露出錯誤。

程式碼審查對於文檔也很有用。閱讀審查者的評論對於試圖撰寫文檔的人來說可能非常啟發性,因為它揭示了對其他人來說令人困惑的地方,並突出了可能會被忽視的細節。程式碼審查也是一個很好的機會,可以與代碼一起審查文檔。

開發人員可能需要一些時間來優化審查流程,而審查代碼所花費的時間並不是寫代碼的時間,也不是我要補充的調試代碼的時間,但是知識轉移的潛力,使團隊或公司在編碼、領域和產品專業知識方面達到更高水平是巨大的。無論是作為正式文檔化流程的一部分還是作為配對編程的非正式副作用,提交到源代碼控制的每個更改都應該經過代碼審查。

較小的更改更容易進行代碼審查,因為審查者需要一次檢查的代碼較少。經過良好測試的更改更容易進行代碼審查,因為審查者可以看到作者考慮了哪些情況,並可能受到啟發提出更多建議。測試使整體代碼更改變得更大,但是寫得好時,應該用於澄清測試代碼的更改,並且應該相對容易進行審查。結合這兩個原則,相對較小、經過良好測試的代碼更改累積成一個完整的功能比沒有測試的單一更改更容易審查,例如導致了心臟出血漏洞的更改—這是經過代碼審查的。如果審查者需要一系列較小、經過良好測試的更改,那麼審查者可以驗證作者是否已經探測了處理無效用戶輸入的弱點並進行了防禦。換句話說,測試提高了代碼審查的質量,就像它們提高了代碼的質量一樣。

這種質量的提高是由於「單元」相對範圍較窄,使它們更容易閱讀和理解。良好的代碼審查實踐包括確保適當的單元測試覆蓋了成功和失敗的情況。

集成/系統測試

有人主張,整合測試或系統測試應該優先於單元測試。確實,對於大型、複雜的專案來說,整合和系統測試至關重要,且自動化程度越高越好。然而,正如所討論的兩個特定 bug 所示,有時最嚴重的 bug 在系統層面可能最難檢測到,在單元層面卻可能最容易測試到。單元測試應該是防止 bug 的第一道防線,因為開發人員在編寫每一行程式碼並提交變更進行審查時,可以執行邊緣案例和錯誤處理案例,在其他測試層面幾乎不可行。

簡而言之:你不應該在整合或系統層面捕捉到本可以在單元層面捕捉到的 bug。當 bug 确實滑过時,寫一個在最低可能層面重現它並防止回归的測試。在更高層面寫一個相等的測試只會使測試比需要的更複雜。

整合和系統測試可能比單元測試慢好幾個數量級。它們通常會與其他模塊或系統互動,其中一些是應用程式外部的。這會增加測試的時間。測試越慢,給開發人員的即時價值就越少,並且引入 bug 的機會就越多。測試越慢,給開發人員的即時價值就越少。當你在開發程式碼時,你希望測試能夠以足夠快的速度運行,以便你在輸入完錯誤後幾乎立即就能得知它。當你還記得你正在處理的問題時,這是打擊相關 bug 的時刻。現在就改正它,然後自信地繼續。

換句話說,整合測試和系統測試是必要的。單元測試單獨不能確保高層次組件之間的整合不會失敗,或者整個系統可以成功地從頭到尾執行完整的操作。事實上,根據產品的性質,整合級別的測試可能更容易編寫,運行速度幾乎一樣快,並且可靠且易於維護,能夠提供顯著價值。如果做得好,整合測試可以使在組件之間進行大規模重構成為可能,通常只需要對現有的整合測試進行很少或幾乎沒有更改,而某些單元測試可能需要在此過程中進行調整或重寫。

無論測試是否根據定義是純粹的“單元”測試,還是運行速度快的受控整合測試,都可以檢測到可能會在其他工具和測試層中滑落的潛在破壞性低級編程錯誤。希望測試不同大小的平衡是可取的;任何特定大小的測試都缺失都是在招惹麻煩。

單元測試實際上可以使整合測試和系統測試變得更容易和更有效。通過單元測試改進的代碼質量可以導致系統更好地以一組有意義的組件和設計良好的接口的方式組成。這為高層次測試提供了更好的基礎,當這些測試發現問題時,也更容易進行調試。如果您發現您的設計迫使您進行系統測試而不是單元測試,這肯定表明您的設計需要改變。

文檔

最終,每個價值系統都需要文件化。這可能從應用程式編程界面(API)的低階技術文檔到系統行為的高階文檔都有所涵蓋。這些文件有效地定義了要求,即代碼或系統旨在實現的合同。如果系統的某個部分難以文檔化,這通常是設計存在問題的一個警告信號。

單元測試和其他自動化測試提供了一種可執行文檔的形式,但對於不直接負責代碼的程序員來說,可能不是最易於訪問的文檔。然而,良好的單元測試和良好的文檔可以共同確保高質量的產品:良好的文檔定義了代碼或系統的期望;良好的單元測試或其他自動化測試驗證了這些期望。對於有助於一致設計的良好編寫的測試也有助於更準確和一致的文檔。

模糊測試

Fuzz testing 是另一種值得一提的測試風格;Codenomicon 在對自己的產品進行模糊測試時發現了 Heartbleed。它涉及運行一個程序自動生成另一個程序的輸入,以尋找錯誤。Heartbleed 的發現,如果不是其他,就是對其有效性的一個明確支持。

這是另一種互補的保護措施。然而,模糊測試並不是單元測試的替代品。模糊測試可以揭示現有測試未覆蓋到的情況,但單元測試可以在執行模糊測試之前捕捉到許多錯誤。如果模糊測試發現錯誤,應該成為標準做法用自動化測試在適當的範圍內重現錯誤以防止回歸。

持續集成

持續整合 是始終更新、構建和測試代碼庫主線以確保其始終處於可發行狀態的過程。每次代碼更改時都重新構建項目的 CI 系統有助於檢測代碼更改導致編譯失敗的情況。然而,僅將其用於該目的是對其真正力量的嚴重忽視:即檢測代碼更改導致整體構建失敗(包括測試失敗)的能力。一個沒有運行測試的持續整合系統就像一輛只開到菜市場再開回來的重型皮卡一樣。是的,它有助於提供重要功能,但你可以做的事情遠遠不止這些!實際上,可以認為除非您的構建是自測的,否則它實際上不是一個持續整合系統。

持續整合系統可能需要一些設置和維護的工作,但通常是非常值得的。 Jenkins 是一個流行的用 Java 編寫的開源 CI 系統。 Buildbot 是另一個開源 CI 框架,由 Chromium、WebKit 和其他項目使用。 Thoughtworks's Go continuous delivery system(不要與 Google 的 Go 編程語言混淆)是另一個開源系統,可以管理非常複雜的依賴關係管道,不僅集成,而且持續部署產品。

Google 的測試自動化平台,在本文的後面部分有詳細描述,它是一個極其強大的系統,改變了 Google 在大規模開發方面的許多規則。它依賴於大規模分佈式建構和測試基礎設施,以便為來自公司各處提交到中央儲存庫的每一個變更提供幾分鐘內的結果。 Solano CI 是一個專有的分佈式 CI 服務,適用於使用其中一種支援的語言編寫的專案。Solano CI

崩潰和核心轉儲

將斷言插入代碼是程序員的常見做法,這將導致程式崩潰,並根據語言和操作環境的不同,生成堆棧跟蹤或內存映像(在 UNIX 中稱為“核心轉儲”),而不是冒險導致數據損壞、進程失控或其他危險。這是一種良好的防禦性做法,無論程序的代碼是否進行了單元測試。但是,終止進程應該是最後的選擇;在將代碼集成、手動測試、預先發布到分段區域或者可能部署到生產環境後,嘗試診斷基本編碼錯誤的成本要高得多,而不是通過編寫單元測試來事先捕捉這些錯誤。如果其他進程以相同的方式阻塞相同的輸入,則直到問題解決,服務處理其他流量的能力可能會降低,可能導致業務、收入和信任的損失。

發布工程

發行工程是追蹤特定軟體發布的所有功能、錯誤修復和其他輸入的過程,並以所有工件都標記和存檔的方式進行,最終產品在命令下可以重現。對於基於雲的軟體,這還涉及對生產進行受控部署,並密切關注生產監控信號,指示成功或需要回滾。發行工程成為防止錯誤進入生產並傳遞給用戶的最後一道防線。發行工程師是對測試普遍持有信仰的人之一,特別是對自動化測試持有信仰,因為通過自動化測試的通過是他們依賴的最重要的信號之一,以確定是否繼續進行發布。這是由於

  • 可重複性:自動化測試本質上比手動測試更可重複
  • 可審計性:自動化測試產生比手動測試更多的可審計記錄
  • 與發布自動化的集成:自動化測試僅是整體發布自動化故事的一部分,但手動測試會中斷流程

網站可靠性工程和生產監控

一旦服務在雲中運行,它就成為 Web 運營的範疇,或者根據 Google 的術語稱為 Site Reliability Engineering。如果團隊缺乏專門的 SRE,至少一名開發人員必須負責此任務。除了監控運行進程或進程組的外部可觀察行為的工具外,SRE 還極其依賴於運行進程導出的監控變量以及基於這些變量的計算。您可以將導出的變量視為進程健康狀況的“生命跡象”。

監控和 SRE 支援是必要且關鍵的,但不應該是發現錯誤的主要手段。是的,在任何足夠複雜的系統中,錯誤偶爾會滑入其中;但減少消耗 SRE 時間和精力的生產問題的數量,以及減少解決每個問題所需的時間是 SRE 的利益所在。這也符合開發人員的利益;但考慮到 SRE 對底層代碼的缺乏依附感(一般而言),以及對代碼是否如開發人員所認為的那樣正確的信心缺乏,他們對導致更多緊急生產工作的任何藉口都不太寬容,尤其是任何需要他們在凌晨3點或週末和假期處理的工作。

好消息是標準監控鉤子可以成為有用的測試工具。與試圖通過特殊界面或模擬對象驗證內部行為不同,檢查程序導出的計數器或其他監控變量可以提供您可以輕鬆在任何大小的自動化測試中驗證的輸出。

成本

值得記住的是,所有這些工具和實踐,包括單元測試,都會產生啟動和維護成本。對於沒有錢、沒有硬件、沒有正確流程文檔、並且通常在做其他工作的開源項目的貢獻者來說,這種成本尤其嚴重。建立持續構建往往是一項重大努力,除非您有一個開發人員支持團隊,就像 Google 一樣。所有這些建議都應該在這種情況下加以考慮,這強烈支持將組織內的許多構建/測試/QA功能集中起來。即便如此,不做任何事情的成本,在長遠來看,將大於採用單元測試和您可以應用的每一個其他工具以確保高代碼質量並防止缺陷的成本。如果您的產品對其用戶群的健康至關重要,那麼您承擔不起不進行這些工作。

健康早餐的一部分

還有更多值得討論的工具和實踐:防御性編程/契約設計風格;錯誤報告和用戶反饋機制;日誌記錄及其在錯誤檢測和診斷中的作用;自動堆棧跟踪匯總和分析工具。希望這些工具和實踐,以及勤奮的單元測試實踐,能夠顯示出寫代碼時或以後持續改進代碼質量的巨大差異。每一項工具都值得考慮,但我希望我已經令人信服地說明了,單元測試應該是首先採用的。它需要知識和經驗才能做好,但不一定需要採用任何其他工具、任何特定的編程語言或任何其他實踐,才能開始收穫其好處。此外,可以逐步將其添加到現有代碼中,以逐步改進代碼質量並逐步減少缺陷的發生。

正如我在本文中提到的,我之所以知道這一點,是因為我親身經歷過。決定離開 Google 最難的部分之一是,我很可能再也不會有像那樣的開發環境了。此外,我離開時,我放棄了一項讓我感到無比自豪的成就:我和我的夥伴們幫助推動了單元測試在一個開發文化中的普及,而該文化在很大程度上對此一無所知、漠不關心或持敵意。在接下來的部分,我想分享一些我們努力的細節,以及 Google 開發環境中其他一些促進大規模高質量代碼的因素。

谷歌的後補測試文化,或:一再感到似曾相識

把「goto fail」和 Heartbleed 這些 bug 拿來作為教學示例的最大原因,除了它們的高知名度外,還因為檢測和防止這類 bug 已經是解決的問題。當我加入 Google 時,開發文化在很大程度上不愛單元測試。我和其他人在 Google 的測試小組中所做的工作有助於讓編寫測試成為常態,而非例外。以下是測試小組如何在一個大型、不斷增長且成功的公司中培養了一個強大的單元測試文化的簡要描述,而在這個公司中,大多數開發者對單元測試要么一無所知,要么持敵意,聲稱「我的代碼太難測試了」或「我沒有時間測試」。

我還將提及我在 Google 工作期間的開發環境的其他一些組件,以便更全面地了解 Google 是如何在其大規模和功能開發速度的情況下保持高水準的代碼質量的。這些信息可能有些過時,但我相信基於我的記憶的整體圖景仍可能會有所幫助。本描述並不是為了規定一個保證成功的過程,而是為了給其他個人和團隊提供啟發,他們希望在自己的組織中進行類似的變革。

要了解更完整的測試小組活動和推動一切發生的人員陣容,請訪問我博客上的測試小組標籤頁面

阻力

你可能會認為 Google 輕易就能採納單元測試文化,因為 Google 是神秘的 Google,擁有無盡的資源和人才。相信我,「輕鬆」並不是我會用來形容我們的努力的詞語。事實上,大量的資源和人才可能會成為障礙,因為它們往往會強化一切都在盡可能順利進行的觀念,從而讓問題在巨大成功的長陰下滋生。Google 之所以能夠改變其開發文化,並不是因為它是 Google;相反,改變 Google 的開發文化是幫助其開發環境和軟件產品繼續擴展並符合期望的原因,儘管開發者和用戶的人數不斷增長。

在 Google 內部對單元測試的抵制主要是因為開發者在單元測試方面知識不足,試圖使用老工具編寫新代碼,而這些工具在 Google 不斷擴展的業務壓力下已經嚴重受限。為現有代碼添加測試似乎是不可想象的困難,鑑於現狀,為新代碼提供測試似乎是徒勞的。關心單元測試的人們做了艱苦的工作,說服其他 Google 員工,編寫單元測試不僅能夠確保他們當前編寫的代碼是正確的,而且在六個月後,當其他人(甚至是原開發者)需要更改代碼時,仍然是正確的。

測試小組提供了一個社群,供我們這些關心單元測試的人共同交流。測試小組及其盟友在多年的努力下成功地在 Google 內部傳播了測試知識,推動了新工具的開發和採用。這些工具讓 Google 開發人員有時間進行測試,這些共享知識使他們的程式碼隨著時間的推移更容易進行測試。測試小組的「測試認證」計劃參與者分享的指標和成功案例也幫助說服其他團隊嘗試進行單元測試/自動化測試。參與的團隊通常歸功於「測試認證」有助於改善他們最關心的生產力指標,例如在一段時間內提交的程式碼更改和/或功能相對於相同期間的錯誤、回滾和緊急發布的數量。

測試小組是什麼?

「測試小組」是一群 Google 開發人員,在他們的 20% 時間(Google 提供的時間,讓開發人員除了主要項目外,可以在 Google 相關的項目上工作)中共同努力解決在 Google 推廣單元測試採用所面臨的挑戰。作為一個全志願者組成的團體,沒有太多資金和直接權威,它依靠說服力和創新說服 Google 開發人員認識到單元測試的價值,並提供他們進行良好測試所需的工具和知識。測試小組成功地採用了非傳統的策略,實現了推動 Google 全面推行單元測試文化的宏偉戰略,其中許多策略在以下子節中描述。

這些與測試小組相關的努力代表了我們一些最好的想法,恰巧在恰當的時機提出。我們嘗試了很多其他未能成功的想法;重要的是我們堅持不懈。我們繼續嘗試新的想法,從中學習,直到我們找到一套在當時的 Google 文化背景下特別有效的方法。一些相同的方法可能適用於其他團隊和公司;但也可能不適用。不過,我希望它們能成為在其他開發組織中能起到靈感作用的想法來源。

測試小組只是一組「跨小組」的其中一個,旨在通過幫助解決影響所有團隊的問題來改善 Google 的日常開發生活質量和生產力。這些小組通常通過提供基層反饋、倡議和其他形式的支持來補充官方的專門團隊的努力。例如,測試小組與測試技術和構建工具團隊、EngEDU 內部培訓組織以及整個工程效率部門有著密切的關係(在下文的「測試認證」子節中討論)。其他由熱情志願者組成的旨在改善開發質量和體驗的努力小組還包括:文檔小組;導師小組;招聘小組;守護者 Google 風格指南和可讀性傳統的可讀性小組;以及 Fixit 小組,該小組負責維護「修復」的傳統,這些「修復」是針對廣泛問題或推出新工具的專注於整個公司的努力。

馬桶上的測試

廁所上的測試 (TotT) 是測試小組的努力和成就中最為突出的一部分。該系列於 2006 年開始,每週仍然發佈新節目。每一集都是關於特定測試技術、工具或相關問題的簡要概述,分發到全球 Google 開發辦公室的洗手間。底部的「廣告」近似於 Google 搜尋結果廣告,提供了與該主題相關的更多信息的鏈接。每一集都是由志願者撰寫、審核、編輯和分發的。多年來,它在教育 Google 開發人員有關單元測試的好處和正確應用方面取得了巨大成效,並且通過使用進一步豐富了測試小組的努力的標準概念開始了公司範圍的對話。這些對話有助於防止出現聽證會效應,使非測試小組成員能夠貢獻他們的想法、論點和經驗。

為什麼要在洗手間張貼傳單,而不是在其他公共場所呢?為什麼不發送電子郵件通訊?這個想法是在測試小組的頭腦風暴會議上提出的;在那裡,沒有任何想法被視為是禁忌的。我們嘗試了許多傳統方法——內部培訓、來賓演講、發放書籍——並正在尋找一些新的角度來吸引人們的注意。這個特定想法的大膽性以及朗朗上口的名字正好迎合了小組的心意;對我們來說,這是行得通的。幸運的是,一旦我們開始張貼傳單,這個想法就奏效了。儘管最初有些人提出了反對意見(預料之中),但這種媒介的價值變得明顯,它傳達的信息——測試是一項易於掌握的技能,有助於增量學習和改進——隨著系列的持續而越來越深入人心。

測試認證

測試認證 是由測試小組設計的一項計劃,旨在為開發團隊提供一條明確的路徑,以改進單元測試實踐和代碼質量。最初,它包含三個“級別”,由隊伍可以採納作為季度目標並隨時間實現的離散步驟組成。(據我所知,它最終定義了五個級別。)第一級別專注於建立工具的使用和基準測量(例如持續集成服務器代碼覆蓋率,標識常常失敗和不穩定測試); 第二級別專注於採納和執行測試政策,要求對所有代碼更改和新代碼進行測試,並設定易於達到的測試覆蓋率目標; 第三級別專注於引導團隊實現高水平的測試覆蓋率和相應的生產力收益。

使每個Google開發團隊達到測試認證三級別成為所有與測試小組相關努力的最終目標。工程效率部門被說服認為測試認證可以為測試工程師和測試軟件工程師提供一個工具,以更好地與開發團隊溝通並更好地利用每個人的時間,並全力支持該計劃。在2010年推出測試自動化平台持續集成系統後,目標已有效實現,此後幾乎每個Google開發團隊都在測試認證三級別運行。

測試僱傭兵

測試僱傭兵是一個由軟件開發人員組成的團隊,全職致力於幫助Google開發團隊實現測試認證狀態。測試小組提出了該團隊的概念,並在2006年底至2009年初存在。理想情況下,每個團隊至少會分配兩名僱傭兵,為期三個月,在此期間,僱傭兵將了解產品、代碼和團隊動態,然後嘗試引入測試認證所設定的改進的單元測試實踐。從團隊到團隊的成功在生產力影響方面有所不同,並且難以測量,但測試僱傭兵的專注、全職努力極大地增強了所有其他基於志願者的測試小組努力。測試僱傭兵的經驗影響了許多測試認證討論和廁所測試集的測試,以及激發了證明在整個文化中驅動單元測試採用的關鍵工具開發。

測試修復

「修復大會」是為了讓 Google 整個開發社群專注於一直被忽視但重要的議題而組織的短期活動。它們也很有用於推出新工具,幫助解決開發者可能遇到的任何問題。修復大會通常持續一天到一週不等,是一些小組和其他團隊實現重大變革的最有效技術之一,這要歸功於每個事件所需的計劃和參與的關鍵質量。

測試小組在2006年8月和2007年3月組織了測試修復大會,專注於修復失效的測試和為未覆蓋的代碼編寫新測試,以及2008年1月的「Revolution Fixit」,該活動引入了來自建置工具小組的強大新工具,大大提高了開發和測試速度。2008年夏季持續數個月的測試認證挑戰吸引了許多新項目,並幫助其他項目達到更高的測試認證級別。建置工具小組於2009年10月舉辦的 Forgeability 修復大會幾乎完成了在雲端建置和執行幾乎每個建置目標和測試,完美地為整個測試修復/測試小組進程做好了鋪墊:2010年3月的 TAP 修復大會,在 Google 全面引入了測試自動化平台。

這些以目標為導向的活動用來強調測試小組啟動的其他長期努力,將整體單元測試採用任務推向新的水平。每個新的修復大會都利用了以前修復大會的經驗和動力。在衛生間進行測試被證明是一個無價的工具,它讓 Google 開發社群提前得知這些事件並為它們做好準備。

運行修復大會不需要執行主管的許可或指令。一旦一個小組決定運行一個修復大會,他們就會執行。 (然而,工程副總裁通常願意發送一份準備好的公告,鼓勵參與。)修復小組存在的目的是協助協調各個修復小組之間的合適日期(例如,避免在九月初的 Burning Man 週期間進行任何修復,因為 Mountain View 的一半人都會在玩火),並且不會耗盡彼此的努力,導致一種被稱為「修復疲勞」的狀況。修復小組還提供工具、文檔、歷史和建議,以便新的修復大會可以從過去的修復大會的經驗中受益。

風格指南/編碼標準

所有 Google 開發者必須在他們定期使用的每種語言中「賺取可讀性」。「賺取可讀性」是一個引導性的過程,開發者內化了大部分語言特定的風格指南。儘管「賺取可讀性」涉及編寫代碼,但最終目的是確保你編寫的代碼按照公司范圍內的慣例保持「可讀性」。低調的可讀性小組是維護這個無價過程的全志願團隊。源代碼控制機制使在長期內使用一種語言生產代碼變得極其繁瑣,如果沒有獲得可讀性狀態。這確保了風格指南的相關性和廣泛執行。

作為避免錯誤的風格指南示例(與避免在大括號、空格和命名上進行無謂爭論相對),當前的 Google C++ 風格指南堅持認為,如果呼叫方要承擔擁有權,則必須通過 std::unique_ptr 將堆分配的函數參數傳遞給它們,並且如果調用方要保留擁有權,則必須通過 const reference 進行傳遞。這是因為在 C++ 中內存不會自動管理,訓練開發人員通過視覺識別不良的內存管理是值得的,與等待靜態和動態分析工具捕捉此類錯誤相比,這是值得的(Google 也運行了這樣的工具,但它們很昂貴且提供了較長的反饋周期)。

幾乎所有 Google 的源代碼存儲庫都對所有開發人員可供瀏覽和檢出到個人工作副本。由於 Google 的風格指南適用於特定語言中的所有項目,並且許多命名慣例在語言指南中都相似,Google 的開發人員可以輕鬆地掃描以前從未見過的代碼庫中的代碼部分,並相對迅速地理解它。這使得 Google 的開發人員可以輕鬆地為不同的項目做出貢獻,將重複的代碼提取到所有項目都可重用的常見庫中,識別並可能修補其他項目中的錯誤,甚至可以在不適應新編碼風格的摩擦中切換項目。

代碼審查

Google 從成立之初就實施了代碼審查的做法:沒有代碼會提交到源代碼控制,直到它被除作者之外的其他人明確審批為止。存在控制以確保項目的“所有者”被納入任何相關審查中。審查代碼與編寫代碼一樣是程序員日常職責的一部分—有時甚至更重要—並且共同的風格指南消除了審查過程中的大量摩擦,使審查人員能夠迅速標記風格看起來錯誤的潛在問題,並盡可能專注於變更本身的含義。內部工具幫助開發人員管理他們的待審查和已審查代碼變更的隊列,並為每個開發人員提供對每個代碼變更的狀態及討論的可見性。

多虧了測試認證二級的要求,幾乎每個團隊都有一個正式的、書面的開發政策,即每次代碼變更都應該伴隨著測試(除了在已經覆蓋的代碼中不改變現有行為的純重構)。最終,構建工具和測試技術團隊將測試結果(或其缺乏)直接集成到代碼審查工具中。審查人員可以看到作者是否已經運行了任何測試並確保它們已通過,尤其是如果根據以前的審查意見進行了更改。

通用基礎設施,隱藏低級細節

鑑於大型的共享源代碼存儲庫和統一的語言風格,Google 鼓勵開發常見庫以隱藏在所有 Google 項目中重複使用的低級細節。最常見的例子是遠程過程調用(RPC)的基礎設施和協議緩沖,一種在 RPC 系統內部以及許多其他需要層次結構化、通常是序列化的數據結構所使用的數據描述語言。如果 Google 中的任何人試圖直接定義序列化結構並操縱內存緩沖區(例如包含 Heartbleed 漏洞代碼中的緩沖區操作),代碼審查人員可能會首先問的是:“為什麼不使用協議緩沖?”

所有這些共通基礎設施都受到廣泛的單元測試,

測試自動化平台連續集成服務

當測試小組於2005年首次成立時,現有的集中式測試服務,稱為單元測試框架,無法滿足需求。它使用一組專用機器來建置和執行公司中的每個測試並將結果存儲在數據庫中。然而,由於系統負載增加,反饋周期不斷延長,減弱了其價值。

作為回應,兩名廣告開發人員開發了自己的單機專案特定的持續整合框架,被稱為“Chris/Jay Continuous Build”。該框架在 Google 中廣泛傳播,部分原因是因為它被包含為測試認證一級的要求。它為 Google 專案提供了一個相對靈活的持續整合服務器,並且多年來支持了測試小組的測試認證任務,但每個使用它的團隊都需要進行相當程度的維護。

作為2008年1月Revolution Fixit的結果,測試自動化平台(TAP)成為了 Google 的集中式持續整合系統。在2010年3月的TAP Fixit期間在 Google 全球推出,TAP是建立在 Google 自家工具鏈的基礎上,利用雲基礎設施來大規模平行化建置操作和測試執行。TAP執行了公司整個代碼庫中受到每次代碼更改影響的每個測試,以及只有受到特定更改影響的測試,而且只需幾分鐘。(這個時間軸現在可能已經改變,因為自從我離開以來,Google 一直在持續增長。)一個 TAP 构建由一個簡短的 Web 表單配置,並且任何專案都可以有多個構建。TAP 的數據收集組件 Sponge 收集了每個構建嘗試和測試運行的結果,無論是由自動構建還是個別開發人員運行的,記錄了其構建命令和完整執行環境,並將信息存檔以供以後檢查。TAP UI 提供了易於查看公司中每個項目受影響的每個更改的可見性。

TAP代表了測試小組努力的最終成果。由測試技術團隊與構建工具團隊密切合作開發,TAP在經過多年的穩步努力後,終於將巨石推上山頂。在我離開 Google 時,幾乎每個團隊都至少有一個 TAP 構建,大多數構建故障在大多數構建警察注意到故障之前已經被回滾或修復。

TAP 擴展到 11

如果上一節還沒有被理解:集中管理的持續整合基礎架構。建置專案一頁式、一鍵式設定。公司中的每一項變更都在幾分鐘內(至少在我在那裡的時候是這樣)通過分散式建置和雲端執行進行整合、建置和測試。每一個結果都被儲存並對每一位開發者可見。大多數的故障在大多數受影響的專案甚至察覺到之前就已經修復了。天堂、涅槃、華拉、巨石陣——你想怎麼稱呼它,TAP就是它。

建立監視球

我在 Google 的第一個編碼專案是寫一個腳本,根據 Chris/Jay 的持續建置的通過/失敗狀態,改變一個發光球體的顏色和脈動。隨著時間的推移,這個腳本的範圍會擴展到處理在不同的持續整合系統上運行的一系列建置專案(最終包括 TAP)並控制幾個不同的硬體球體裝置,包括以紐約為靈感的Lorberty像(是的,火炬會以不同的顏色發光)。最終,瀏覽器插件將作為對個人團隊成員的更可見的提醒,無論他們是否在辦公桌前或使用筆記型電腦登錄,但在共享的團隊空間中的實體球體從未完全過時。

球體的重點有三個:首先,它們很有趣。對於那些希望以立即可感知的方式促進測試文化的人來說,組建或擴展球體項目是一種有趣的方式。這有助於招募人們參與測試小組項目,產生一種能量和進步的感覺,提高士氣。其次,測試小組將它們用作「獎勵」,頒發給參加測試認證計劃的團隊,這是谷歌傳統上的做法,通過獎勵他們以獲得時髦的禮品來說服人們採取行動。我們遵循谷歌的本質,而不是與之相抗衡。在沒有資金和權威的情況下,測試小組必須充分利用現有資源和文化力量來實現變革。事實上,我會認為這些限制迫使我們產生了比任何金錢或權威都更具持久力的創造性解決方案。

最後,實體球體是高度可見的信息輻射器,是全面的共同儀表板的次佳替代品。可以說,即使在擁有全面儀表板的團隊中,球體仍可能有一席之地,因為它鼓勵了一種俏皮的「恥辱」文化,團隊成員會因為球體的不快而個人關心,並在建構失敗時互相負責。

Noogler 新人

與EngEDU合作,谷歌內部的培訓組織,測試小組製作了一個入門單元測試的講座和實驗室。這有助於確保每位新加入谷歌的開發人員至少了解可用的工具和框架、單元測試背後的原理,以及一些基本的單元測試原則和技術。通常,在測試小組的成員進行的一小時講座後,Noogler會參加另一個測試小組成員監考的實驗室,以立即動手體驗他們剛剛學到的知識。測試小組幫助製作和維護這個實驗室使用的內部材料。

在《ToT》(Testing on the Toilet)推出後,隨著公司的發展和更多辦公空間的獲得,Noogler成為了改進分發的主要機制。我們以贈送書籍或T恤的承諾結束了單元測試講座,對於那些願意在他們的辦公樓張貼當週《ToT》的勇敢Noogler。我們稱他們為「Noogler Army」。這是另一種讓人們參與到單元測試文化中來、玩得開心並感到歸屬感和早期貢獻的方式。

以及更多……

Google 配備了其他工具、流程和測試階段,以確保代碼質量最高,並避免災難性、可預防的缺陷。它們並沒有捕捉到每個缺陷,但許多滑過的缺陷相對較小,易於迅速找出並修復,而且不必擔心負面影響。更具挑戰性的缺陷通常也可以以更大的信心和速度來解決。自動化測試,包括高水準的單元測試覆蓋率,對於這種無懼的環境至關重要,它使高生產力得以實現,儘管開發操作和用戶基數龐大。

然而,我不想讓你以為 Google 是很棒的,做得無所不用其極,而你自己的團隊或公司則無望了。我提供這個描述是為了促進思想,而不是提醒你的環境與理想有多遠。相信我,我離開的這個 Google 環境與我最初加入的 Google 環境形成鮮明對比,而我和我的測試小組夥伴們資金短缺,人手嚴重不足。我們不得不從小處開始,多年來不懈努力,以實現我們致力於實現的文化改變。

重點是,儘管機會不利於我們,但我們最終成功了。總結本文,我想明確說明我從 Google 經驗中總結出的幾個一般原則,這些原則可能更清晰地揭示了如何隨著時間的推移在你自己的團隊或整個公司中實現類似變革。

如何改變文化

你可能會認為 "goto fail" 和 Heartbleed 可以通過單元測試來預防。你可能會認為開源代碼應該增加對單元測試的需求,而不是減少它。你可能會相信單元測試除了預防缺陷之外,還能帶來一系列好處,並且它是值得成本的。在閱讀本文的概念驗證測試並開始測試你自己的一些代碼之後,你可能會對它產生興趣。你可能會相信單元測試可以改善現有工具和實踐的應用,你可能會受到 Google 在公司整體上推動單元測試的榜樣的啟發。

現在你已經準備好在你自己的項目、團隊或公司中進行改變了...但你可能不知道如何開始。在這裡,我將提供一些個人見解,這些見解可能有助於引導你。這不是要一字不差地遵循的處方,也不能保證結果。但我希望它們能夠促使你產生一些自己的見解,這些見解可能有助於推動在你的環境中採用單元測試。

成為你想看到的改變

(引用自馬克杜·甘地)

不論你是否察覺到,你已經開始了。你已經閱讀了這篇文章並內化了它的論點。你已經為自己內化了單元測試的經驗。這為你建立了一個基礎,一個從中可以進行任何關於軟體開發主題討論的觀點。現在沒有任何阻礙你開始實踐了,即使沒有人跟隨你。暫時不要直接試圖改變任何人的想法;只需展示如何做,通過為你自己的代碼編寫測試。尋找博客、雜誌、書籍和研討會來磨練你的技能,例如下面“進一步閱讀”部分中的內容。閱讀馬丁網站上的所有內容。參加一個Meetup,比如波士頓、紐約、舊金山和費城的AutoTest Meetups,或者自己開始一個。以身作則,堅定信念。

從現有代碼開始小規模開始

如“goto fail”和Heartbleed的概念證明、Google網頁伺服器的故事以及整個Google故事所示,你可以立即開始改善現有的代碼。你的代碼庫唯一會改善的方法就是與之共事,沒有任何討論或爭論能像實際編寫測試一樣有效。通過樹立榜樣,提供給其他人遵循的模式,你正在證明這些想法即使在你團隊的代碼中也可以運作——而工作中的代碼就是最好的論點。

從現有的代碼庫中選擇一小部分,為其編寫一個測試。如果必要,重構代碼;提取出能夠作為良好、獨立單元測試的函數和類。當在現有代碼中添加新功能時,確保它是一個經過良好測試的單元的一部分,必要時使用新的單元重構代碼。

如果可能,添加一個單元測試框架;否則,研究本文提供的示例,學習如何在沒有單元測試框架的情況下應對。逐步解決問題;隨著時間的推移,你將驚訝於你自己所能夠達成的事情。

小/中/大測試金字塔

單元測試並不是代碼或產品質量的萬能解,你不應該承諾它是如此。測試小組率先提出了「小/中/大」測試大小架構的概念;Mike Cohn的「測試金字塔」與此極為相似。確保每個人都清楚單元測試所扮演的基本角色,但不要過度吹噓。

建立持續集成

無論如何,即使你必須乞求、借用或竊取,也要建立一個持續整合環境。如果必要,自己動手使用 shell 腳本和 cron 任務來創建,即使它運行在你自己的工作站上。即使一開始沒有運行測試,能夠確保代碼可以構建(對於編譯語言)並且程序可以隨時啟動是傳播單元測試文化的關鍵先決條件;如果代碼無法編譯,單元測試就幾乎沒有用處。

如果你的團隊還沒有養成始終確保代碼處於可編譯狀態的習慣,那可能是你需要在推動採用單元測試之前首先解決的第一場戰役。如果每個人都在完全獨立的分支上進行開發,並且集成是在事後很長一段時間才進行的,那麼你可以暗中進行集成工作。建立自己的 git 存儲庫來從這些不同的分支中拉取並在它們之間進行集成。當人們看到你的工作成果以及你幫助避免的許多麻煩時,你將獲得會議的信譽。

最大化可見性

確保其他人能夠看到構建是否失敗。曾經對持續構建和測試漠不關心或敵對的人和經理們通過在他們辦公桌易於看到的地方設置監控設備而改變了他們的想法。這有效是因為當構建失敗時,人們自然會開始提問(「為什麼那個東西又變成紅色了?」),隨著時間的推移,這可能會對每個人的態度產生重大影響。關心我們可以看到的問題是人類的本性,因此讓人們能夠看到問題的存在非常重要。

監控設備可以是插件形式存在於個人的瀏覽器中、位於中央的發光球體、顯示構建儀表板的大型顯示器屏幕、特殊連線的交通燈等等。它應該明顯到人們必須刻意去忽視構建的當前狀態。

可見性輔助品也可以增加樂趣。團隊可以在展示他們的測試狀態時富有想像力且趣味競爭。Google 的一個團隊曾經有一隻拍打翅膀的企鵝,當他們的構建出現問題時,它會喧囂地活過來。當然,所有周圍的團隊都必須努力找到同樣好的東西。這一切有助於傳達信息。

共犯

最終,您將不得不與一些志同道合的夥伴合作,這些人無需說服即可理解。您們將彼此挑戰和加強對方的想法,在面對阻力時,將為對方提供道義支持。通過彼此交流來發展您的論點、方法、成語等。對這些想法要比任何潛在的評論者更加批評,但要彼此禮貌和尊重。使彼此變得更好,最終可能使您的團隊或公司變得更好。

在試圖說服一群人(任何事情)時,從那些已經最接近贊同您觀點的人開始通常是最容易的。一旦您讓另一個人看到您的方式,您就不再是孤獨者,也不再是那個沒人相信的瘋狂人,現在有兩個人在說服。一旦您得到第三個,然後是第四個,您就有了一些動力。

另一種微妙而有效的讓其他人參與其中的方式是尋求建議。如果您團隊中的某個人對測試持懷疑態度,甚至只是不熟悉,請該人檢閱您的代碼和測試。詢問是否還有其他您沒有想到的測試。大多數程序員都樂於提供意見,這是一種在不強迫他們的情況下將其納入測試的方式。隨著時間的推移,他們可能會自願成為單元測試的倡導者。

教育

找到一種方式將知識傳遞給您的團隊。可以是每周一次的午餐聚會,也可以是每周在浴室張貼傳單的瘋狂方式。邀請人們向您的團隊發表演講,或者組織一次團隊外出參加演講或聚會。建立一個內部郵件列表,分享和討論想法和工具。

委派、委派、委派!

矛盾的是,你需要直接做的事情越少,你就能讓更多事情發生。如果你能確立一個願景和方向,你會找到樂意承擔特定角色並積極參與的志願者,這會讓他們在你正在建立的社區中感到歸屬感和價值,同時讓你專注於更大的圖景。

在進行了幾次Fixits之後,我意識到與其把每一個責任都留給自己,創建一個明確的角色列表會更加有效。從那時起,提前呈現角色列表就像魔法一樣,能夠快速啟動一個基層組織。你現在可以考慮為你的團隊或組織擔任的一些角色(有些名稱可能故意幽默,以保持輕松愉快的氛圍)

  • 歷史學家:在一個中央可訪問的存儲庫(如維基或團隊博客)中記錄、總結和存檔重要問題或活動及其產物
  • 資訊部長:個人邀請人們製作演講、博客文章、文章等;該人可以領導演講者、作者和志願編輯的子社區(如Testing on the Toilet),甚至可以建立一個特定於社區的知識庫(例如使用維基)
  • 宣傳部長:通過各種媒體宣布團隊活動,例如電子郵件、傳單、顯眼的牆壁投影、提供給高級管理人員、執行官或其他代表的腳本等
  • 溝通部長:監控團隊可用的溝通渠道的健康狀況,提出並實施改進建議(與資訊部長一起);也許維護一個聯繫信息列表和產物存檔(與歷史學家一起)
  • 文字工匠:專門負責新產物的維護和組織,例如確保文章被標記,也許嘗試使用CSS樣式,對產物進行SEO工作,確保內容易於搜索引擎發現(如果產物是公開的)等
  • 排程員:跟蹤物流,例如誰在什麼時候發言,活動在哪裡舉行;維護適合的場地列表並尋找新場地等
  • Festmeister:對於活動,確保啤酒、比薩和所有贈品都妥善安排。
  • 心靈和靈魂:跟進演講者、作者或其他貢獻者和客人,並代表團隊以各種形式表達感謝:個人電子郵件、禮品券、小物件、小型派對等

這些只是我腦海中想到的一部分,但我希望你注意到一些事情:現在,你可能正在填補所有這些角色,無論你是否意識到。這對一個人來說是很多事情,既使你被困住,也錯失了將團隊發展成一個真正的社區的重要機會。

做海象

那麼在把一切都委派掉後,你還會有什麼角色呢?我自稱為“The Walrus”,因為我是一個愚蠢的披頭四迷,但這個角色的本質是“組織者”。你是那個眼光放在大局上的人,管理著一個專家團隊。你是那個設定方向和優先事項的人,有幸向你信任的重要責任人提供反饋,並幫助消除他們遇到的任何障礙,並且被人們帶來的能量和創造力不斷驚訝,他們的任務做出了你從未想到的令人難以置信的事情。

擁抱團隊合作的力量

堅持擔任其他角色只會阻礙您作為組織者蓬勃發展的能力,進而使社區無法發揮其全部潛力。因此,我鼓勵您列出您已為社區做的事情清單,將它們規範化為一系列角色,並積極與您認為最適合每個角色的個人互動。

有時我甚至會列出一份角色名稱清單,並在旁邊以粗體紅色字體寫上我的名字,並告訴每個人,企業的成功與我的名字旁邊仍以紅色標記的角色數量成反比。(唯一一個旁邊是綠色的是“海象”角色。)當面對這樣一份清單時,以及有了清晰的定義的角色後,他們將會多麼迅速地自願參與和行動。

也就是說,這些角色的人應該被鼓勵在不必將每個決定都經過您的許可下互動;角色有助於澄清責任,以便您不必參與每一個細節,人們可以在彼此之間解決許多事情。應該鼓勵每個人尋找好主意,發展好主意,並在彼此之間分享它們。當然,您應該保持耳朵貼地,但人們應該感覺到您在傾聽,而不是在監聽或試圖成為他們的老闆。期望他們愉快地給您帶來驚喜,他們也會這樣做。

讓自己變得不再必要

從第一天開始尋找您的接班人。任何企業都不應該如此脆弱,以至於在您離職後就崩潰。這也適用於生活中的一般情況。關於推廣單元測試文化,您不希望被貼上“測試人員”的標籤。您希望確保人們願意並且能夠在您需要或想要退位時接替您的位置。這就是如何建立傳奇。

運行一個修復程序

說到角色和修復問題,一個有趣且高效的方法是組織正在建立的社區來促進單元測試的是舉辦一個修復(fixit)活動。您可以從小團隊規模的修復活動開始,然後進行整個辦公室甚至公司范圍的活動。您需要做的就是確定一個清晰的目標(例如修復所有壞掉的代碼/測試,增加覆蓋率X%,採用一些新的不錯的工具),一組明確定義的志願者角色(如上所述),以及一個共享的電子表格來跟蹤需要完成的任務以及分配給誰處理每個任務。然後選擇一天,散佈消息,讓它發生!沒有什麼比集中團隊努力來提高士氣並解決棘手的、困擾已久的問題更有效的了。

舉個例子來說明為何修補工作不僅有趣且有效,而且可能對事業至關重要。考慮一個大型專案的情況,其中的部分程式碼甚至無法編譯。這完全破壞了建立持續整合的任何努力,並鼓勵人們開始選擇他們測試的專案分支,而不是在提交更改之前嘗試測試所有內容。換句話說,他們會執行<tool> test subprojectyBitA/**/* anotherBitB/ohAndThis/**/* partC/**/* ...而不是<tool> build **/*或按照其他更適當的選擇標準進行測試,例如測試大小。因此,慢性問題可能會變得更糟,而持續整合仍然無法實現。

這種情況非常適合修補工作:可以事先識別代碼中的問題區域並將其編譯成試算表;然後人們可以自願處理特定的問題,以避免重複努力。團隊可以在一個專門的短期內解決這些問題,使活動充滿節日氣氛和樂趣,並且代碼在一天結束時將處於有利於持續整合和測試的狀態——希望如此。即使一切不能立即修復,團隊也應該受到有形進步的鼓勵,以解決慢性問題並獲得動力最終完全解決問題。

拒絕權威

最好的解決方案不是自上而下強加的;最好的解決方案是那些個人獨立感知為提供價值並自願接受的解決方案。通常,這些解決方案將為人們提供一種賦予權力和目的感的感覺,而不是產生無助和無意義感的強制性解決方案。抵制通過發布命令或要求經理或執行官代表你發布命令來解決問題的誘惑;它們幾乎肯定會產生不良影響。人們討厭被告知如何做他們的工作;你知道程序員比大多數人更討厭這樣。

為此,強調你試圖實現的目標,而不是堅持精確達成目標的方式。提供清晰、具體的想法,但允許人們靈活地適應自己的情況。很少有程序員會爭辯說減少編譯錯誤、回滾或深夜緊急排除故障是件壞事。單元測試、持續整合和代碼審查是減輕壓力、增加對代碼的信心並減少診斷和修復問題所花費時間的方式,這些方式已經被許多不同的團隊在許多不同的情況下用於解決類似問題;但沒有兩套單元測試、持續構建或代碼審查實踐是完全相同的。

從管理或執行管理層獲得支持是另一回事。鼓勵和認可可以以建議的形式提高你的努力的可見度,只要它們被呈現為來自高層的建議而不是命令。

相反地,如果管理層被動,盡力前進;至少他們沒有阻礙或以任何方式威脅你。如果管理層積極地敵對或不屑一顧,那麼選擇變得更加困難。爭取改變值得冒著失去工作的風險嗎?離職值得嗎?這些風險你必須自行斟酌;但儘管被解雇或辭職令人不快,但這並不意味著不值得嘗試。不管你喜不喜歡,你都有選擇,你將不得不接受它。

同時,請記住,無論你承擔多少領導和責任,你都不是任何人的老闆。你正在幫助每個人做出最好的工作,就像他們自願幫助你完成你的工作一樣。專注於你們都期望的共同結果,而不是你的自我。

相信自己

改變開發文化不是一個公式化的事務;我們不能只是插入正確的數據然後得到所期望的結果。也許有一天,有人會進行正式的學術研究,收集普遍認可的度量標準,以合理地證明單元測試的有效性,但即使如此,也不能保證人們會聽從並改變他們的行為。比喻一下,已經有一個世紀的時間收集了關於醫生洗手以防止感染的科學研究,研究至今仍在進行。盡管有這一大量證據,一些醫生仍然需要提醒為了病人的利益而洗手。

儘管在行業歷史上目前缺乏正式的研究,但(良好的)單元測試經驗帶來的長期效益是可以觀察到的現象,已經多次重複出現。許多團隊已經收集了關於缺陷率和其他因素的數據,他們認為這些數據反映了質量和生產力的改善;這些想法在下面的“度量、強制、努力”小節中予以考慮。一旦你積累了單元測試的經驗—特別是如果你在另一個團隊或公司已經取得了成功—依賴“根據我的經驗”論點並沒有錯。你的經驗難道不是你的團隊或公司聘用你的一個重要原因嗎?顯然,他們必須在某種程度上重視這種經驗;不要低估它。儘管我建議“避免威權”,但你並不是在說“因為我這樣說”,而是在說“因為我已經這樣做了”,你可以指出你的具體努力和成就。這是一個很大的區別。

這是因為從經驗的立場進行論述並不是在敷衍塞責,只要你有實際經驗可以指出。例如:說"goto fail"和Heartbleed可以通過單元測試檢測到可能是敷衍塞責的;而提供工作的概念驗證程式碼則不是。說你的程式碼"太難測試"是敷衍塞責的;而說它不必保持這種狀態,因為谷歌網頁伺服器通過紀律性的單元測試實踐從糟糕到良好,就不是。

擁有良心和一定的謙遜,並注意不要過度宣揚單元測試或一般自動化測試的好處是很好的,但不要忘記相信自己。良心和謙遜在沒有信心的情況下是無用的—這最終是由經驗而不是數據來決定的。

保持專注

文化變革的最終目標,無論是驅動單元測試採用還是實現其他目標,都是以某種方式讓每個人的生活更美好。單元測試是一種手段,即減少缺陷,提高生產力和業務成功,所有這些希望都能轉化為開發人員和用戶的幸福。當我們深入討論技術、戰術或戰略時,很容易忽略這一點。討論這些事情很重要,但至少同樣重要的是保持單元測試與其長期利益之間的聯繫清晰可見。

為此,請確保與參與該努力的所有人以及您試圖影響的人保持聯繫,盡可能多地。無論這種形式多麼非正式—在喝咖啡或午餐時,在團隊會議和代碼審查中偶然發表評論等—都要養成習慣,抓住每個機會徵求反饋並糾正方向。培養這種習慣還將悄悄地影響人們更多地思考單元測試問題和機遇,隨著時間的推移慢慢打開每個人的思維。

培養指尖感

Fingerspitzengefühl是一個起源於軍事背景的術語,暗示著極端的情境意識。學會感知誰在處理什麼,何時何地。你不需要自己做所有事情,但你需要知道發生了什麼,這樣你才能更好地指導你的團隊、你的社區所進行的行動。鼓勵他人培養同樣的感覺,保持開放和對機遇的敏感,並在適當的時候抓住它們。

讓策略自然形成

開始時並不重要有一個宏偉的戰略,但嘗試建立社區,使其能夠在出現時機時很好地採取行動。事實上,我會認為一開始最好的策略是專注於建立社區,而不是擔心社區將來會實現什麼。當正確的策略出現時,而人們已經發展了他們的指尖感覺時,社區就會自然地知道該做什麼來實施它。(我的參考是,當然,是谷歌測試小組在大約幾年之內圍繞各種想法,然後出現了宏偉、統一的Test Certified戰略。)

儘管如此,尋找顯示前景的專注領域是有好處的。高能見度的軟體錯誤和開發專案失敗是很好的選擇。就像我在 "goto fail" 和 Heartbleed 中所做的那樣,利用它們盡可能具體和深入地說明單元測試的價值。案例接踵而來,逐漸提高社群對這些機會的敏感度。鼓勵社區成員撰寫博客文章和演講,甚至可能在本地 Meetup、各種公司和會議上進行演講。(畢竟,如果不是非常本地化、定期舉辦的會議,Meetup 是什麼?)尋找有潛力的演講者和作者,並輕輕地鼓勵他們做更多。

如果這個特定角度不吸引您,找到另一個適合的、有前途的線索,並繼續拉動它,看它能走多遠。

找一個導師

當您試圖將所有這些整合在一起時,擁有一位可以倚靠並徵求建議的導師總是非常非常有幫助的。向您認為可以為您提供可靠指導的任何人求助;他們可能沒興趣或沒時間,但詢問是便宜且無痛的。許多時候,人們會感到相當榮幸,並會欣然接受;或者如果他們不能,他們可能會推薦其他人並安排一次介紹。

無論如何,都要發出您的感受。不要試圖獨自應對所有這些。

與自然共事,而不是對抗它

要充分了解您的觀眾。不同的人對不同形式的說服有不同的反應;有些人根本不會被說服,必須被歷史拖著走。通過向他們提供最深刻的見解和經驗來影響人們,逐個影響他們。如果存在常見的藉口,則修復根本問題。如果是 "我沒有時間進行測試",那麼使工具更快;如果是 "我的代碼太難測試",那麼提供信息和示例,說明如何使測試更容易。自願與某人合作為一段棘手的代碼編寫測試。說服總是優於強制執行,但尋找其中的機會,並在有機會時把握。

一次一個團隊

雖然測試小組的目標是改變 Google 的整體情況,但該目標最終是逐個團隊地實現的。測試認證計劃提供了一個逐步改善單位測試實踐和覆蓋率的計劃,並提供導師幫助每個團隊回答問題並取得進展。試圖從上到下改變整個公司的做法很可能注定失敗,即使在短期內看起來取得成功,也不太可能持久。

衡量、強制執行、努力

任何開發方法的挑戰之一是衡量其有效性。懷疑者可能合情合理地要求你「證明」投資於測試是否值得成本。就像任何其他開發選擇(例如使用哪種語言、框架或IDE)一樣,有許多因素使得這很難進行清晰的衡量。相反,應該衡量團隊在當前目標和問題方面認為有意義的事項。雖然可能無法實現完全可靠的指標,但可以通過任何人們不太可能為自己的利益而操縱的合理替代指標來取得進展。例如,團隊可能希望在本季度交付一組功能;可能希望減少報告的缺陷或緊急發布的頻率;或者可能希望增加定期發布的頻率。同時,制定一個類似於「測試認證」的計劃,收集測試指標,建立政策,並朝著測試目標努力。隨著時間的推移,類似於「測試認證」的目標的進展應該與團隊其他目標的進展相關聯。從邏輯上講,相關性不能證明因果關係,但從經驗層面來看,應該理解單元測試對此產生了積極影響。

衡量因為測試發現而未發生的所有問題,或者開發人員在選擇和撰寫測試時發現的所有問題,是非常困難的。你可以衡量釋放週期速度、回滾頻率和報告的錯誤等副作用,但在一個還沒有進行測試的項目開始時,你需要相信這帶來了很多好處。在一些團隊中,試驗單元測試幾個月,然後跟進一個開發人員調查(「你是否覺得代碼變得更加健康?你是否不太擔心你的更改會在生產環境中引起問題?你在撰寫測試時發現了錯誤嗎?」)可能會向懷疑的管理層證明測試有助於團隊。

站出來

如果人們對你的結果表示懷疑,並要求進一步證明單元測試的效能,可以指出開發人員信心、生產力和幸福感的提高,以及如果有這樣的數據的話,用戶幸福感的提高。懷疑論者是否能夠對其他因素,如編程語言選擇、代碼編輯器選擇、假期聚會、會議、外部活動和獎金,以及提供給客戶的最終價值,進行相同的結果測量?如果我們要因缺乏證實其練習的價值的硬數據而指責測試,那麼我們也應該展示這些其他技術和業務決策所涉及的學術研究以及證明其影響的無可辯駁的實證研究。

重點是,成功的團隊、產品和業務的生產涉及許多因素。並非所有因素都能完全衡量,但這並不是不採用它們的理由,因為我們有一種感覺,效果的總和大於各個部分的總和。在這方面,單元測試與許多其他業務因素並無二致。

如果有人試圖聲稱單元測試不起作用,因為他們的情況是“不同的”,請勇於反對這種毫無根據的駁回。您可以從一個堅強的立場辯論,即有許多不同的人,擁有許多不同的經驗,他們都會為單元測試的有效性作證。這些人、團隊、產品和經驗之間的差異強化了單元測試的有效性的論點,而不是削弱了它。克服單元測試面臨的任何“差異”挑戰的唯一要求就是勇氣去嘗試。不要錯過將懦弱偽裝成理智的機會。

多餘地重複自己

不是每個人都會對您的第一組論點做出回應。不同的人對不同的解釋會有不同的反應。實驗找出向不同人傳達您的想法的最佳方法。不斷改進您的想法,找到使它們對新觀眾有吸引力的方法。

登月計劃

從可實現的短期目標開始採取增量步驟,但不要害怕設定巨大目標,並在時機成熟時向它們邁進。由於舉辦講座、製作內部培訓材料並分發書籍,測試小組最初只是這樣做。最終,它變得更加大膽,推出了廁所上的測試並運行測試修復。只有經過幾年的實驗,測試認證戰略才得以實現,而且還需要幾年時間才能完成TAP的工作。我們的成功是通過首先招募了一小部分熱心的志願者來實現的;一起建立堅實的社區和工具和實踐的基礎;然後設定了一個極其積極的目標。

您的努力應該遵循類似的弧線。一旦您有足夠的隊友,並且為您的團隊制定了測量、政策和目標,您就有了一個“地球上的人”。現在您可以將目光放得更高。您可以為您的團隊組織一次Fixit,或者在您的辦公室中有幾個團隊,記錄每個任務的目標、任務和指派給每個任務的人。通過成功舉辦一次以單元測試為重點的活動,您現在有了一個“軌道上的人”。反思該活動的教訓,並利用它產生的動力,您已經準備好試圖讓一個“登月人”進入並改變您的整個公司。

一旦您的公司變得時尚,也許您有機會讓一個“火星人”進入並改變整個行業。這是值得思考的事情。

堅持不懈

說服人們相信單元測試的價值並不總是容易的。依靠你的支持網絡;在他們身上依賴以獲得支持和解脫。你不必孤身奮戰。委派任務。讓其他人偶爾帶頭,而你則重新充電。準備好在有人需要依靠你時重新振作。

跟進

即使你實現了最雄心勃勃的文化改變目標,工作真的永遠不會完成。健康的文化需要持續警惕的維護,而在建立單元測試文化之外的下一個巨大進步是教導良好的自動化測試美學。這不僅限於單元測試層面;單元測試、集成測試和系統測試的整個範譜都應該被應用以確保高代碼質量,人們需要被教育以適當應用和每個測試層級的最佳實踐。請記住,人們可能會被說服採用自動化測試,但這並不意味著他們會做得很好。

缺乏指導和反饋,人們可能會對糟糕組織的代碼或寫得不好的 API 寫出極其複雜、難以維護的測試。重量級集成規模測試可能會檢查微不足道的事情。人們可能會寫出成本高、價值低的測試,甚至沒有意識到這一點,比如測試因為檢查錯誤的事物而失敗(例如測試特定像素是否為藍色,而不是檢查頁面是否完成加載),或者測試存在不必要的冗余。總會有一些人會將一個好主意搞得有些過頭,或者朝錯誤的方向發展。這並不意味著這個想法缺乏價值,只是沒有一個單獨存在的想法能免於濫用。

一套精心編寫的自動化測試可以是一個強大的工具,用於確認您的代碼是否按預期工作並且組織清晰、解耦(由於自動化測試的實踐所施加的設計壓力)。然而,自動化測試是一項像其他技能一樣需要時間和努力去發展的技能,而且總有改進的空間。目標不是以任何必要手段實現完美的測試覆蓋率,而是使開發盡可能高效、有效和可靠。一旦你說服了人們自動化測試的價值,幫助他們不斷提高他們的技能,以確保他們的測試有助於實現這一目標。

獎勵和認可

生活中很少有什麼比將生命投入到重要的事情中,對於這件事情的貢獻幾乎被忽視或更糟的是,被其他人冒名頂替更令人失望的事情。不要讓這種情況發生在與你一起努力實現真正變革的人身上。不要過分吹捧,但要習慣性地讓人們知道他們的努力得到了認可和讚賞。

讓它有趣

永遠不要低估在實現雄心勃勃的目標時的樂趣的力量。樂趣減輕了負擔,使人們彼此聯繫在一起。樂趣創造了精彩的故事,你會喜歡向你的孫子們講述,或者至少是在你的團隊或公司長期完成任務之後加入的新資深開發人員。

最後的思考

如果單元測試不需要任何額外的工具,不需要將所有東西重寫成新語言,增強了其他工具和實踐的應用,可以逐步應用於現有代碼,承擔的成本不比學習任何其他新技術或產品領域更高,已經成為世界上最複雜的開發操作中最受期待的文化規範之一,並且能夠檢測或防止可能滑過每一個其他可能的保障和測試層的災難性

為什麼單元測試不成為每個開發文化的一部分呢?

有些程式設計師和團隊可能不熟悉單元測試及其作用,缺乏相關經驗,或需要幫助開始進行測試。希望本文能提出有說服力的論點,說服他們採用單元測試的實踐。作為鼓勵的例子,OpenSSL 專案本身對於我提出協助增加單元/自動測試覆蓋率的提議持開放態度,我正在積極招募人員協助此項努力。

此外,開發者忽略編寫單元測試,是因為其團隊或公司最多容忍缺乏測試,最壞的情況是積極反對測試。此類團隊常常聲稱他們“沒有時間進行測試”,或者他們的程式碼“難以測試”。這可能是由於故意無視、漠不關心、糟糕的過去經驗、企業的激勵結構和壓力、或是典型的牛仔碼辣手偏見所導致的。無論主要動機是什麼,其效果是維持現狀,為走捷徑提供辯解。“Bugs happen”因為接受這一事實為既定事實,比改變自己的習慣或周圍文化更加舒適。而且方便的是,公眾願意接受這個藉口,因為他們無法合理地知道更好的方法。

作為開發者,我們身上擔負著一個未經充分了解的公眾(可以說是不應該的信任)所賦予的角色,我們可以並且必須做得比這更好。輕鬆、方便的藉口或許能幫助我們通過爭議的不適,但它們真正做的事情並不具有實質性成果。

如果我們不能克服採用單元測試的文化障礙,我們將無法將我們手邊最有效的開發工具之一應用於防止昂貴、尷尬且潛在危險的軟體缺陷這一真正挑戰。當我們因為對其他程序員的同情(以及對自己被判斷的秘密恐懼)而使我們的判斷力受到影響,導致我們迅速且舒適地得出結論,將可以預防的缺陷視為理所當然時,我們就和任何在沒有單元測試的情況下產生此類缺陷的程式設計師、團隊或公司一樣有罪。

“文化”是我們對技術失敗最難以捉摸的解釋,尤其是對於沒有軟體開發背景的使用者——因此,新聞界急於接受它可以理解並報導的原因,因為理解提供了對事件的控制的幻覺。文化也是我們自己能給予的最不舒適的原因,因為對文化失敗的承認引發了與身份有關的心理衝突,而指向外部因素則在很大程度上避免了這種衝突。然而,避免這個不舒適的事實就是繼續容忍導致“goto fail”和Heartbleed以及它們對一個很大程度上不知情、信任的社會造成的損害的自信心,這個社會越來越依

洗手單獨不能成為一名醫生,也不能拯救一名患者,但我們不會信任一個不洗手的醫生。作為軟件開發者,我們應該假定對我們的用戶承擔類似的責任。沒有單元測試的軟件是不值得信任的。


進一步閱讀

我的博客mike-bland.com有很多有關goto failHeartbleed漏洞的先前工作。直接影響本文的先前工作列於我的Goto Fail, Heartbleed, and Unit Testing Culture頁面

我還詳細介紹了單元測試在Google帶來的變化,並在我的“捕鯨”系列中描述了Google的許多開發和測試工具及流程。

在我參與Testing Grouplet、Fixit Grouplet和Test Mercenaries時,最影響我的思想之一是索爾·阿林斯基的《激進分子的規則》。我特別喜歡改述他的觀點,即如果人們不相信自己有能力解決問題,他們甚至不會考慮嘗試解決。Testing Grouplet等人的目標就是給予Google開發者解決代碼質量問題的能力,並以一種非傳統的、自下而上的方式提供這種能力。

我也很喜歡羅伯特·格林(Robert Greene)的史詩作品《權力的48法則》《戰爭的33個策略》。格林就像現代的馬基雅維利,他的著作不僅因其學術廣度和深度而令人印象深刻,而且因其不道德的操縱手段而令人印象深刻。我一直認為這種操縱的方面對娛樂和震撼具有過度的表現力;對人性的深刻洞察對於任何試圖改變人心和思想的人來說都是極其寶貴的,特別是因為這些書提供了保護你自己的心靈和思想的出色建議。

羅伯特·西爾迪尼的《影響力:說服心理學》是一本exceptionally清晰的書,解釋了為什麼我們在一瞬間經常屈服於說服力,而事後感到後悔。它提供了識別當有人在潛意識水平上試圖影響你時的工具——這些工具在影響你的環境中產生改變時可能對你有用。被“說服專業人士”使用的信任機制通常使我們作為一個物種得以生存和繁榮,但很容易被利用於善惡兩端。閱讀本書將為你提供一個廣泛的框架,格林的教訓將變得更加有意義。

杰弗裡·A·摩爾的《重構:改善現有程式的設計》。其中的想法非常有影響力,以至於如果您從其他地方了解到它們,很難相信它們都源自這本書。我聽說過很多關於 Gerard Meszaros 的《xUnit 測試模式》的好評,它是作為 Martin 的標誌性系列之一出版的。我在撰寫本文之前幾個月,我的 Automated Testing Boston Meetup 同事 Stephen Vance 出版了《品質代碼:軟體測試原則、實踐和模式》《廁所上的測試》的公開可用集也是自動化測試智慧的一個非常有用的來源。

說到底,測試的目的在於幫助我們撰寫優秀的程式碼,因此,閱讀有關良好編碼和設計實踐的書籍具有很大的價值,即使它們沒有專注於單元測試。事實上,將來自多方的想法應用於您自己的程式碼並使它們互相協調,比期望一兩本書告訴您一切更為重要。為此,作為一名新手C++開發人員,我花了很多寶貴的時間閱讀Herb Sutter的Exceptional C++系列;Scott Meyers的Effective C++系列;Bjarne Stroustrup的The C++ Programming Language;Brian Kernighan和Dennis Ritchie的The C Programming Language;Cormen、Leiserson和Rivest的Introduction to Algorithms(Stein成為合著者之前);以及前述的Gang Of Four書籍,即Design Patterns: Elements of Reusable Object-Oriented Software。我的Northrop Grumman隊友嘲笑我每天帶著一個健身袋上班的"圖書館"。我深陷於所有這些書中,不僅開始將它們的想法應用於生產程式碼:我還開始對我正在撰寫的這些令人興奮的新程式碼進行大量的單元測試。這種經驗使我能夠更深入地吸收信息,得益於我新手單元測試提供的快速反饋,並讓我永遠認同單元測試的價值。

致謝

儘管這篇文章在署名中有我的名字,但這是數十位人的慷慨和細心努力的結果,其中一些人甚至可能應該分享合著者的榮譽。

不過,我必須說,其中許多人負責本文的長度。儘管預期我寫得太多,我的審稿人將幫助我縮短它——公正地說,他們在許多地方確實幫助我大幅度地緊縮了語言——但我整合了許多他們熱情和獨到的建議。

Charles Ballowe challenged me to clarify my point that while unit test case design should be driven by the interface contract, one should use knowledge of the implementation to probe for weaknesses in corner cases and error handling. He suggested the "buggy test" example, and reminded me to be explicit about my "postmortem/retrospective" intent in the introduction. Tony Aiuto showed me the "pointer encoding" trick that I eventually used in the "goto fail" unit test. John Penix reminded me that the "long timers" at Google thought they were doing everything right pre-testing culture and double-checked my claims about TAP. Christian Kemper took care to ensure my comments about Google were relevant to the time I was there, to avoid confusion in light of developments since my departure. John Turek challenged me to clarify when to draw the line between when tests necessarily need to "mirror" the implementation (rarely, at the lowest levels) versus when they don't (most of the time), and to clarify that unit testing should happen while writing code, not after. Stephen Ng challenged me to clarify exactly what attitude I wanted to present and what arguments I wanted to make in the introduction, "goto fail", and "Google Retrofitted" sections. Sverre Sundsdal suggested fleshing out the specific principles in the "How Could Unit Testing Have Helped?" sections and underscoring the power of TAP. Adam Sawyer helped me avoid the potential for unintended political conclusions readers might've drawn from the "goto fail" section. Alex Buccino clarified the RelEng view of automated testing for all RelEng past, present, and future. Rich Martin provided me with the "craftsmanship" paragraph for the "Tools" section almost verbatim, as well as the second paragraph of "Partners-In-Crime" and "Increase Visibility". Alex Martelli pointed out the difficulty of unit testing for concurrency issues which appears in the introduction to the "Tools" section. Sean Cassidy reminded me that documentation was worthy of inclusion in "Tools". Lisa Carey pointed out that difficulty documenting a system often points to problem in its design. Jessica Tomechak pointed out the benefits that code review has on documentation. Ana Ulin's perspective on leaving Google and her experiences at her new company motivated me to produce the "How to Change a Culture" section, and I added "Maintain Focus" as a rewrite of her original ideas. Patrick Doyle contributed many great ideas to the "How to Change a Culture" section in addition to inspiring the "Follow Through" subsection. Adam Wildavsky was especially thorough in his comments throughout the article, suggesting grammatical improvements, challenging some of my arguments, and giving me additional material to include.

上述許多人也對文章的其他方面提出了許多其他極其有用的意見;我只想公正地為他們一些最突出的貢獻給予應有的讚揚。

其他對特定部分或整篇文件提供了大量意見的人包括:Kendra Curtis;Aran Donohue;Alan Donovan;Chris George;Larry Hosken;Rob Konigsberg;Chris Rohrs;Gregor Rothfuss;Matt Simmons;Andrew Trenk;Glenn Trewitt;Gene Volovich;Zhanyong Wan;Col Willis。

我也感謝其他人的查閱和提供反饋和/或批准:Alex Aizikovsky;Jason Arbon;Dave Astels;Andrew Boyer;RT Carpenter;Mathieu Gagné;Chris George;Joseph Graves;Paul Hammant;Mark Ivey;Bryan Kinney;Tayeb Karim;Camilo Arango Moreno;Brian Okken;David Plass;C. Keith Ray;Steve Schirripa;Isaac Truett;Stephen Vance。

除了直接幫助我撰寫本文的人外,還有更多人幫助改變了Google的開發文化,這為本文和我在這個主題上撰寫的其他文章奠定了基礎。我已盡力在我的博客文章中公平地表彰了他們。他們包括測試小組成員、測試僱傭兵、測試認證導師和團隊、廁所測試維護人員和貢獻者、測試技術團隊、構建工具團隊以及以前的工程生產力部門成員。除了這些群體外,還有許多志同道合的人以某種方式貢獻了自己的力量。我特別想提到以下人士(如果您認為自己的名字應該或不應該出現在此列表中,請告訴我,我將相應地進行更新)

Adam Abrons; Ulf Adams; David Agraz; Mohsin Ahmed; Tony Aiuto; Alex Aizikovsky; Vishal Arora; Dave Astels; Venuprakash Barathan; Milos Besta; Jennifer Bevan; Tracy Bialik; Carla Bromberg; Dennis Byrne; Michael Chastain; Araceli Checa; Deanna Chen; Dianna Chou; Alex Chu; Kevin Cooney; Patrick Copeland; Jay Corbett; Bradford Cross; Kendra Curtis; Pavithra Dankanikote; Kelechi Dike; Alan Donovan; Patrick Doyle; Peter Epstein; Ambrose Feinstein; Simon Quellen Field; Daniel Fireman; Ariel Garza; Nachum Goldstein; Nikhil Gore; Brad Green; Misha Gridnev; Christian Gruber; Paul Hammant; Matt Hargett; Johannes Henkel; Johannes Henkel; Miško Hevery; Gregor Hohpe; Jason Huggins; Susan Hunter; Mark Ivey; Ralph Jocham; Emily Johnston; Michał Kaczmarek; Tayeb Karim; Nitin Kaushik; Christian Kemper; Maria Khomenko; Wolfgang Klier; Erik Kline; Damon Kohler; Rob Konigsberg; Nicolai Krakowiak; David Kramer; Archana Krishna; Deepa Kurian; Jonny LeRoy; Mike Lee; Flavio Lerda; Nick Lesiecki; Michelle Levesque; Kimmy Lin; Mindy Liu; Chris Lopez; David Mankin; Alex Martelli; Rich Martin; Thomas McGhan; Jim McMaster; Bharat Mediratta; Boyd Montgomery; David Morganthaler; Sam Newman; Steve Ng; Eric Nickell; Robert Nilsson; Neal Norwitz; Andy Watson Orion; Rong Ou; John Penix; Rob Peterson; Antoine Picard; James Pine; David Plass; Rachel Potvin; Simon Pyle; Kevin Rabsatt; C. Keith Ray; Tim Reaves; Thirumala Reddy; Mamie Rheingold; Phil Rollet; Gregor Rothfuss; Russ Rufer; Thomas Rybka; Nick Sakharov; Diego Salas; Thiago Robert Santos; John Sarapata; Steve Schirripa; Eric Schrock; Roshan Sembacuttiaratchy; Meghan Shakar; Craig Silverstein; Matt Simmons; Dave Smith; Matthew Springer; Kurt Steinkraus; Bill Strathearn; Mark Striebeck; Cristina Tcheyan; Jean Tessier; John Thomas; Jessica Tomechak; Andrew Trenk; Glenn Trewitt; John Turek; Scott Turnquest; Ana Ulin; Matt Vail; Gene Volovich; Zhanyong Wan; Lindsay Webster; Chris Van Der Westhuizen; Nicolas Wettstein; Adam Wildavsky; Collin Winter; Jonathan Wolter; Julie Wu; Kai Xu; Runhua Yang; Noel Yap; Jeffrey Yasskin; Catherine Ye; Nathan York; Paul Zabelin; Henner Zeller.

正如我喜歡說的那樣,世界上不存在一個能夠以揮動魔杖便讓事情發生的超人。我們所有人,在多年的努力過程中共同合作,才使變革發生。然而,這的確發生了。在這個世界上,沒有比團隊合作更具有強大魔力的事情了。

當然,還要感謝Martin Fowler讓我“拒絕不了的提議”。最初,我請求他幫助審查我發表的《ACM Queue》文章,《發現蘋果中的不止一條蟲》。最終,他提議我為這篇文章作出貢獻,並為此定義了一個遠遠超出我最初所認為的範圍和結構。最終的成品直接是由他的願景和指導所產生的,這絕對不是我自己能夠產生的東西。

重要修訂

2014年6月3日: 發佈了如何改變文化和最終思考部分以及附錄

2014年5月29日: 發佈了Google部分

2014年5月27日: 發佈了其他有用的工具和實踐

2014年5月20日: 發佈了成本和收益部分

2014年5月14日:發佈Heartbleed部分

2014年5月12日:發佈簡介和goto fail部分