Programing — C# Entity Framework with DbContext Unit Test
ลองหาโซลูชั่นสำหรับการทำ Unit Testing ให้กับ Entity Framework ในกรณีที่เราต้องการทดสอบการทำงานของ API Controller ที่มีการเรียกใช้ DbContext ที่ต่อกับฐานข้อมูล แต่เราไม่ต้องการที่จะให้มันไปยุ่งกับฐานข้อมูลจริงของเรา เพราะไม่งั้น ค่าในฐานข้อมูลจะเละ แค่อยากรู้ว่าทำงานถูกไหม แค่นั้น ก็เลยมีแนวคิดที่เรียกว่า in-memory testing ขึ้นมา ซึ่งมันก็คือการจำลองฐานข้อมูลชั่วคราวขึ้นมาใน memory ให้ ใช้เสร็จ ก็ลบ จบกัน
ต้นฉบับจาก docs.microsoft.com เครื่องมือที่ใช้ในการทดสอบ คือ Visual Studio 2017 กับ Entity Framework ในการทำ DbContext (ตัวอย่างนี้จะไม่มีส่วนที่เป็น migration นะ) และ Web API 2 สำหรับสร้าง API (ที่ทำงานใช้ Nancy จัดการในส่วนนี้ แต่คิดว่าคงไม่แตกต่างกันมาก เลยใช้ Web API 2 ตามตัวอย่างละกัน)
เริ่มต้นสร้างโปรเจค
Visual Studio ไปที่เมนู File > New > Project…
จะได้หน้าต่างแบบด้านล่าง ให้เลือกไปที่ Templates > Visual C# > Web แบบ ASP.NET Web Application (.NET Framework) อย่าลืมตั้งชื่อ จากนั้นก็คลิกปุ่ม OK

สำหรับใครที่มีโปรเจ็คเก่าแล้วต้องการเพิ่ม test เข้าไป ให้เลือกไปที่ Templates > Visual C# > Test แบบ Unit Test Project (.NET Framework) ตั้งชื่อ และคลิกที่ปุ่ม OK เช่นกัน

จากขั้นตอน New Project จาก Web เราจะได้หน้าจอดังรูป ให้เลือกเป็น Empty Templates ติกที่ Web API และ Add unit tests จากนั้นก็คลิกที่ปุ่ม OK

Visual Studio ก็จะสร้างโปรเจ็คให้เรา โดยมีโครงสร้างใน directory ประมาณนี้ (ไฟล์ต่างๆ ในแต่ละโฟลเดอร์ยังไม่มีนะ เดี๋ยวเราจะมาสร้างกัน)

Model, Controller and Dependency Injection
มาเริ่มกันที่ Model คลิกขวาที่โฟลเดอร์ Models ใน Solution Explorer เลือก Add > New Item… ภายใต้ Visual C# เลือก Code > Class ตั้งชื่อ และคลิกที่ปุ่ม Add

ได้ไฟล์ Product.cs ใน Models มาแล้ว เราก็แปะโค้ดนี้เข้าไป
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
ต่อไปเป็น Controller ซึ่งเราจะใช้ Web API 2 โดย คลิกขวาที่โฟลเดอร์ Controllers เลือก Add > New Scaffolded Item… จากนั้นเลือก Cummon > Web API 2 Controller with actions, using Entity Framework และคลิกที่ปุ่ม Add


เลือก Model class ที่เราได้สร้างไว้ก่อนหน้านี้ ตั้งชื่อ Controller name และคลิกที่ปุ่ม Add เมื่อได้ไฟล์ ProductsController.cs มาแล้ว ข้างในคลาสก็เพิ่มโค้ดชุดด้านล่าง ซึ่งเป็นในส่วนของการสร้าง API เพื่อ สร้าง อัพเดท ลบ และดึงข้อมูล Product (ผมไม่ลงรายละเอียดนะ เพราะเป็น API ปกติทั่วๆไป)

