Unit Testing สำหรับ Revit Plugin
การเขียนโค้ดโดยปราศจาก Unit Test เปรียบเสมือนการสร้างตึกโดยไม่มีการตรวจสอบงาน ปลั๊กอิน Revit ที่ดีในองค์กรต้องมีชุดทดสอบที่รันได้อัตโนมัติ เพื่อป้องกันโค้ดเก่าพังเมื่อเพิ่มฟีเจอร์ใหม่ (Regression Testing) ครับ
ความท้าทาย: ทำไม Unit Test กับ Revit ยากกว่าปกติ?
Section titled “ความท้าทาย: ทำไม Unit Test กับ Revit ยากกว่าปกติ?”ปัญหา: Revit API ต้องรันอยู่ภายใน Revit.exe เท่านั้น ดังนั้นการเรียก FilteredElementCollector หรือ Transaction นอก Revit จะ throw Exception ทันทีวิธีแก้มี 2 แนวทาง:
- RevitTestFramework — รัน Test จริงภายใน Revit (Integration Test)
- Architecture แยก Logic + Moq — Mock Revit API เพื่อ Unit Test แบบเร็ว ❤️
1. แนวคิด: แยก Business Logic ออกจาก Revit API
Section titled “1. แนวคิด: แยก Business Logic ออกจาก Revit API”กุญแจสำคัญคือ อย่าเขียน Logic ใน Execute() โดยตรง ให้แยกออกมาเป็น Service Class:
❌ ยากทดสอบ: ✅ ทดสอบได้ง่าย:Command.Execute() Command.Execute() └── ทุก Logic อยู่ที่นี่ └── เรียก ColumnService.NumberColumns() ColumnService ← Test ตรงนี้ └── Logic อยู่ที่นี่using System.Collections.Generic;
namespace RevitToolkit.Services;
/// <summary>/// Service สำหรับจัดการเลขลำดับเสา — ทดสอบได้โดยไม่ต้องใช้ Revit/// </summary>public class ColumnNumberingService{ /// <summary> /// สร้างรายการรหัสเสาตามลำดับ โดยรับ List ของ (X, Y) และ prefix /// </summary> public List<string> GenerateMarks(List<(double X, double Y)> positions, string prefix) { if (string.IsNullOrWhiteSpace(prefix)) throw new ArgumentException("Prefix ห้ามเป็นค่าว่าง", nameof(prefix));
// เรียงพิกัดจากซ้ายไปขวา บนลงล่าง var sorted = positions .OrderBy(p => Math.Round(p.Y, 1)) .ThenBy(p => Math.Round(p.X, 1)) .ToList();
var marks = new List<string>(); for (int i = 0; i < sorted.Count; i++) { marks.Add($"{prefix}{i + 1:D2}"); } return marks; }
/// <summary> /// ตรวจสอบว่า Mark ซ้ำกันไหม /// </summary> public bool HasDuplicateMarks(IEnumerable<string> marks) { var list = marks.ToList(); return list.Count != list.Distinct().Count(); }}2. เขียน Unit Test ด้วย xUnit
Section titled “2. เขียน Unit Test ด้วย xUnit”ติดตั้ง NuGet Packages ในโปรเจ็กต์ Test:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <!-- หากทดสอบบนโค้ดชุด 2027 อย่าลืมเปลี่ยนเป็น net10.0-windows --> <TargetFramework>net8.0-windows</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup>
<ItemGroup> <!-- xUnit Test Framework --> <PackageReference Include="xunit" Version="2.9.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<!-- อ้างอิงไปยัง Project หลัก --> <ProjectReference Include="..\RevitToolkit\RevitToolkit.csproj" /> </ItemGroup></Project>using RevitToolkit.Services;using Xunit;
namespace RevitToolkit.Tests;
public class ColumnNumberingServiceTests{ private readonly ColumnNumberingService _service = new();
[Fact] public void GenerateMarks_ShouldReturnCorrectCount() { // Arrange: เตรียมข้อมูล Input var positions = new List<(double X, double Y)> { (0, 0), (5, 0), (10, 0) // เสา 3 ต้นแนวนอน };
// Act: รัน Logic ที่ต้องการทดสอบ var marks = _service.GenerateMarks(positions, "C-");
// Assert: ตรวจสอบผลลัพธ์ Assert.Equal(3, marks.Count); }
[Fact] public void GenerateMarks_ShouldSortLeftToRight() { // เสาที่พิกัด X=10 ควรได้รหัสหลัง X=0 var positions = new List<(double X, double Y)> { (10, 0), (0, 0), (5, 0) // สลับลำดับโดยตั้งใจ };
var marks = _service.GenerateMarks(positions, "C-");
// X=0 ควรได้ C-01 (ซ้ายสุด) // X=5 ควรได้ C-02 // X=10 ควรได้ C-03 (ขวาสุด) Assert.Equal("C-01", marks[0]); Assert.Equal("C-02", marks[1]); Assert.Equal("C-03", marks[2]); }
[Fact] public void GenerateMarks_ShouldThrow_WhenPrefixIsEmpty() { var positions = new List<(double X, double Y)> { (0, 0) };
// Assert: คาดว่าจะ throw ArgumentException Assert.Throws<ArgumentException>(() => _service.GenerateMarks(positions, "") ); }
[Theory] [InlineData("C-", 1, "C-01")] [InlineData("COL-", 5, "COL-05")] [InlineData("ST-", 10, "ST-10")] public void GenerateMarks_ShouldFormat_WithCorrectPrefix( string prefix, int count, string expectedLast) { var positions = Enumerable.Range(0, count) .Select(i => ((double)i, 0.0)) .ToList();
var marks = _service.GenerateMarks(positions, prefix);
Assert.Equal(expectedLast, marks.Last()); }
[Fact] public void HasDuplicateMarks_ShouldDetect_WhenDuplicatesExist() { var marksWithDuplicates = new[] { "C-01", "C-02", "C-01" }; // C-01 ซ้ำ
bool result = _service.HasDuplicateMarks(marksWithDuplicates);
Assert.True(result); }
[Fact] public void HasDuplicateMarks_ShouldReturnFalse_WhenNoDuplicates() { var uniqueMarks = new[] { "C-01", "C-02", "C-03" };
bool result = _service.HasDuplicateMarks(uniqueMarks);
Assert.False(result); }}3. รัน Test ใน Visual Studio
Section titled “3. รัน Test ใน Visual Studio”# รัน Test ทั้งหมดผ่าน Terminaldotnet test
# รัน Test เฉพาะ Classdotnet test --filter "FullyQualifiedName~ColumnNumberingServiceTests"
# รัน Test พร้อมดูผลรายละเอียดdotnet test -v normalหรือใน Visual Studio:
- เปิดเมนู
Test→Run All Tests(Ctrl+R, A) - ดูผลใน Test Explorer (View → Test Explorer)
4. Moq — Mock Revit Object สำหรับ Integration-level Test
Section titled “4. Moq — Mock Revit Object สำหรับ Integration-level Test”สำหรับโค้ดที่ต้อง Interface กับ Revit API เราใช้ Moq เพื่อสร้างวัตถุจำลอง:
<PackageReference Include="Moq" Version="4.20.72" />using Moq;using Autodesk.Revit.DB;using Xunit;
namespace RevitToolkit.Tests;
public class ParameterWriterTests{ [Fact] public void WriteMarkToParameter_ShouldCallSet_WhenNotReadOnly() { // Arrange: สร้าง Mock Parameter var mockParam = new Mock<Parameter>(); mockParam.Setup(p => p.IsReadOnly).Returns(false); mockParam.Setup(p => p.Set(It.IsAny<string>())).Returns(true);
// Act bool success = WriteMarkToParameter(mockParam.Object, "C-01");
// Assert Assert.True(success); mockParam.Verify(p => p.Set("C-01"), Times.Once); // ตรวจว่า Set() ถูกเรียก 1 ครั้ง }
private bool WriteMarkToParameter(Parameter param, string value) { if (param == null || param.IsReadOnly) return false; param.Set(value); return true; }}5. RevitTestFramework — Integration Test ใน Revit จริง
Section titled “5. RevitTestFramework — Integration Test ใน Revit จริง”สำหรับการทดสอบที่ต้องใช้ FilteredElementCollector, Transaction จริงๆ ให้ใช้ RevitTestFramework:
# ติดตั้งผ่าน NuGetInstall-Package RevitTestFrameworkusing RTF.Framework;using Xunit;
namespace RevitToolkit.IntegrationTests;
[TestFixture]public class FilterCollectorTests{ // [Test] จะถูกรัน ภายใน Revit จริง [Test] public void FilteredElementCollector_ShouldFindColumns() { // ต้องมีไฟล์ .rvt ที่มีเสาอยู่แล้วเพื่อทดสอบ Document doc = RevitTestExecutive.CommandData.Application.ActiveUIDocument.Document;
var columns = new FilteredElementCollector(doc) .OfCategory(BuiltInCategory.OST_StructuralColumns) .WhereElementIsNotElementType() .ToElements();
Assert.True(columns.Count > 0, "ควรพบเสาในโมเดลทดสอบ"); }}