在此頁面中,我們會討論撰寫自動化測試的技術,這些測試牽涉到應用程式在生產環境中執行的資料庫系統。 替代的測試方法是存在的,其中生產資料庫系統會由測試替身進行交換;如需詳細資訊,請參閱 測試概觀頁面 。 請注意,測試時使用與生產環境中不同的資料庫(例如 Sqlite)並未在此涵蓋,因為這些不同的資料庫用作測試替身;此方法涵蓋在 不使用生產資料庫系統進行測試。
涉及實際資料庫之測試的主要障礙是確保適當的測試隔離,讓平行執行的測試(甚至序列中)不會互相干擾。 您可以在 這裏檢視下列的完整範例程式代碼。
設定資料庫系統
現在大部分的資料庫系統都可以在 CI 環境和開發人員電腦上輕鬆安裝。 雖然透過一般安裝機制安裝資料庫通常很容易,但現成可用的 Docker 映像適用於大多數主要資料庫,而且可在 CI 中特別容易安裝。 針對開發人員環境 GitHub 工作區, 開發容器 可以設定所有必要的服務和相依性,包括資料庫。 雖然這需要對設定進行初始投資,但一旦完成,您就擁有工作測試環境,而且可以專注於更重要的事情。
在某些情況下,資料庫會有特殊版本或版本,有助於測試。 使用 SQL Server 時, LocalDB 可以用來在本機執行測試,幾乎完全沒有設定,視需要啟動資料庫實例,並可能節省較不強大的開發人員機器上的資源。 不過,LocalDB 並非沒有問題:
- 它不支援 SQL Server Developer Edition 所做的一切。
- 它只能在 Windows 上使用。
- 這可能會導致第一次測試執行延遲,因為服務正在啟動。
我們通常建議安裝 SQL Server Developer 版本,而不是 LocalDB,因為它提供完整的 SQL Server 功能集,而且通常很容易做到。
使用雲端資料庫時,通常適合針對資料庫的本機版本進行測試,以提升速度和降低成本。 例如,在生產環境中使用 SQL Azure 時,您可以針對本機安裝的 SQL Server 進行測試 - 這兩者非常類似(雖然在進入生產環境之前,對 SQL Azure 本身執行測試仍然明智)。 使用 Azure Cosmos DB 時, Azure Cosmos DB 模擬器 是本機開發及執行測試的實用工具。
建立、植入和管理測試資料庫
安裝資料庫之後,您就可以開始在測試中使用資料庫。 在大多數簡單情況下,您的測試套件有一個可在多個測試類別的多個測試之間共享的單一資料庫,因此我們需要一些邏輯,以確保在整次測試運行期間,資料庫只需建立並初始化一次。
使用 Xunit 時,這可以透過 類別裝置來完成,此裝置代表資料庫,而且會在多個測試回合之間共用:
public class TestDatabaseFixture
{
private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTestSample;Trusted_Connection=True;ConnectRetryCount=0";
private static readonly object _lock = new();
private static bool _databaseInitialized;
public TestDatabaseFixture()
{
lock (_lock)
{
if (!_databaseInitialized)
{
using (var context = CreateContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
}
_databaseInitialized = true;
}
}
}
public BloggingContext CreateContext()
=> new BloggingContext(
new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(ConnectionString)
.Options);
}
當上述裝置實例化時,它會使用 EnsureDeleted() 刪除資料庫(以防先前執行存在),然後使用 EnsureCreated() 最新的模型組態建立它(請參閱這些 API 的文件)。 建立資料庫之後,裝置會植入一些測試可以使用的數據。 值得花一些時間思考您的種子資料,因為稍後變更它以進行新的測試可能會導致現有測試失敗。
若要在測試類別中使用基件,只需在您的基件類型上實作 IClassFixture
,xUnit 就會將它注入到您的建構函式:
public class BloggingControllerTest : IClassFixture<TestDatabaseFixture>
{
public BloggingControllerTest(TestDatabaseFixture fixture)
=> Fixture = fixture;
public TestDatabaseFixture Fixture { get; }
您的測試類別現在具有 Fixture
屬性,可供測試用來建立功能完整的內容實例:
[Fact]
public async Task GetBlog()
{
using var context = Fixture.CreateContext();
var controller = new BloggingController(context);
var blog = (await controller.GetBlog("Blog2")).Value;
Assert.Equal("http://blog2.com", blog.Url);
}
最後,您可能已經注意到上述裝置的建立邏輯中有一些鎖機制。 如果裝置只用於單一測試類別,則保證由 xUnit 完全具現化一次;但在多個測試類別中,通常會使用相同的資料庫裝置。 xUnit 確實提供 收集裝置,但該機制會防止測試類別平行執行,這對測試效能很重要。 若要使用 xUnit 類別裝置安全地管理此功能,我們會對資料庫建立和植入採取簡單的鎖定,並使用靜態旗標來確保我們永遠不需要執行兩次。
測試修改數據
上述範例顯示只讀測試,這是測試隔離觀點的簡單案例:因為沒有任何修改,因此無法測試干擾。 相較之下,會修改數據的測試比較有問題,因為它們可能會互相干擾。 隔離撰寫測試的一個常見技巧是將測試包裝在交易中,並在測試結束時復原該交易。 由於實際上沒有提交任何內容到資料庫中,所以其他測試不會看到任何修改,也避免了干擾。
以下是將部落格新增至資料庫的控制器方法:
[HttpPost]
public async Task<ActionResult> AddBlog(string name, string url)
{
_context.Blogs.Add(new Blog { Name = name, Url = url });
await _context.SaveChangesAsync();
return Ok();
}
我們可以使用下列項目來測試此方法:
[Fact]
public async Task AddBlog()
{
using var context = Fixture.CreateContext();
context.Database.BeginTransaction();
var controller = new BloggingController(context);
await controller.AddBlog("Blog3", "http://blog3.com");
context.ChangeTracker.Clear();
var blog = await context.Blogs.SingleAsync(b => b.Name == "Blog3");
Assert.Equal("http://blog3.com", blog.Url);
}
上述測試程式代碼的一些注意事項:
- 我們會開始一個交易來確保下列變更不會被提交至資料庫,並且不會影響其他測試。 由於交易從未被提交,因此在測試結束並處置內容實例時,交易會被隱式回滾。
- 進行我們想要的更新之後,我們會用ChangeTracker.Clear清除上下文實例的變更追蹤器,以確保我們確實從下列資料庫載入部落格。 我們可以改用兩個內容實例,但我們必須確定這兩個實例使用相同的交易。
- 您甚至可以想要在裝置
CreateContext
的 中啟動交易,讓測試接收已經在交易中的內容實例,並準備好進行更新。 這可以幫助防止因意外遺忘交易而導致的測試干擾,這類干擾通常很難排除。 您也可以在不同的測試類別中分隔唯讀測試和寫入測試。
明確管理交易的測試
有一個最終的測試類別,其中呈現額外的困難:測試會修改數據,同時明確管理交易。 因為資料庫通常不支援巢狀交易,因此無法像以上所述使用交易來達成隔離,因為需要由實際的產品程式碼來使用。 雖然這些測試通常比較罕見,但必須以特殊方式處理它們:您必須在每次測試之後清除資料庫的原始狀態,而且必須停用平行處理,讓這些測試不會互相干擾。
讓我們檢查下列控制器方法作為範例:
[HttpPost]
public async Task<ActionResult> UpdateBlogUrl(string name, string url)
{
// Note: it isn't usually necessary to start a transaction for updating. This is done here for illustration purposes only.
await using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var blog = await _context.Blogs.FirstOrDefaultAsync(b => b.Name == name);
if (blog is null)
{
return NotFound();
}
blog.Url = url;
await _context.SaveChangesAsync();
await transaction.CommitAsync();
return Ok();
}
假設基於某些原因,此方法需要使用可串行化的交易(這通常不是這種情況)。 因此,我們無法使用交易來保證測試隔離。 由於測試實際上會提交變更至資料庫,因此我們將定義另一個具備獨立資料庫的測試環境,確保不會干擾前述其他測試:
public class TransactionalTestDatabaseFixture
{
private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=EFTransactionalTestSample;Trusted_Connection=True;ConnectRetryCount=0";
public BloggingContext CreateContext()
=> new BloggingContext(
new DbContextOptionsBuilder<BloggingContext>()
.UseSqlServer(ConnectionString)
.Options);
public TransactionalTestDatabaseFixture()
{
using var context = CreateContext();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
Cleanup();
}
public void Cleanup()
{
using var context = CreateContext();
context.Blogs.RemoveRange(context.Blogs);
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
}
}
此裝置與上述裝置類似,但值得注意的是包含 Cleanup
方法;我們會在每次測試之後呼叫此裝置,以確保資料庫重設為其起始狀態。
如果這個裝置只供單一測試類別使用,我們可以將它當做上述類別裝置來參考 -xUnit 不會在同一類別內平行處理測試(深入瞭解 xUnit 檔中的測試集合和平行處理)。 不過,如果我們想要在多個類別之間共用此裝置,我們必須確定這些類別不會平行執行,以避免任何干擾。 若要這樣做,我們將使用此裝置作為 xUnit 集合裝置 ,而不是作為 類別裝置。
首先,我們會定義 測試集合,該集合會參考我們的裝置,並將供所有需要它的交易式測試類別使用:
[CollectionDefinition("TransactionalTests")]
public class TransactionalTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}
我們現在在測試類別中引用測試集合,並像之前一樣在建構函式中接受基件。
[Collection("TransactionalTests")]
public class TransactionalBloggingControllerTest : IDisposable
{
public TransactionalBloggingControllerTest(TransactionalTestDatabaseFixture fixture)
=> Fixture = fixture;
public TransactionalTestDatabaseFixture Fixture { get; }
最後,我們會讓測試類別可處置,並安排在每次測試之後呼叫裝置 Cleanup
的方法:
public void Dispose()
=> Fixture.Cleanup();
請注意,由於 xUnit 僅實例化集合裝置一次,因此我們不需要像上述那樣進行鎖定來建立和植入資料庫。
您可以在 這裏檢視上述的完整範例程式代碼。
小提示
如果您有多個測試類別與修改資料庫的測試,您仍然可以使用不同的裝置平行執行它們,每個都參考自己的資料庫。 建立和使用多個測試資料庫並無問題,應在需要時進行。
有效率的資料庫建立
在上述範例中,我們在執行測試之前使用 EnsureDeleted() 和 EnsureCreated(),以確保我們擁有 up-to已更新的測試資料庫。 在某些資料庫中,這些操作可能會有點慢,這對於不斷重複進行程式碼更改和測試來說,可能會成為一個問題。 如果是這種情況,您可能會想在裝置的建構函式中暫時將 EnsureDeleted
註解掉:這會在測試回合之間重複使用相同的資料庫。
這種方法的缺點是,如果您變更 EF Core 模型,您的資料庫架構將不會是最新的,而且測試可能會失敗。 因此,我們只建議您在開發週期期間暫時執行這項作。
有效率的資料庫清除
我們在上文看到,當變更實際提交至資料庫時,我們必須在每次測試之間清空資料庫,以避免干擾。 在上述交易測試範例中,我們使用 EF Core API 來刪除資料表的內容來執行這項作業:
using var context = CreateContext();
context.Blogs.RemoveRange(context.Blogs);
context.AddRange(
new Blog { Name = "Blog1", Url = "http://blog1.com" },
new Blog { Name = "Blog2", Url = "http://blog2.com" });
context.SaveChanges();
這通常不是清除表格最有效的方法。 如果測試速度是個問題,您可能想要改用未經處理的 SQL 來刪除資料表:
DELETE FROM [Blogs];
您也可以考慮使用 重新啟動 套件,這能有效率地清除資料庫。 此外,您不需要指定要清除的數據表,因此不需要更新清除程式代碼,因為數據表會新增至模型。
總結
- 針對實際資料庫進行測試時,值得區分下列測試類別:
- 只讀測試相當簡單,而且一律可以針對相同的資料庫平行執行,而不必擔心隔離。
- 寫入測試比較有問題,但交易可用來確定它們已正確隔離。
- 交易式測試最有問題,需要邏輯將資料庫重設為其原始狀態,以及停用平行處理。
- 將這些測試類別分成不同的類別,可能會避免測試之間的混淆和意外干擾。
- 請事先考慮您的預先設計的測試數據,並嘗試以在種子數據變更時不會經常出錯的方式撰寫測試。
- 使用多個資料庫平行處理修改資料庫的測試,也可能允許不同的種子數據組態。
- 如果測試速度令人擔憂,您可能想要查看建立測試資料庫的更有效率的技術,以及在每次運行之間清除其數據。
- 請務必記住測試平行處理和隔離。