public class ProductsController : ApiController
{ // some code with dependency injection
// GET: api/Products
// get product list
public IQueryable<Product> GetProducts()
{
return db.Products;
} // GET: api/Products/5
// get product item by id
[ResponseType(typeof(Product))]
public IHttpActionResult GetProduct(int id)
{
Product product = db.Products.Find(id);
if (product == null)
{
return NotFound();
} return Ok(product);
} // PUT: api/Products/5
// update product item by id
[ResponseType(typeof(void))]
public IHttpActionResult PutProduct(int id, Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
} if (id != product.Id)
{
return BadRequest();
}
// implement in context
db.MarkAsModified(product); try
{
db.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
if (!ProductExists(id))
{
return NotFound();
}
else
{
throw;
}
} return StatusCode(HttpStatusCode.NoContent);
} // POST: api/Products
// insert new product item
[ResponseType(typeof(Product))]
public IHttpActionResult PostProduct(Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
} db.Products.Add(product);
db.SaveChanges(); return CreatedAtRoute("DefaultApi", new { id = product.Id }
, product);
} // DELETE: api/Products/5
// delete product item by id
[ResponseType(typeof(Product))]
public IHttpActionResult DeleteProduct(int id)
{
Product product = db.Products.Find(id);
if (product == null)
{
return NotFound();
} db.Products.Remove(product);
db.SaveChanges(); return Ok(product);
} protected override void Dispose(bool disposing)
{
if (disposing)
{
db.Dispose();
}
base.Dispose(disposing);
}
private bool ProductExists(int id)
{
return db.Products.Count(e => e.Id == id) > 0;
}
}
มาต่อกันที่ dependency injection (DbContext) เพื่อเชื่อมคลาส model เข้ากับ data set (DbSet) ให้มีความสามารถ insert update delete select ได้ ซึ่งตามตัวอย่าง จะให้สร้าง interface สำหรับ DbContext ซึ่งที่ผมลองปรากฏว่าทำงานไม่ได้ ผมจึงไม่ใช้ interface และข้ามไปสร้าง context เลย โดยหลังจากสร้างไฟล์ EFUnitTest02Context.cs ในโฟลเดอร์ Models ขึ้นมาแล้ว ก็ให้ทำการแก้ไขโค้ดตามด้านล่าง
public class EFUnitTest02Context : DbContext
{
public EFUnitTest02Context() : base("name=EFUnitTest02Context")
{
} public DbSet<Product> Products { get; set; } public void MarkAsModified(Product item)
{
Entry(item).State = EntityState.Modified;
}
}
จากนั้นแก้ไขไฟล์ ProductController.cs เพื่อให้สามารถ รับค่า context จาก test ได้ โดยเพิ่ม constructure ขึ้นมาอีกชุดหนึ่ง
public class ProductsController : ApiController
{
// modify the type of the db field ** not use interface
private EFUnitTest02Context db = new EFUnitTest02Context(); // add these contructors
public ProductsController() { }
public ProductsController(EFUnitTest02Context context)
{
db = context;
} // API implement
}
ติดตั้ง NuGet Package เพิ่มเติม
ส่วนนี้จะเป็นการ include library เสริมสำหรับโปรเจ็คของเรา
เลือกเมนู Tools > NuGet Package Manager > Manage NuGet Packages for Solution…

ติดตั้ง package ตามในกรอบสีเหลือง โดยเลือกไปที่ tab Browse และพิมพ์ค้นหา package ที่ต้องการในช่อง Search หลังจากคลิกเลือก package แล้ว ด้านขวา ต้องเลือกให้ติดตั้ง package ดังกล่าวในทั้งสองโปรเจ็ค (ทั้งปกติและ .Tests) จากนั้นคลิกที่ปุ่ม install จะมีหน้าต่าง confirm และ licence ขึ้นมาให้ยืนยันอีกรอบ ให้เราทำการคลิกยืนยันไป

Test test test
และก็มาถึงส่วนสำคัญ นั่นคือการทำ testing นั่นเอง โครงสร้างของโปรเจ็คเทสจะเป็นดังนี้

