有效地重構測試程式,可以讓 TDD 或撰寫測試程式的生產力提昇數倍。
本文介紹當使用 specflow 在進行整合測試或驗收測試時,在 feature 檔案上透過 tag 的標示,即可在 scenario 開始之前,以及 feature 結束之後,清除相關 table 的測試資料,以確保自動測試可重複執行無誤。
實務上常透過 specflow 搭配 Entity Framework 等 ORM 框架來進行資料面整合測試的初始化資料、清除資料與查詢驗證資料。範例如下所示:
Feature 檔案內容
Feature: BookService
Scenario: Add the first book
Given a book for registering
| ISBN | Name |
| 9789869094481 | specification by example |
When Create
Then Book table should exist a record
| ISBN | Name |
| 9789869094481 | specification by example |
Step Definitions 內容
[Binding]
public class BookServiceSteps
{
private BookService _bookService;
[BeforeScenario()]
public void BeforeScenario()
{
this._bookService = new BookService();
}
[Given(@"a book for registering")]
public void GivenABookForRegistering(Table table)
{
var bookViewModel = table.CreateInstance<BookViewModel>();
ScenarioContext.Current.Set<BookViewModel>(bookViewModel);
}
[When(@"Create")]
public void WhenCreate()
{
var bookViewModel = ScenarioContext.Current.Get<BookViewModel>();
this._bookService.Create(bookViewModel);
}
[Then(@"Book table should exist a record")]
public void ThenBookTableShouldExistARecord(Table table)
{
using (var dbcontext = new NorthwindEntitiesForTest())
{
var book = dbcontext.Books.FirstOrDefault();
Assert.IsNotNull(book);
table.CompareToInstance(book);
}
}
}
Production Code 內容
internal class BookViewModel
{
public string ISBN { get; set; }
public string Name { get; set; }
}
internal class BookService
{
public void Create(BookViewModel bookViewModel)
{
var book = new Book { ISBN = bookViewModel.ISBN, Name = bookViewModel.Name };
//production and testing project shouldn't use the same connection string
//it is just for sample code
using (var dbcontext = new NorthwindEntitiesForTest())
{
dbcontext.Books.Add(book);
dbcontext.SaveChanges();
}
}
}
第一次測試結果,通過測試
測試第二次,測試失敗,duplicate key
使用 Entity Framework 清理測試資料
在 BeforeScenario
中,加入 Truncate
的指令,程式碼如下:
[BeforeScenario()]
public void BeforeScenario()
{
this._bookService = new BookService();
using (var dbcontext = new NorthwindEntitiesForTest())
{
dbcontext.Database.ExecuteSqlCommand("TRUNCATE TABLE [Books]");
dbcontext.SaveChangesAsync();
}
}
如此一來就能確保,每次測試案例執行前,都會將 Books
資料表中的資料清掉。
但目前作法會碰到兩個問題:
- 放在
BeforeScenario
裡面,不需要清理資料的 Scenario 會無謂的執行TRUNCATE
命令,耗費效能。 - 要清理不同的 table 中的測試資料時,能不能有彈性的重用這段程式碼。
使用 Tag 來綁定要實現的邏輯
首先先把 CleanTable()
從 step definitions 中提煉出來,放到一個共用的 Hooks.cs
中。
[Binding]
public sealed class Hooks
{
[BeforeScenario()]
public void CleanTable()
{
using (var dbcontext = new NorthwindEntitiesForTest())
{
dbcontext.Database.ExecuteSqlCommand("TRUNCATE TABLE [Books]");
dbcontext.SaveChangesAsync();
}
}
}
原本的 step definitions 就不需要再寫任何清 table 的指令。
[BeforeScenario()]
public void BeforeScenario()
{
this._bookService = new BookService();
}
接下來,希望透過 tag 標記與 tag 名稱來決定,這個 scenario 該先清除什麼表的測試資料,把 Hooks 的 CleanTable()
加工一下。
[BeforeScenario()]
public void CleanTable()
{
var tags = ScenarioContext.Current.ScenarioInfo.Tags
.Where(x => x.StartsWith("Clean"))
.Select(x => x.Replace("Clean", ""));
if (!tags.Any())
{
return;
}
using (var dbcontext = new NorthwindEntitiesForTest())
{
foreach (var tag in tags)
{
dbcontext.Database.ExecuteSqlCommand($"TRUNCATE TABLE [{tag}]");
}
dbcontext.SaveChangesAsync();
}
}
ScenarioContext.Current.ScenarioInfo.Tags
取得所有 tag 後,篩選出 Clean 開頭的 tag 並 parse table 名稱,執行 truncate
命令。接下來只需要在 scenario 上標記 tag,例如 @CleanBooks
就可以在 scenario 執行前清掉 table [Books]
的資料。
同理,需要設定 web UI testing browser 時,也可以用同樣的方式處理,例如:
[BeforeFeature()]
[Scope(Tag = "web")]
public static void SetBrowser()
{
SeleniumWebDriver.Bootstrap(
SeleniumWebDriver.Browser.Chrome
);
}
只要有標記 @web
就會設定 browser 為 Chrome
,當然,也可以透過多個 tag 來設定不同的 browser 種類。
結論
就像 ASP.NET MVC 的 ActionFilter
一樣,透過 tag 做 Hook 除了讓 feature 上有哪些背景作業要處理看起來可以一目了然,也可以讓共用的程式碼獨立維護、重複使用。
常見的應用,還有某些功能需要先登入後才能使用,除了在 feature 上用 Background 的方式標記外,也可以透過 tag 來處理。
blog 與課程更新內容,請前往新站位置:http://tdd.best/