數據庫測試的基礎要素
{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"衆所周知,在測試行業,模擬數據庫和其他持久化層會降低測試效率。在測試時,如果一個組件不屬於測試的一部分,就很難測試它與其他組件之間的交互行爲。遺憾的是,這個行業只專注於功能層面的測試,很少有人接受過其他類型測試的培訓。這篇文章通過引入數據庫測試的概念來糾正這個問題。這些技術也適用於其他類型的持久化機制,比如調用微服務。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了瞭解如何測試數據庫,我們先“忘記”與單元測試和集成測試相關的一些概念。直接一點說,現如今對這些術語的定義已經偏離了它們最初的含義。所以,在文章的剩餘部分,我們將不再使用它們。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"測試最本質的目的是生成信息。一個測試用例在執行完之後應該生成與被測試的東西相關的信息,這些信息是你原先不知道的。生成的信息越多越好。因此,我們傾向於“一個測試用例應該儘可能提供可以證明某個事實所需的斷言”,而不是“一個測試用例只提供一個斷言”。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另一個有問題的觀點是“所有的測試都應該是獨立的”。人們通常會誤讀這個觀點,認爲每一個測試都應該使用Mock,你所測試的每一個功能應該與它們的依賴項隔離。但這樣是毫無意義的,因爲在生產環境中,這些功能不可能與它們的依賴項隔離。相反,你應該儘可能像在生產環境中那樣測試,這樣纔會發現儘可能多的問題。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“所有的測試都應該是獨立的”這句話真正的意思是說,每一個測試都可以獨立於其他測試運行。或者,換句話說,你可以按照任意的順序、在任意時刻運行每一個測試或一組測試。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"很多人在測試時把事情弄複雜了。他們在執行每一個測試(甚至是每一個單獨的測試用例)之前都會完整地重建數據庫。這帶來了一些問題。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先,測試變慢了。創建新數據庫和填充數據需要時間,這通常是造成數據庫測試變慢的直接原因,而這又反過來讓人們不願意去執行測試,甚至不準備這類測試。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另一個問題與數據庫裏的記錄數量有關。當數據庫裏只有一條代碼,有些代碼運行得很好,但當有成千上百條記錄時就會失敗。在某些情況下,比如查詢語句裏缺少了WHERE子句,只要兩條記錄就會導致測試失敗。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"因此,我們需要編寫數據庫端的測試。不管在任何時候,你都應該用生產環境的數據副本來執行測試,並看着它們全部執行成功。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“"},{"type":"link","attrs":{"href":"https:\/\/grauenwolf.github.io\/DotNet-ORM-Cookbook\/index.htm","title":"","type":null},"content":[{"type":"text","text":".NET ORM Cookbook"}]},{"type":"text","text":"”就給出了一個很好的示例。這個項目有1600多個數據庫端測試用例,它們可以按照任意順序執行。爲了理解其中的原理,我們將構建一些簡單的CRUD測試來解釋這些概念。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下來的問題是一致性。人們常說,每一個測試都應該具備完美的一致性,也就是說,每次運行同一個測試都應該得到相同的結果。爲了獲得一致性,不能使用基於時間或隨機生成的測試數據,也不能被環境影響到。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在測試數據庫時,這是無法實現的。因爲總有一些不可預測的問題出現,比如網絡連接問題、磁盤問題、舊數據,等等。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但並不是說不具備這種一致性的測試就是不可靠的。儘管一些屬性會不一致,但測試在大部分時間都會返回相同的結果。隨機出現的失敗讓可以你知道應用程序在哪些情況下會有怎樣的表現。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"注意:本文所有的"},{"type":"link","attrs":{"href":"https:\/\/github.com\/Grauenwolf\/TestingWithDatabases\/tree\/main\/TestingWithDatabases","title":"","type":null},"content":[{"type":"text","text":"例子"}]},{"type":"text","text":"都可以再GitHub上找到。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"創建記錄"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們的第一個測試是創建一條記錄。爲了簡單起見,我們選擇了EmployeeClassification類,它只有四個字段:"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"int EmployeeClassificationKey \nstring? EmployeeClassificationName \nbool IsEmployee \nbool IsExempt\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在檢查數據庫模式時,我們發現EmployeeClassificationKey是一個自生成數字字段,所以就不用管它了。EmployeeClassificationName有唯一性約束,這是給很多人造成麻煩的地方。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example1_Create()\n{\n var repo = CreateEmployeeClassificationRepository();\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test classification\",\n };\n await repo.CreateAsync(row);\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個測試是不可重複運行的,因爲在第二次運行它時,相同的名字已經存在了。爲了解決這個問題,我們加了一個區分方式,比如時間戳或GUID。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example2_Create()\n{\n var repo = CreateEmployeeClassificationRepository();\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n await repo.CreateAsync(row);\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這個測試並沒有真正測試什麼東西。我們知道,CreateAsyn沒有拋出異常,但它可能是一個空方法。爲了讓測試完整,我們需要加入讀操作。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"創建和讀取記錄"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在創建和讀取測試中,我們先確保可以從數據庫讀取到非0的鍵。然後,我們用這個鍵讀取記錄,並驗證從數據庫讀取的記錄字段與原先的一樣。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example3_Create_And_Read()\n{\n var repo = CreateEmployeeClassificationRepository();\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n var key = await repo.CreateAsync(row);\n Assert.IsTrue(key != 0);\n\n var echo = await repo.GetByKeyAsync(key);\n Assert.AreEqual(key, echo.EmployeeClassificationKey);\n Assert.AreEqual(row.EmployeeClassificationName, echo.EmployeeClassificationName);\n Assert.AreEqual(row.IsEmployee, echo.IsEmployee);\n Assert.AreEqual(row.IsExempt, echo.IsExempt);\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"注意:當沒有讀取到記錄時Repository並不會拋出異常,所以,在屬性級別的斷言之前加入Assert.IsNotNull,可以更好地捕獲測試失敗情況。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"斷言太多會導致一些問題。首先,如果一個斷言失敗了,你不知道是哪一個。IsEmployee和IsExempt都是Boolean類型,所以你都沒有辦法通過上下文信息來判斷是哪個失敗了。你可以通過加入更多信息來解決這個問題,如果測試框架支持的話。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其次,難以診斷。如果多個斷言失敗了,只有第一個被捕獲到,後續的信息丟失了。爲了解決這個問題,我們使用了AssertionScope對象。所有與之相關的斷言會被集中在一起,在using代碼塊最後統一報出來。AssertionScope的實現示例可以在"},{"type":"link","attrs":{"href":"https:\/\/github.com\/Grauenwolf\/TestingWithDatabases\/blob\/main\/TestingWithDatabases\/AssertionScope.cs","title":"","type":null},"content":[{"type":"text","text":"GitHub"}]},{"type":"text","text":"上找到。對於更爲複雜的創建,可以考慮使用"},{"type":"link","attrs":{"href":"https:\/\/fluentassertions.com\/introduction#assertion-scopes","title":"","type":null},"content":[{"type":"text","text":"流式AssertionScope"}]},{"type":"text","text":"或者NUnit的"},{"type":"link","attrs":{"href":"https:\/\/docs.nunit.org\/articles\/nunit\/writing-tests\/assertions\/multiple-asserts.html","title":"","type":null},"content":[{"type":"text","text":"Assert.Multiple"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example4_Create_And_Read()\n{\n var repo = CreateEmployeeClassificationRepository();\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n var key = await repo.CreateAsync(row);\n Assert.IsTrue(key != 0, \"New key wasn't created or returned\");\n\n var echo = await repo.GetByKeyAsync(key);\n\n using (var scope = new AssertionScope(stepName))\n {\n scope.AreEqual(expected.EmployeeClassificationKey, actual.EmployeeClassificationKey, \"EmployeeClassificationKey\");\n scope.AreEqual(expected.EmployeeClassificationName, actual.EmployeeClassificationName, \"EmployeeClassificationName\");\n scope.AreEqual(expected.IsEmployee, actual.IsEmployee, \"IsEmployee\");\n scope.AreEqual(expected.IsExempt, actual.IsExempt, \"IsExempt\");\n } \n} \n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"隨着測試用例越來越多,這會變成一項枯燥的重複性工作,所以我們需要一個輔助方法。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"row.EmployeeClassificationKey = key;\nPropertiesAreEqual(row, echo); \n\nstatic void PropertiesAreEqual(EmployeeClassification expected, EmployeeClassification actual, string? stepName = null)\n{\n Assert.IsNotNull(actual, $\"Actual value for step {stepName} is null.\");\n Assert.IsNotNull(expected, $\"Expected value for step {stepName} is null.\");\n\n using (var scope = new AssertionScope(stepName))\n {\n scope.AreEqual(expected.EmployeeClassificationKey, actual.EmployeeClassificationKey, \"EmployeeClassificationKey\");\n scope.AreEqual(expected.EmployeeClassificationName, actual.EmployeeClassificationName, \"EmployeeClassificationName\");\n scope.AreEqual(expected.IsEmployee, actual.IsEmployee, \"IsEmployee\");\n scope.AreEqual(expected.IsExempt, actual.IsExempt, \"IsExempt\");\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"你也可以不用手動寫這個方法,直接使用"},{"type":"link","attrs":{"href":"https:\/\/github.com\/GregFinzer\/Compare-Net-Objects","title":"","type":null},"content":[{"type":"text","text":"CompareNETObjects"}]},{"type":"text","text":"庫。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"創建、更新和讀取記錄"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下來的測試我們要更新記錄,涉及一個創建操作和兩次讀取操作。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example5_Create_And_Update()\n{\n var repo = CreateEmployeeClassificationRepository();\n var version1 = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n var key = await repo.CreateAsync(version1);\n Assert.IsTrue(key != 0, \"New key wasn't created or returned\");\n version1.EmployeeClassificationKey = key;\n\n var version2 = await repo.GetByKeyAsync(key);\n PropertiesAreEqual(version1, version2, \"After created\");\n\n version2.EmployeeClassificationName = \"Modified \" + DateTime.Now.Ticks;\n await repo.UpdateAsync(version2);\n\n var version3 = await repo.GetByKeyAsync(key);\n PropertiesAreEqual(version2, version3, \"After update\");\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了能夠知道爲什麼比較操作會失敗,我們給PropertiesAreEqual方法加了一個stepName參數。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"創建和刪除記錄"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"到目前爲止,我們已經涵蓋了CRUD的C、R和U,就差D了。在刪除測試中,我們仍然會讀取數據兩次。但是,我們會使用Repository另一個方法,當找不動記錄時返回null。如果你的Repository沒有這個方法,請參考第7個示例。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example6_Create_And_Delete()\n{\n var repo = CreateEmployeeClassificationRepository();\n var version1 = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n var key = await repo.CreateAsync(version1);\n Assert.IsTrue(key != 0, \"New key wasn't created or returned\");\n version1.EmployeeClassificationKey = key;\n\n var version2 = await repo.GetByKeyOrNullAsync(key);\n Assert.IsNotNull(version2, \"Record wasn't created\");\n PropertiesAreEqual(version1, version2, \"After created\");\n\n await repo.DeleteByKeyAsync(key);\n\n var version3 = await repo.GetByKeyOrNullAsync(key);\n Assert.IsNull(version3, \"Record wasn't deleted\");\n}\n[TestMethod]\npublic async Task Example7_Create_And_Delete()\n{\n var repo = CreateEmployeeClassificationRepository();\n var version1 = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n };\n var key = await repo.CreateAsync(version1);\n Assert.IsTrue(key != 0, \"New key wasn't created or returned\");\n version1.EmployeeClassificationKey = key;\n\n var version2 = await repo.GetByKeyAsync(key);\n PropertiesAreEqual(version1, version2, \"After created\");\n\n await repo.DeleteByKeyAsync(key);\n\n try\n {\n await repo.GetByKeyAsync(key);\n Assert.Fail(\"Expected an exception. Record wasn't deleted\");\n }\n catch (MissingDataException)\n {\n \/\/Expected\n }\n}\n"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果你的數據庫使用了軟刪除,你還需要檢查相應的記錄是否更新了刪除標記。這可以通過以下幾行代碼來實現。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"var version4 = await GetEmployeeClassificationIgnoringDeletedFlag(key);\nAssert.IsNotNull(version4, \"Record was hard deleted\");\nAssert.IsTrue(version4.IsDeleted);\n"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"創建記錄的改進"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在第一個測試中,可選數據列總是使用默認值。這個可以通過數據驅動測試來解決。下面的例子針對的是 MSTest,不過其他主流的測試框架也有類似的東西。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\n[DataTestMethod, EmployeeClassificationSource]\npublic async Task Example9_Create_And_Read(bool isExempt, bool isEmployee)\n{\n var repo = CreateEmployeeClassificationRepository();\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks,\n IsExempt = isExempt,\n IsEmployee = isEmployee\n };\n var key = await repo.CreateAsync(row);\n Assert.IsTrue(key > 0);\n Debug.WriteLine(\"EmployeeClassificationName: \" + key);\n\n var echo = await repo.GetByKeyAsync(key);\n Assert.AreEqual(key, echo.EmployeeClassificationKey);\n Assert.AreEqual(row.EmployeeClassificationName, echo.EmployeeClassificationName);\n Assert.AreEqual(row.IsEmployee, echo.IsEmployee);\n Assert.AreEqual(row.IsExempt, echo.IsExempt);\n}\n\npublic class EmployeeClassificationSourceAttribute : Attribute, ITestDataSource\n{\n public IEnumerable
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.