มาเริ่มกันที่ การสร้าง test context คลิกขวาที่ โปรเจ็คเทส เลือก Add > New Item… > เลือกสร้างคลาส ชื่อ TestDbSet.cs ซึ่งคลาสนี้ จะทำตัวเป็น base คลาสให้กับ data set ใน test ซึ่งภายในคลาสก็เป็นดังด้านล่าง
public class TestDbSet<T> : DbSet<T>, IQueryable, IEnumerable<T> where T : class
{
ObservableCollection<T> _data;
IQueryable _query; public TestDbSet()
{
_data = new ObservableCollection<T>();
_query = _data.AsQueryable();
} public override T Add(T item)
{
_data.Add(item);
return item;
} public override T Remove(T item)
{
_data.Remove(item);
return item;
} public override T Attach(T item)
{
_data.Add(item);
return item;
} public override T Create()
{
return Activator.CreateInstance<T>();
} public override TDerivedEntity Create<TDerivedEntity>()
{
return Activator.CreateInstance<TDerivedEntity>();
} public override ObservableCollection<T> Local
{
get { return new ObservableCollection<T>(_data); }
} Type IQueryable.ElementType
{
get { return _query.ElementType; }
} System.Linq.Expressions.Expression IQueryable.Expression
{
get { return _query.Expression; }
} IQueryProvider IQueryable.Provider
{
get { return _query.Provider; }
} System.Collections.IEnumerator
System.Collections.IEnumerable.GetEnumerator()
{
return _data.GetEnumerator();
} IEnumerator<T> IEnumerable<T>.GetEnumerator()
{
return _data.GetEnumerator();
}
}
จากนั้นก็สร้าง data set สำหรับ product จาก base คลาสข้างต้น ขึ้นมาในคลาสชื่อ TestProductDbSet ในไฟล์ TestProductDbSet.cs เพื่อสร้างฟังก์ชั่นเสริมสำหรับการทดสอบ
class TestProductDbSet : TestDbSet<Product>
{
public override Product Find(params object[] keyValues)
{
return this.SingleOrDefault(product => product.Id ==
(int)keyValues.Single());
}
}
และก็เพิ่ม db context สำหรับทดสอบขึ้นมา ชื่อคลาส TestEFUnitTest02Context โดยสืบทอดมาจาก EFUnitTest02Context จากโปรเจ็คที่เราจะทดสอบ
public class TestEFUnitTest02Context : EFUnitTest02Context
{
public TestEFUnitTest02Context()
{
this.Products = new TestProductDbSet();
} override
public int SaveChanges()
{
return 0;
}
}
ส่วนสุดท้ายที่สำคัญที่สุด นั่นก็คือ Test Class นั่นเอง ในคลาสเราก็จะเขียน test case สำหรับการเรียกใช้ API ต่างๆ ใน controller ในโปรเจ็คที่เราต้องการทดสอบ หน้าตาเป็นอย่างไร ไปดูกัน
[TestClass]
public class TestProductController
{
[TestMethod]
public void PostProduct_ShouldReturnSameProduct()
{
var controller = new ProductsController(new
estEFUnitTest02Context());
var item = GetDemoProduct(); var result = controller.PostProduct(item) as
reatedAtRouteNegotiatedContentResult<Product>; Assert.IsNotNull(result);
Assert.AreEqual(result.RouteName, "DefaultApi");
Assert.AreEqual(result.RouteValues["id"],
result.Content.Id);
Assert.AreEqual(result.Content.Name, item.Name);
} [TestMethod]
public void PutProduct_ShouldReturnStatusCode()
{
var controller = new ProductsController(new
TestEFUnitTest02Context());
var item = GetDemoProduct(); var result = controller.PutProduct(item.Id, item) as
StatusCodeResult; Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(StatusCodeResult));
Assert.AreEqual(HttpStatusCode.NoContent,
result.StatusCode);
} [TestMethod]
public void PutProduct_ShouldFail_WhenDifferentID()
{
var controller = new ProductsController(new
TestEFUnitTest02Context());
var badresult = controller.PutProduct(999,
GetDemoProduct()); Assert.IsInstanceOfType(badresult,
typeof(BadRequestResult));
} [TestMethod]
public void GetProduct_ShouldReturnProductWithSameID()
{
var context = new TestEFUnitTest02Context();
context.Products.Add(GetDemoProduct());
var controller = new ProductsController(context); var result = controller.GetProduct(3) as
OkNegotiatedContentResult<Product>; Assert.IsNotNull(result);
Assert.AreEqual(3, result.Content.Id);
} [TestMethod]
public void GetProducts_ShouldReturnAllProducts()
{
var context = new TestEFUnitTest02Context();
context.Products.Add(new Product { Id = 1, Name = "Demo1",
Price = 20 });
context.Products.Add(new Product { Id = 2, Name = "Demo2",
Price = 30 });
context.Products.Add(new Product { Id = 3, Name = "Demo3",
Price = 40 });
var controller = new ProductsController(context); var result = controller.GetProducts() as TestProductDbSet; Assert.IsNotNull(result);
Assert.AreEqual(3, result.Local.Count);
} [TestMethod]
public void DeleteProduct_ShouldReturnOK()
{
var context = new TestEFUnitTest02Context();
var item = GetDemoProduct();
context.Products.Add(item);
var controller = new ProductsController(context); var result = controller.DeleteProduct(3) as
OkNegotiatedContentResult<Product>; Assert.IsNotNull(result);
Assert.AreEqual(item.Id, result.Content.Id);
} Product GetDemoProduct()
{
return new Product() { Id = 3, Name = "Demo name",
Price = 5 };
}
}
เมื่อครบทุกไฟล์แล้วเราก็ทำการ test ได้เลย โดยคลิกที่เมนู Test > Run > All Tests

