數據庫測試的基礎要素

{"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 GetData(MethodInfo methodInfo)\n {\n for (var isExempt = 0; isExempt < 2; isExempt++)\n for (var isEmployee = 0; isEmployee < 2; isEmployee++)\n yield return new object[] { isExempt == 1, isEmployee == 1 };\n }\n\n public string GetDisplayName(MethodInfo methodInfo, object[] data)\n {\n return $\"IsExempt = {data[0]}, IsEmployee = {data[1]}\";\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":"現在,我們可以爲單個測試創建多條記錄,需要具備查看在數據庫創建了哪些記錄的能力。在MSTest中,我們可以使用Debug.WriteLine來記錄日誌。如果你用的是其他測試框架,可以參考它們的文檔,找到相應的方法。"}]},{"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":"到目前爲止只涉及單條記錄,但一些Repository方法會返回多條記錄,這就帶來了一些額外的挑戰。"}]},{"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 = true和IsExempt = false的記錄。我們需要事先在數據庫中準備好匹配的記錄和不匹配的記錄。"}]},{"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":"numberedlist","attrs":{"start":1,"normalizeStart":1},"content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"斷言返回了我們事先插入的匹配的記錄。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"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":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"[TestMethod]\npublic async Task Example10_Filtered_Read()\n{\n var repo = CreateEmployeeClassificationRepository();\n\n var matchingSource = new List();\n for (var i = 0; i < 10; i++)\n {\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks + \"_A\" + i,\n IsEmployee = true,\n IsExempt = false\n };\n matchingSource.Add(row);\n }\n\n var nonMatchingSource = new List();\n for (var i = 0; i < 10; i++)\n {\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks + \"_B\" + i,\n IsEmployee = false,\n IsExempt = false\n };\n nonMatchingSource.Add(row);\n }\n for (var i = 0; i < 10; i++)\n {\n var row = new EmployeeClassification()\n {\n EmployeeClassificationName = \"Test \" + DateTime.Now.Ticks + \"_C\" + i,\n IsEmployee = true,\n IsExempt = true\n };\n nonMatchingSource.Add(row);\n }\n await repo.CreateBatchAsync(matchingSource);\n await repo.CreateBatchAsync(nonMatchingSource);\n\n var results = await repo.FindWithFilterAsync(isEmployee: true, isExempt: false);\n\n foreach (var expected in matchingSource)\n Assert.IsTrue(results.Any(x => x.EmployeeClassificationName == expected.EmployeeClassificationName));\n\n var nonMatchingRecords = results.Where(x => x.IsEmployee == false || x.IsExempt == true).ToList();\n Assert.IsTrue(nonMatchingRecords.Count == 0,\n $\"Found unexpected row(s) with the following keys \" +\n string.Join(\", \", nonMatchingRecords.Take(10).Select(x => x.EmployeeClassificationKey)));\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":"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":"numberedlist","attrs":{"start":1,"normalizeStart":1},"content":[{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"重置數據庫。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"改進索引。"}]}]},{"type":"listitem","attrs":{"listStyle":null},"content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"移除Repository的這些方法。"}]}]}]},{"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":"最後一個選項也需要考慮在內,特別是當這些方法返回很多數據。GetAll方法只返回幾十條記錄是沒有問題的,但如果返回1萬條記錄,你就不應該考慮在生產環境中使用它,你應該將其移除。"}]},{"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":"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":"說到事務,有人建議整個測試從頭到尾只使用一個事務。這可能是一種嚴重的反模式,它會影響你並行執行測試,因爲它可能會阻塞數據庫(還可能出現死鎖)。況且,有些數據庫(比如SQL Server)的回滾非常慢。"}]},{"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":"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場景開始,再過渡到複雜的場景,比如並行測試、隨機採樣、性能測試和全數據集掃描。"}]},{"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","marks":[{"type":"strong"}],"text":"Jonathan Allen"},{"type":"text","text":"在90年代後期開始爲一家健康診所開發MIS項目,並逐步將它們從Access和Excel變成企業級解決方案。在花了五年時間爲金融行業開發自動化交易系統之後,他成爲了多個項目的顧問,包括機器人倉庫的UI、癌症研究軟件的中間層,以及一家大型房地產保險公司的大數據需求。在業餘時間,他喜歡學習16世紀的武術,並撰寫相關的文章。"}]},{"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","marks":[{"type":"strong"}],"text":"原文鏈接"},{"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":"link","attrs":{"href":"https:\/\/www.infoq.com\/articles\/Testing-With-Persistence-Layers\/","title":"","type":null},"content":[{"type":"text","text":"The Fundamentals of Testing with Persistence Layers"}]}]}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章