SQLite 的可靠性和健壯性部分歸功於徹底且仔細的測試。
截至 版本 3.42.0(2023-05-16),SQLite 程式庫包含約 155.8 KSLOC 的 C 程式碼。(KSLOC 表示數千行「原始程式碼行數」,換句話說,就是不包含空白行和註解的程式碼行數。)相較之下,此專案有 590 倍的測試程式碼和測試指令碼 - 92053.1 KSLOC。
有四個獨立的測試架構用於測試核心 SQLite 函式庫。每個測試架構都獨立於其他架構設計、維護和管理。
TCL 測試是 SQLite 的原始測試。它們包含在與 SQLite 核心相同的原始碼樹中,並且與 SQLite 核心一樣屬於公有領域。TCL 測試是開發期間使用的主要測試。TCL 測試使用 TCL 指令碼語言 編寫。TCL 測試架構本身包含 27.2 KSLOC 的 C 程式碼,用於建立 TCL 介面。測試指令碼包含在 1390 個檔案中,總計 23.2MB。有 51445 個不同的測試案例,但許多測試案例都已參數化並執行多次(使用不同的參數),因此在完整測試執行期間會執行數百萬個獨立測試。
TH3 測試架構是一組專有測試,以 C 編寫,提供核心 SQLite 函式庫 100% 分支測試涵蓋率(以及 100% MC/DC 測試涵蓋率)。TH3 測試設計用於嵌入式和特殊平台,這些平台不容易支援 TCL 或其他工作站服務。TH3 測試僅使用已發布的 SQLite 介面。TH3 包含約 76.9 MB 或 1055.4 KSLOC 的 C 程式碼,實作 50362 個不同的測試案例。TH3 測試高度參數化,因此完整涵蓋率測試會執行約 240 萬個不同的測試執行個體。提供 100% 分支測試涵蓋率的案例構成 TH3 測試套件的子集。在釋出前進行的浸泡測試會執行約 2.485 億個測試。有關 TH3 的其他資訊 另行提供。
SQL 邏輯測試 或 SLT 測試架構用於對 SQLite 和其他多個 SQL 資料庫引擎執行大量 SQL 陳述,並驗證它們是否都得到相同的答案。SLT 目前將 SQLite 與 PostgreSQL、MySQL、Microsoft SQL Server 和 Oracle 10g 進行比較。SLT 執行 720 萬個查詢,包含 1.12GB 的測試資料。
dbsqlfuzz 引擎是一個專有模糊測試器。其他 SQLite 模糊測試器 會變異 SQL 輸入或資料庫檔案。Dbsqlfuzz 同時變異 SQL 和資料庫檔案,因此能夠達到新的錯誤狀態。Dbsqlfuzz 是使用 LLVM 的 libFuzzer 架構和自訂變異器所建置。有 336 個種子檔案。dbsqlfuzz 模糊測試器每天執行約 10 億個測試變異。Dbsqlfuzz 有助於確保 SQLite 能夠抵禦惡意 SQL 或資料庫輸入的攻擊。
除了四個主要的測試框架之外,還有許多其他實作特殊測試的小程式。以下是幾個範例
在每次發布 SQLite 之前,以上所有測試都必須在多個平台和多個編譯時期組態下成功執行。
在每次簽入 SQLite 原始碼樹之前,開發人員通常會執行 Tcl 測試的子集(稱為「veryquick」),包含約 304.7 萬個測試案例。veryquick 測試包括異常、模糊和浸泡測試以外的大多數測試。veryquick 測試背後的想法是,它們足以捕捉到大多數錯誤,但只執行幾分鐘而不是幾個小時。
異常測試是旨在驗證當發生錯誤時 SQLite 正確行為的測試。建立一個在功能齊全的電腦上對格式良好的輸入正確運作的 SQL 資料庫引擎(相對)容易。更困難的是建立一個對無效輸入做出明智回應,並在系統故障後繼續運作的系統。異常測試旨在驗證後者的行為。
SQLite 與所有 SQL 資料庫引擎一樣,廣泛使用 malloc()(請參閱 SQLite 中的動態記憶體配置 的個別報告以取得更多詳細資訊)。在伺服器和工作站上,malloc() 在實務上從不失敗,因此正確處理記憶體不足 (OOM) 錯誤並非特別重要。但在嵌入式裝置上,OOM 錯誤可怕地常見,由於 SQLite 經常在嵌入式裝置上使用,因此 SQLite 能夠優雅地處理 OOM 錯誤非常重要。
OOM 測試是透過模擬 OOM 錯誤來完成。SQLite 允許應用程式使用 sqlite3_config(SQLITE_CONFIG_MALLOC,...) 介面替換替代的 malloc() 實作。TCL 和 TH3 測試套件都能插入修改過的 malloc() 版本,可以在一定次數的配置後進行調整以失敗。這些工具化的 malloc 可以設定為只失敗一次,然後重新開始工作,或在第一次失敗後繼續失敗。OOM 測試是在迴圈中完成的。在迴圈的第一個反覆運算中,工具化的 malloc 被調整為在第一次配置時失敗。然後執行一些 SQLite 操作,並進行檢查以確保 SQLite 正確處理 OOM 錯誤。然後將工具化 malloc 上的失效時間計數器增加一個,並重複測試。迴圈會持續進行,直到整個操作執行完畢,且未遇到模擬的 OOM 失敗。像這樣的測試會執行兩次,一次將工具化的 malloc 設定為只失敗一次,另一次將工具化的 malloc 設定為在第一次失敗後持續失敗。
I/O 錯誤測試旨在驗證 SQLite 對失敗的 I/O 操作有合理的回應。I/O 錯誤可能是因為硬碟機已滿、硬碟硬體故障、使用網路檔案系統時網路中斷、在 SQL 操作過程中發生的系統組態或權限變更,或其他硬體或作業系統故障。無論原因為何,SQLite 都必須能夠正確回應這些錯誤,而 I/O 錯誤測試旨在驗證它確實能做到。
I/O 錯誤測試的概念類似於 OOM 測試;模擬 I/O 錯誤,並進行檢查以驗證 SQLite 是否正確回應模擬錯誤。在 TCL 和 TH3 測試套件中,模擬 I/O 錯誤的方式是插入一個新的 虛擬檔案系統物件,該物件經過特別設計,可在設定的 I/O 作業次數後模擬 I/O 錯誤。與 OOM 錯誤測試一樣,I/O 錯誤模擬器可以設定為僅失敗一次,或在第一次失敗後持續失敗。測試以迴圈執行,緩慢增加失敗點,直到測試案例在沒有錯誤的情況下執行完畢。迴圈執行兩次,一次將 I/O 錯誤模擬器設定為僅模擬單一失敗,另一次將其設定為在第一次失敗後使所有 I/O 作業失敗。
在 I/O 錯誤測試中,在停用 I/O 錯誤模擬失敗機制後,使用 PRAGMA integrity_check 檢查資料庫,以確保 I/O 錯誤沒有造成資料庫損毀。
崩潰測試旨在證明,如果應用程式或作業系統崩潰,或是在資料庫更新過程中發生電源故障,SQLite 資料庫不會損毀。另一份名為 SQLite 中的原子提交 的白皮書描述了 SQLite 為防止崩潰後資料庫損毀而採取的防禦措施。崩潰測試力求驗證這些防禦措施是否運作正常。
當然,使用實際電源故障進行崩潰測試是不切實際的,因此崩潰測試是在模擬中進行的。插入一個替代的 虛擬檔案系統,允許測試框架模擬崩潰後資料庫檔案的狀態。
在 TCL 測試框架中,崩潰模擬在一個獨立的程序中進行。主要的測試程序產生一個子程序,執行一些 SQLite 操作,並在寫入操作的過程中隨機崩潰。一個特殊的 VFS 隨機重新排序並損壞未同步的寫入操作,以模擬緩衝檔案系統的效果。子程序結束後,原始測試程序打開並讀取測試資料庫,並驗證子程序嘗試的變更已成功完成或已完全回滾。integrity_check PRAGMA 用於確保不會發生資料庫損壞。
TH3 測試框架需要在嵌入式系統上執行,這些系統不一定有產生子程序的能力,因此它使用記憶體中的 VFS 來模擬崩潰。記憶體中的 VFS 可以被設定為在一定數量的 I/O 操作後對整個檔案系統進行快照。崩潰測試在一個迴圈中執行。在迴圈的每個反覆運算中,建立快照的點會被推進,直到被測試的 SQLite 操作執行完畢而不會遇到快照。在迴圈中,在被測試的 SQLite 操作完成後,檔案系統會被還原到快照,並會引入隨機檔案損壞,這是電源損失後預期會看到的損壞類型。然後打開資料庫並進行檢查,以確保資料庫格式良好,並且交易已執行完畢或已完全回滾。迴圈的內部會針對每個快照重複多次,每次都有不同的隨機損壞。
SQLite 的測試套件也會探討堆疊多個失敗的結果。例如,測試會執行以確保在嘗試從先前崩潰中復原時,當 I/O 錯誤或 OOM 錯誤發生時,行為正確。
模糊測試旨在建立 SQLite 對無效、超出範圍或格式錯誤的輸入做出正確回應的機制。
SQL 模糊測試包括建立語法正確但極度荒謬的 SQL 陳述,並將它們提供給 SQLite 以查看它將如何處理它們。通常會傳回某種錯誤(例如「沒有此資料表」)。有時,純粹出於偶然,SQL 陳述也恰好是語義正確的。在這種情況下,會執行產生的已準備好的陳述,以確保它提供合理的結果。
模糊測試的概念已經存在數十年,但直到 2014 年 Michal Zalewski 發明了第一個實用的檔案導向模糊器 American Fuzzy Lop 或「AFL」,模糊測試才成為找出錯誤的有效方法。與盲目產生隨機輸入的先前模糊器不同,AFL 會對受測程式進行編制(透過修改 C 編譯器的組譯語言輸出),並使用該編制來偵測輸入導致程式執行不同動作(遵循新的控制路徑或迴圈不同次數)的時間。會保留引發新行為的輸入,並進一步變異。透過這種方式,AFL 能夠「發現」受測程式的各種新行為,包括設計者從未預見的行為。
AFL 證明在尋找 SQLite 中的隱晦錯誤時非常擅長。大部分的發現都是 assert() 陳述,其中條件在不明的情況下為 false。但 AFL 也在 SQLite 中發現了相當數量的崩潰錯誤,甚至還有幾種 SQLite 計算結果不正確的情況。
由於過去的成功,AFL 從 版本 3.8.10(2015-05-07)開始成為 SQLite 測試策略的標準部分,直到它在 版本 3.29.0(2019-07-10)中被更好的模糊測試器取代。
從 2016 年開始,Google 的一個工程師團隊啟動了 OSS Fuzz 專案。OSS Fuzz 使用在 Google 基礎架構上執行的 AFL 風格引導模糊測試器。模糊測試器會自動下載參與專案的最新簽入,對其進行模糊測試,並寄送電子郵件給開發人員回報任何問題。當簽入修正程式時,模糊測試器會自動偵測到這一點,並寄送確認電子郵件給開發人員。
SQLite 是 OSS Fuzz 測試的眾多開源專案之一。SQLite 儲存庫中的 test/ossfuzz.c 原始檔是 SQLite 與 OSS 模糊測試的介面。
OSS Fuzz 不再找到 SQLite 中的歷史錯誤。但它仍然在執行,並且偶爾會在新的開發簽入中找到問題。範例: [1] [2] [3]。
從 2018 年底開始,SQLite 已使用稱為「dbsqlfuzz」的專有模糊測試器進行模糊測試。Dbsqlfuzz 是使用 LLVM 的 libFuzzer 架構建置的。
dbsqlfuzz 這個模糊測試器同時變異 SQL 輸入和資料庫檔案。Dbsqlfuzz 在定義輸入資料庫和針對該資料庫執行的 SQL 文字的特殊輸入檔案上使用自訂的 結構感知變異器。由於它同時變異輸入資料庫和輸入 SQL,因此 dbsqlfuzz 能夠找出 SQLite 中一些模糊測試器錯過的模糊錯誤,這些模糊測試器只變異 SQL 輸入或只變異資料庫檔案。SQLite 開發人員持續讓 dbsqlfuzz 在約 16 個核心上針對主幹執行。每個 dbsqlfuzz 程式執行個體每秒能夠評估約 400 個測試案例,表示每天會檢查約 5 億個案例。
dbsqlfuzz 模糊測試器在強化 SQLite 程式碼庫以防惡意攻擊方面非常成功。自從 dbsqlfuzz 加入 SQLite 內部測試套件以來,來自 OSSFuzz 等外部模糊測試器的錯誤報告已幾乎停止。
請注意,dbsqlfuzz 不是 Chromium 使用的 SQLite 的基於 Protobuf 的結構感知模糊測試器,且在 結構感知變異器文章 中有說明。這兩個模糊測試器之間沒有關聯,除了它們都基於 libFuzzer 之外。SQLite 的 Protobuf 模糊測試器是由 Google 的 Chromium 團隊撰寫和維護,而 dbsqlfuzz 則是由原始的 SQLite 開發人員撰寫和維護。擁有多個獨立開發的 SQLite 模糊測試器是好的,因為這表示模糊問題更有可能被發現。
2024 年 1 月底,第二個名為「jfuzz」的基於 libFuzzer 的工具開始使用。Jfuzz 會產生損毀的 JSONB 膨脹並將它們提供給 JSON SQL 函數,以驗證 JSON 函數是否能夠安全且有效率地處理損毀的二進位輸入。
SQLite 似乎是第三方模糊測試的熱門目標。開發人員聽聞許多模糊測試 SQLite 的嘗試,而且他們偶爾會收到獨立模糊測試人員發現的錯誤報告。所有此類報告都會立即修復,因此產品得以改進,整個 SQLite 使用者社群都能受益。這種擁有許多獨立測試人員的機制類似於 林納斯定律:「只要有足夠多的眼睛,所有錯誤都是淺顯易見的」。
一位特別值得注意的模糊測試研究人員是 Manuel Rigger。大多數模糊測試器只會尋找斷言錯誤、崩潰、未定義行為 (UB) 或其他容易偵測到的異常。另一方面,Rigger 博士的模糊測試器能夠找出 SQLite 計算出不正確答案的案例。Rigger 發現了 許多此類案例。這些發現大多是涉及類型轉換和相似性轉換的模糊角落案例,而且許多發現都是針對未發布的功能。儘管如此,他的發現仍然很重要,因為它們是真正的錯誤,而 SQLite 開發人員很感謝能夠找出並修復根本問題。
來自 AFL、OSS Fuzz 和 dbsqlfuzz 的歷史測試案例收集在 SQLite 原始碼樹中的資料庫檔案集中,然後只要執行「make test」,「fuzzcheck」實用程式就會重新執行這些測試案例。Fuzzcheck 僅執行數千個「有趣的」案例,這些案例是來自多年來各種模糊測試器檢查的數十億個案例中挑選出來的。「有趣的」案例是指展現先前未見行為的案例。模糊測試器找到的實際錯誤始終包含在有趣的測試案例中,但 fuzzcheck 執行的案例大多數從未是實際錯誤。
模糊測試和 100% MC/DC 測試 之間存在緊張關係。也就是說,經過 100% MC/DC 測試的程式碼往往更容易受到模糊測試找到的問題影響,而在模糊測試中表現良好的程式碼往往 (遠低於) 100% MC/DC。這是因為 MC/DC 測試會阻止具有無法到達分支的 防禦性程式碼,但沒有防禦性程式碼,模糊測試器更有可能找到會造成問題的路徑。MC/DC 測試似乎很適合建置在正常使用期間穩健的程式碼,而模糊測試則適合建置能抵禦惡意攻擊的穩健程式碼。
當然,使用者會偏好既能在正常使用中穩健,又能抵抗惡意攻擊的程式碼。SQLite 開發人員致力於提供這樣的程式碼。本節的目的僅是指出同時執行這兩項任務很困難。
在大部分的歷史中,SQLite 都專注於 100% MC/DC 測試。抵抗模糊攻擊只在 2014 年導入 AFL 時才成為一個問題。那段時間,模糊測試器在 SQLite 中發現許多問題。在最近幾年,SQLite 的測試策略已演變為更重視模糊測試。我們仍然維持核心 SQLite 程式碼的 100% MC/DC,但現在大多數的測試 CPU 週期都用於模糊測試。
雖然模糊測試和 100% MC/DC 測試存在競爭關係,但它們並非完全背道而馳。SQLite 測試套件測試到 100% MC/DC 的事實意味著,當模糊測試器發現問題時,這些問題可以快速修復,而且幾乎沒有引入新錯誤的風險。
有許多測試案例驗證 SQLite 能夠處理格式錯誤的資料庫檔案。這些測試首先建立一個格式正確的資料庫檔案,然後透過非 SQLite 的方式變更檔案中的一個或多個位元組來加入損毀。然後使用 SQLite 讀取資料庫。在某些情況下,位元組變更位於資料中間。這會導致資料庫內容變更,同時保持資料庫格式正確。在其他情況下,檔案中未使用的位元組會被修改,這不會影響資料庫的完整性。有趣的情況是定義資料庫結構的檔案位元組發生變更。格式錯誤的資料庫測試驗證 SQLite 會找到檔案格式錯誤,並使用 SQLITE_CORRUPT 回傳碼報告這些錯誤,而不會溢位緩衝區、解除參考 NULL 指標或執行其他不健全的動作。
dbsqlfuzz 模糊測試器在驗證 SQLite 對格式錯誤的資料庫檔案做出明智回應方面也做得非常好。
SQLite 對其操作定義了某些 限制,例如表格中的最大欄位數、SQL 陳述式最大長度或整數最大值。TCL 和 TH3 測試套件都包含許多測試,這些測試將 SQLite 推到其定義限制的邊緣,並驗證它對所有允許值都能正確執行。其他測試超越了定義的限制,並驗證 SQLite 正確回傳錯誤。原始碼包含 testcase 巨集,用於驗證每個邊界的兩側都已測試過。
每當有人回報 SQLite 的錯誤,在加入新的測試案例到 TCL 或 TH3 測試套件中,以顯示該錯誤之前,該錯誤都不會被視為已修復。多年來,這已產生數千個新測試。這些回歸測試確保過去已修正的錯誤不會在 SQLite 的未來版本中再次出現。
當系統資源被配置且從未釋放時,就會發生資源外洩。許多應用程式中最麻煩的資源外洩是記憶體外洩 - 當記憶體使用 malloc() 配置,但從未使用 free() 釋放時。但其他類型的資源也可能外洩:檔案描述符、執行緒、互斥鎖等。
TCL 和 TH3 測試架構都會自動追蹤系統資源,並在每個測試執行時回報資源外洩。不需要特殊設定或安裝。測試架構特別注意記憶體外洩。如果變更導致記憶體外洩,測試架構會快速辨識出來。SQLite 設計成絕不外洩記憶體,即使在發生例外狀況,例如 OOM 錯誤或磁碟 I/O 錯誤時。測試架構會熱衷於執行此項任務。
SQLite 核心,包括 unix VFS,在 TH3 的預設組態下,根據 gcov 測量,具有 100% 分支測試涵蓋率。此分析不包含 FTS3 和 RTree 等擴充功能。
有許多方法可以測量測試涵蓋率。最受歡迎的指標是「陳述涵蓋率」。當你聽到有人說他們的程式具有「XX% 測試涵蓋率」,沒有進一步說明時,他們通常指的是陳述涵蓋率。陳述涵蓋率測量測試套件執行過至少一次的程式碼行百分比。
分支涵蓋率比陳述涵蓋率更嚴謹。分支涵蓋率測量至少在兩個方向上評估過一次的機器碼分支指令數量。
為了說明陳述覆蓋率和分支覆蓋率之間的差異,請考慮以下假設的 C 程式碼行
if( a>b && c!=25 ){ d++; }
這行 C 程式碼可能會產生十幾條獨立的機器碼指令。如果其中任何一條指令被評估,我們就說這條陳述已被測試。因此,例如,條件式可能總是為假,而「d」變數永遠不會遞增。即便如此,陳述覆蓋率仍將這行程式碼視為已測試。
分支覆蓋率更嚴格。在分支覆蓋率中,陳述中的每個測試和每個子區塊都會被個別考慮。為了在上述範例中達到 100% 分支覆蓋率,必須至少有三個測試案例
上述任何一個測試案例都會提供 100% 陳述覆蓋率,但 100% 分支覆蓋率需要這三個案例。一般來說,100% 分支覆蓋率意味著 100% 陳述覆蓋率,但反之則不然。再次強調,SQLite 的 TH3 測試架構提供了更強大的測試覆蓋率形式 - 100% 分支測試覆蓋率。
寫得好的 C 程式通常會包含一些防禦性條件式,在實務上總是為真或總是為假。這導致了一個程式設計難題:是否移除防禦性程式碼以獲得 100% 分支覆蓋率?
在 SQLite 中,前一個問題的答案是「否」。在測試目的上,SQLite 原始碼定義了稱為 ALWAYS() 和 NEVER() 的巨集。ALWAYS() 巨集包含預期總是評估為真的條件,而 NEVER() 則包含總是評估為假的條件。這些巨集作為註解,表示這些條件是防禦性程式碼。在釋出版本中,這些巨集是直通式
#define ALWAYS(X) (X) #define NEVER(X) (X)
然而,在大部分測試期間,如果這些巨集的引數沒有預期的真值,這些巨集會擲出斷言錯誤。這會讓開發人員快速注意到不正確的設計假設。
#define ALWAYS(X) ((X)?1:assert(0),0) #define NEVER(X) ((X)?assert(0),1:0)
在測量測試覆蓋率時,這些巨集被定義為常數真值,因此它們不會產生組譯語言分支指令,因此在計算分支覆蓋率時不會發揮作用
#define ALWAYS(X) (1) #define NEVER(X) (0)
測試套件的設計是執行三次,每次執行一個如上所示的 ALWAYS() 和 NEVER() 定義。這三次測試執行都應產生完全相同的結果。有一個執行時間測試使用 sqlite3_test_control(SQLITE_TESTCTRL_ALWAYS, ...) 介面,可驗證巨集是否正確設定為第一個表單(傳遞表單)以進行部署。
與測試涵蓋率量測結合使用的另一個巨集是 testcase() 巨集。引數是我們想要測試案例的條件,評估為真和假。在非涵蓋率建置中(也就是說,在發行建置中),testcase() 巨集是不執行任何操作的
#define testcase(X)
但在涵蓋率量測建置中,testcase() 巨集會產生評估其引數中條件式運算式的程式碼。然後在分析期間,會進行檢查以確保測試存在,並評估條件為真和假。Testcase() 巨集用於例如幫助驗證是否測試邊界值。例如
testcase( a==b ); testcase( a==b+1 ); if( a>b && c!=25 ){ d++; }
當 switch 陳述式的兩個或多個案例轉到同一區塊程式碼時,也會使用 Testcase 巨集,以確保已針對所有案例觸及程式碼
switch( op ){ case OP_Add: case OP_Subtract: { testcase( op==OP_Add ); testcase( op==OP_Subtract ); /* ... */ break; } /* ... */ }
對於位元遮罩測試,testcase() 巨集用於驗證位元遮罩的每個位元都會影響結果。例如,在以下程式碼區塊中,如果遮罩包含表示正在開啟 MAIN_DB 或 TEMP_DB 的兩個位元之一,則條件為真。if 陳述式之前的 testcase() 巨集會驗證是否已測試兩個案例
testcase( mask & SQLITE_OPEN_MAIN_DB ); testcase( mask & SQLITE_OPEN_TEMP_DB ); if( (mask & (SQLITE_OPEN_MAIN_DB|SQLITE_OPEN_TEMP_DB))!=0 ){ ... }
SQLite 原始程式碼包含 1184 個 testcase() 巨集使用。
上面說明了兩種量測測試涵蓋率的方法:「陳述式」和「分支」涵蓋率。除了這兩個之外,還有許多其他測試涵蓋率指標。另一個熱門指標是「已修改條件/決策涵蓋率」或 MC/DC。維基百科 將 MC/DC 定義如下
在 C 程式語言中,其中 && 和 || 是「短路」運算子,MC/DC 和分支覆蓋率幾乎是同一件事。主要差異在於布林向量測試。可以在位元向量中測試任何幾個位元,即使 MC/DC 的第二個元素(每個決策中的每個條件都會採用每個可能的結果的要求)可能無法滿足,仍然可以得到 100% 的分支測試覆蓋率。
SQLite 使用前一個小節中所述的 testcase() 巨集,以確保位元向量決策中的每個條件都會採用每個可能的結果。這樣,SQLite 除了 100% 的分支覆蓋率之外,還達到了 100% 的 MC/DC。
SQLite 中的分支覆蓋率目前使用 gcov 和「-b」選項來測量。首先,使用「-g -fprofile-arcs -ftest-coverage」選項編譯測試程式,然後執行測試程式。然後執行「gcov -b」以產生覆蓋率報告。覆蓋率報告冗長且不便於閱讀,因此使用一些簡單的腳本來處理 gcov 生成的報告,並將其放入更人性化的格式中。當然,整個過程都是使用腳本自動化的。
請注意,使用 gcov 執行 SQLite 並非 SQLite 的測試,而是測試組的測試。gcov 執行並未測試 SQLite,因為 -fprofile-args 和 -ftest-coverage 選項會導致編譯器產生不同的程式碼。gcov 執行僅驗證測試組是否提供 100% 分支測試涵蓋率。gcov 執行是測試的測試,也就是元測試。
在 gcov 執行後驗證 100% 分支測試涵蓋率,接著使用提供編譯器選項(不含特殊 -fprofile-arcs 和 -ftest-coverage 選項)重新編譯測試程式,並重新執行測試程式。第二次執行才是 SQLite 的實際測試。
驗證 gcov 測試執行和第二次實際測試執行是否都產生相同的輸出非常重要。輸出有任何差異,表示 SQLite 程式碼中使用未定義或不確定的行為(因此有錯誤),或編譯器有錯誤。請注意,SQLite 在過去十年中已發現 GCC、Clang 和 MSVC 中的錯誤。編譯器錯誤雖然罕見,但確實會發生,因此以提供組態測試程式碼非常重要。
使用 gcov(或類似工具)顯示每個分支指令至少在兩個方向執行一次,是測試組品質的良好衡量標準。但更重要的是顯示每個分支指令在輸出中造成差異。換句話說,我們不僅要顯示每個分支指令都會跳躍和執行,還要顯示每個分支都在執行有用的工作,而且測試組能夠偵測並驗證該工作。如果發現某個分支在輸出中沒有造成差異,表示與該分支相關聯的程式碼可以移除(減少函式庫大小,並可能讓執行速度更快),或表示測試組無法充分測試該分支實作的功能。
SQLite 致力於使用突變測試驗證每個分支指令都會造成差異。 一個指令碼會先將 SQLite 原始碼編譯成組譯語言(例如,使用 gcc 的 -S 選項)。然後,指令碼會逐步執行產生的組譯語言,並逐一將每個分支指令變更為無條件跳躍或無動作,編譯結果,並驗證測試組是否偵測到突變。
不幸的是,SQLite 包含許多分支指令,有助於程式碼在不變更輸出的情況下執行得更快。這些分支會在變異測試期間產生誤報。舉例來說,考量以下用於加速表格名稱查詢的 雜湊函數
55 static unsigned int strHash(const char *z){ 56 unsigned int h = 0; 57 unsigned char c; 58 while( (c = (unsigned char)*z++)!=0 ){ /*OPTIMIZATION-IF-TRUE*/ 59 h = (h<<3) ^ h ^ sqlite3UpperToLower[c]; 60 } 61 return h; 62 }
如果第 58 行實作「c!=0」測試的分支指令變更為 no-op,則 while 迴圈將會永遠迴圈,而測試套件將會因為逾時而失敗。但是,如果該分支變更為無條件跳躍,則雜湊函數將會永遠傳回 0。問題在於 0 是有效的雜湊。永遠傳回 0 的雜湊函數仍然有效,因為 SQLite 仍然會永遠取得正確的答案。表格名稱雜湊表會簡化為連結清單,因此在剖析 SQL 陳述式時發生的表格名稱查詢可能會稍慢,但最終結果將會相同。
為了解決這個問題,以「/*OPTIMIZATION-IF-TRUE*/
」和「/*OPTIMIZATION-IF-FALSE*/
」形式的註解會插入到 SQLite 原始碼中,以告知變異測試指令碼忽略某些分支指令。
SQLite 的開發人員發現,完整涵蓋測試是找出和防止錯誤的極有效方法。由於 SQLite 核心程式碼中的每個分支指令都涵蓋在測試案例中,開發人員可以確信在程式碼某個部分進行的變更不會對程式碼的其他部分造成意外的後果。近年來新增到 SQLite 的許多新功能和效能改善,如果沒有完整涵蓋測試,將無法實現。
維持 100% MC/DC 既費力又費時。對於典型的應用程式而言,維持完整覆蓋測試所需的努力程度可能不符合成本效益。然而,我們認為對於像 SQLite 這樣廣泛部署的基礎架構程式庫,以及本質上會「記住」過去錯誤的資料庫程式庫,進行完整覆蓋測試是合理的。
動態分析是指在 SQLite 程式碼執行期間對其進行的內部和外部檢查。動態分析已被證明對於維持 SQLite 的品質非常有幫助。
SQLite 核心包含 6754 個 assert() 陳述式,用於驗證函式的先決條件和後置條件,以及迴圈不變式。Assert() 是 ANSI-C 標準的一部分,是一個巨集。其引數是一個布林值,假設永遠為真。如果斷言為假,程式會印出錯誤訊息並停止執行。
透過編譯時定義 NDEBUG 巨集,可以停用 Assert() 巨集。在大部分系統中,預設會啟用斷言。但在 SQLite 中,斷言數量眾多,而且出現在效能至關重要的位置,因此在啟用斷言時,資料庫引擎的執行速度會變慢約三倍。因此,SQLite 的預設(生產)建置會停用斷言。只有在編譯 SQLite 時定義 SQLITE_DEBUG 預處理器巨集,才會啟用斷言陳述式。
請參閱SQLite 中 assert 的使用文件,以取得有關 SQLite 如何使用 assert() 的其他資訊。
Valgrind 也許是世界上最令人驚奇且有用的開發人員工具。Valgrind 是一個模擬器,它模擬執行 Linux 二進位檔的 x86。(針對 Linux 以外平台的 Valgrind 移植正在開發中,但截至撰寫本文為止,Valgrind 僅在 Linux 上穩定運作,根據 SQLite 開發人員的意見,這表示 Linux 應該是所有軟體開發的首選平台。)由於 Valgrind 會執行 Linux 二進位檔,因此它會尋找各種有趣的錯誤,例如陣列溢位、讀取未初始化的記憶體、堆疊溢位、記憶體外洩等。Valgrind 會找出容易讓 SQLite 中所有其他測試都漏掉的錯誤。此外,當 Valgrind 找到錯誤時,它可以在錯誤發生的確切點將開發人員直接傾印到符號除錯程式中,以利快速修正。
由於它是一個模擬器,因此在 Valgrind 中執行二進位檔會比在原生硬體上執行它慢。(粗略估計,在工作站上於 Valgrind 中執行的應用程式效能約與在智慧型手機上原生執行的效能相同。)因此,不切實際地讓完整的 SQLite 測試套件在 Valgrind 中執行。不過,在每次發行之前,都會讓極快速的測試和 TH3 測試的覆蓋範圍在 Valgrind 中執行。
SQLite 包含一個可插入的記憶體配置子系統。預設實作使用系統 malloc() 和 free()。不過,如果 SQLite 是使用SQLITE_MEMDEBUG編譯的,則會插入一個替代記憶體配置包裝器 (memsys2),它會在執行時尋找記憶體配置錯誤。當然,memsys2 包裝器會檢查記憶體外洩,但也會尋找緩衝區溢位、使用未初始化的記憶體,以及在記憶體釋放後嘗試使用記憶體。這些相同的檢查也會由 valgrind 執行(而且,事實上,Valgrind 執行得更好),但 memsys2 的優點是速度遠快於 Valgrind,這表示可以更頻繁地執行檢查,並執行更長時間的測試。
SQLite 包含一個可插入的互斥子系統。根據編譯時選項,預設的互斥子系統包含介面 sqlite3_mutex_held() 和 sqlite3_mutex_notheld(),用於偵測呼叫執行緒是否持有特定互斥子。這兩個介面在 SQLite 中的 assert() 陳述式中廣泛使用,用於驗證互斥子是否在所有正確的時刻持有和釋放,以再次檢查 SQLite 是否在多執行緒應用程式中正確運作。
SQLite 執行的一項動作,用於確保交易在系統崩潰和電源故障時具有原子性,就是在變更資料庫之前將所有變更寫入回滾日誌檔案。TCL 測試架構包含一個替代的 作業系統後端 實作,用於驗證此動作是否正確執行。「日誌測試 VFS」會監控資料庫檔案和回滾日誌之間的所有磁碟 I/O 流量,檢查以確保沒有任何內容寫入資料庫檔案,除非已先寫入並同步到回滾日誌。如果發現任何差異,就會引發斷言錯誤。
日誌測試是除了崩潰測試之外的另一項再次檢查,用於確保 SQLite 交易在系統崩潰和電源故障時具有原子性。
在 C 程式語言中,撰寫具有「未定義」或「實作定義」行為的程式碼非常容易。這表示程式碼可能在開發期間運作,但隨後在不同的系統上提供不同的答案,或在使用不同的編譯器選項重新編譯時提供不同的答案。ANSI C 中未定義和實作定義行為的範例包括
由於未定義和實作定義的行為是不可移植的,而且很容易導致錯誤的答案,因此 SQLite 非常努力地避免它。例如,在 SQL 陳述式中將兩個整數欄位值加在一起時,SQLite 不會僅使用 C 語言的「+」運算子將它們加在一起。相反地,它會先檢查以確保加法不會溢位,如果會,它會改用浮點數進行加法。
為了幫助確保 SQLite 沒有使用未定義或實作定義的行為,測試套件會使用嘗試偵測未定義行為的工具化建置重新執行。例如,測試套件會使用 GCC 的「-ftrapv」選項執行。而且它們會使用 Clang 的「-fsanitize=undefined」選項再次執行。並再次使用 MSVC 中的「/RTC1」選項執行。然後,測試套件會使用「-funsigned-char」和「-fsigned-char」等選項重新執行,以確保實作差異也不重要。然後在 32 位元和 64 位元系統以及大端序和小端序系統上重複測試,使用各種 CPU 架構。此外,測試套件會增加許多測試案例,這些案例經過刻意設計以引發未定義行為。例如:「SELECT -1*(-9223372036854775808);」。
介面 sqlite3_test_control(SQLITE_TESTCTRL_OPTIMIZATIONS, ...) 允許在執行階段停用選取的 SQL 陳述式最佳化。SQLite 應該始終在啟用最佳化和停用最佳化的情況下產生完全相同的答案;啟用最佳化後,答案只會更快地到達。因此,在生產環境中,最佳化始終保持啟用狀態(預設設定)。
在 SQLite 上使用的其中一項驗證技術是以最佳化啟用狀態執行一次完整的測試套件,並以最佳化停用狀態執行第二次,然後驗證兩次都取得相同的輸出。這顯示最佳化不會引入錯誤。
並非所有測試案例都能以這種方式處理。有些測試案例會檢查驗證最佳化是否真的透過計算查詢期間發生的磁碟存取、排序作業、全掃描步驟或其他處理步驟的數量來減少運算量。當最佳化停用時,這些測試案例將會顯示失敗。但大多數測試案例只檢查是否取得正確答案,而且所有這些案例都可以順利執行,無論是否啟用最佳化,以顯示最佳化不會導致故障。
SQLite 開發人員使用線上檢查清單來協調測試活動,並驗證在每次 SQLite 發行之前所有測試都通過。過去的檢查清單會保留下來以供歷史參考。(匿名網路瀏覽者只能讀取檢查清單,但開發人員可以登入並在他們的網路瀏覽器中更新檢查清單項目。)使用檢查清單來進行 SQLite 測試和其他開發活動的靈感來自於 檢查清單宣言 。
最新的檢查清單包含約 200 個項目,會針對每個發行版個別驗證。有些檢查清單項目只需幾秒鐘即可驗證並標記完成。其他項目則涉及執行數小時的測試套件。
發佈檢查清單並非自動化:開發人員手動執行清單上的每個項目。我們發現讓人類參與迴圈中非常重要。有時,即使測試本身通過,在執行檢查清單項目時也會發現問題。讓人類在最高層級檢閱測試結果,並持續詢問「這真的正確嗎?」非常重要。
發佈檢查清單會持續演進。當發現新問題或潛在問題時,會新增新的檢查清單項目,以確保這些問題不會出現在後續版本中。發佈檢查清單已被證明是無價的工具,有助於確保在發佈過程中不會遺漏任何事項。
靜態分析是指在編譯時分析原始碼,以檢查正確性。靜態分析包括編譯器警告訊息和更深入的分析引擎,例如 Clang 靜態分析器。SQLite 在 Linux 和 Mac 上使用 -Wall 和 -Wextra 旗標,在 Windows 上使用 MSVC 編譯時不會產生警告。Clang 靜態分析器工具「scan-build」也不會產生任何有效的警告(儘管最新版本的 clang 似乎會產生許多誤報)。不過,其他靜態分析器可能會產生一些警告。我們鼓勵使用者不要過度擔心這些警告,而應該對上述說明的 SQLite 嚴格測試感到安心。
靜態分析對於找出 SQLite 中的錯誤沒有幫助。靜態分析已找出 SQLite 中的一些錯誤,但這些都是例外。在嘗試讓 SQLite 在沒有警告的情況下編譯時,引入了更多錯誤,而不是透過靜態分析找出的錯誤。
SQLite 是開放原始碼。這讓許多人認為它沒有像商業軟體一樣經過良好的測試,而且可能不可靠。但這種印象是錯誤的。SQLite 在實際應用中展現出非常高的可靠性,而且缺陷率非常低,特別是考慮到它的快速演進。SQLite 的品質部分來自於仔細的程式碼設計和實作。但廣泛的測試在維持和提升 SQLite 的品質中也扮演了至關重要的角色。本文彙整了每個 SQLite 版本所經歷的測試程序,希望激勵大家相信 SQLite 適用於任務關鍵應用程式。
此頁面最後修改於 2024-03-13 17:43:35 UTC