Visual Studio ก็จะทำการ build โปรเจ็ค แล้วก็สั่งรัน test ผลการทดสอบที่ได้ก็จะแสดงใน Test Explorer ว่ามีการทดสอบอะไรบ้าง ผ่านกี่เคส error กี่เคส

ลองมาทำให้มัน error ดู กรณีที่มี error จากการทดสอบ ก็จะแสดงข้อมูลประมาณนี้

error อะไรก็ไม่รู้ จะทำยังไงดี ไม่ต้องกังวล ให้ไปดูฟังก์ชั่นเทสที่ error (ดับเบิ้ลคลิกที่ชื่อฟังก์ชั่นที่ error ใน Test Explorer ก็ได้) สังเกตด้านบนของชื่อฟังก์ชั่นจะมีสัญลักษณ์ กากบาทสีแดงอยู่ ให้ทำการคลิก จะมี popup แสดงรายละเอียด Test Failed ให้เราดู ว่าสาเหตุเกิดจากอะไร ทำไมไม่ผ่าน ถ้าอ่านแล้วยังไม่เข้าใจ เรามีตัวช่วยสุดเทพของการเขียนโปรแกรม นั่นก็คือ การดีบัคโค้ดดด (ใส่ effect echo ให้ด้วย) แล้วใช้ยังไง ? (ต้องบอกด้วยหรอ ?) กลับไปที่ฟังก์ชั่นเทสที่ error ใหม่ เราต้องการที่จะเริ่มดีบัคที่บรรทัดไหน ให้ดับเบิ้ลคลิกที่หน้าแถวจนวงกลมสีแดงปรากฏ จากนั้นไปคลิกกากบาทสีแดงบนชื่อฟังก์ชั่นอันเมื่อตะกี้ใหม่ ด้านล่างของ popup สังเกต Run | Debug ก็ให้คลิกที่ปุ่ม Debug รอจน Visual build และรันโหมดดีบัคเสร็จ แล้วก็ไล่ไปตาม step F10, F11 (อันนี้ไม่ต้องบอกใช่มะ)

การเขียน Unit Test สำหรับ Entity Framework ในกรณีที่ต้องการทดสอบการเรียกใช้ API Controller จำลองการต่อกับฐานข้อมูลแบบ in-memory ก็มีเพียงเท่านี้ครับ ผมไม่ได้อธิบายอะไรมากนะ เพราะมันเป็นพื้นฐานทั่วๆไป และส่วนตัว ก็ยังไม่ได้ช่ำชองมากนัก เพิ่งจะเริ่มนำมาใช้กับระบบที่พัฒนาใหม่ สำหรับที่ทำงานเช่นกัน
เรียนรู้ไปด้วยกันครับ (^ ^)
ปล. ถ้าเจอ error ประมาณนี้ใน testing โปรเจ็ค
Message: Test method EFUnitTest02.Tests.TestProductController.PutProduct_ShouldReturnStatusCode threw exception:
System.InvalidOperationException: No connection string named ‘EFUnitTest02Context’ could be found in the application config file.
ให้ทำการ copy <connectionStrings> ในไฟล์ Web.config ในโปรเจ็คหลัก มาใส่ที่ App.config ของ Test โปรเจ็คด้วยโดยให้อยู่ภายใต้ <configuration>