من (نویسندهی اصلی مطلب) حدود دو سال پیش شروع به ساختن TUnit کردم؛ Independent testing framework جدیدی برای .NET که چند هفته پیش بالاخره نسخهٔ ۱.۰ آن منتشر شد. اگر با xUnit.net، NUnit یا MSTest کار کرده باشید، احتمالاً اولین سؤالی که به ذهنتان میرسد این است: «چرا یکی دیگه؟ مگر سهتا فریمورک بزرگ کم بود؟». در این مقاله میخواهم صادقانه بگویم چرا این پروژه از یک تمرین یادگیری ساده، تبدیل به یک فریمورک کامل و تولید-محور شد و چه مشکلاتی را سعی کردم حل کنم که در زندگی روزمرهٔ یک توسعهدهندهٔ .NET واقعاً آزاردهنده هستند.
مقدمه: یک شروع بدون بار تاریخی
وقتی از صفر شروع میکنید، یک مزیت بزرگ دارید: هیچ legacy debt ندارید.
برخلاف xUnit، NUnit و MSTest که سالهاست روی VSTest (پلتفرم قدیمی تست مایکروسافت) بنا شدهاند، من توانستم مستقیم روی Microsoft.Testing.Platform (اختصار: MTP) که سال ۲۰۲۳ معرفی شد، بنویسم.
MTP سبک، extensible و کاملاً unopinionated است. نتیجه؟ TUnit بدون هیچ لایهٔ سنگین قدیمی کار میکند، حجم NuGet پکیج زیر ۲۰۰ کیلوبایت است و کاملاً با آیندهٔ .NET همراستا است.
ویژگیهای کلیدی که TUnit را متفاوت میکند
۱. پشتیبانی کامل از Native AOT
در زمان شروع پروژه، هیچ فریمورک تست معروفی بهطور کامل با Native AOT کار نمیکرد (چون همگی سنگین به Reflection وابسته بودند).
TUnit از Source Generator برای کشف تستها در زمان کامپایل استفاده میکند و فقط در موارد خاص (مثل پروژههای قدیمی) به reflection fallback میکند.
نتیجه: میتوانید تستهایتان را publish کنید با PublishAot=true و همچنان همه چیز کار کند — حتی در محیطهای serverless یا iOS/Android با .NET 8+.
۲. ایزوله بودن پیشفرض (Instance Per Test)
یکی از بزرگترین دردهای من با NUnit این بود که بهصورت پیشفرض یک instance از کلاس تست، بین همه متدها به اشتراک گذاشته میشد. این یعنی:
- state leakage
- تستهای intermittent وقتی parallel اجرا میشوند
- مجبور شدن به
[OneTimeSetUp] و کلی attribute پیچیده
در TUnit این قانون سخت است: هر تست = یک instance جدید از کلاس. دیگر نیازی به فکر کردن به این موضوع نیست. آرامش واقعی!
۳. موازیسازی هوشمند و بدون دردسر
کامپیوترهای امروزی ۸، ۱۶ یا حتی ۶۴ هسته دارند، اما خیلی از فریمورکها هنوز بهصورت پیشفرض تستهای یک کلاس را sequential اجرا میکنند.
TUnit از همان اول به اندازهٔ thread pool سیستم، تستها را موازی اجرا میکند. اگر نیاز به کنترل داشتید:
[ParallelLimit(4)] // حداکثر ۴ تست همزمان در این کلاس/اسمبلی
class MyTests { }
[NotInParallel] // این تست هیچوقت با بقیه موازی نشود
public async Task CriticalTest() { ... }بدون نیاز به collection fixture، magic string یا مستندات ۵۰ صفحهای.
۴. تستهای وابسته (Dependent Tests) — برای Integration Test واقعی
در دنیای واقعی، خیلی وقتها میخواهیم یک جریان کامل را تست کنیم:
- ایجاد کاربر
- بهروزرسانی کاربر
- حذف کاربر
در xUnit این کار عمداً سخت طراحی شده (چون ضدپترن محسوب میشود). در TUnit خیلی ساده است:
[Test]
public async Task Step1_CreateUser() { ... }
[Test]
[DependsOn(nameof(Step1_CreateUser))]
public async Task Step2_UpdateUser() { ... }
[Test]
[DependsOn(nameof(Step2_UpdateUser))]
public async Task Step3_DeleteUser() { ... }اگر Step1 fail شود، دو تست بعدی بهصورت خودکار Skip میشوند — لاگ تمیزتر، زمان اجرای CI کمتر.
۵. Hookهای خوانا و کامل
به جای IClassFixture، IAsyncLifetime و IDisposable پیچیده، TUnit از attributeهای انگلیسی ساده استفاده میکند:
[Before(Session)] static async Task GlobalSetup() { ... }
[After(Session)] static async Task GlobalTeardown() { ... }
[Before(Assembly)] static void AssemblySetup() { ... }
[Before(Class)] static void ClassSetup() { ... }
[Before(Test)] void PerTestSetup() { ... }
[After(Test)] void PerTestTeardown() { ... }
// و غیره...این hookها کاملاً با وراثت کار میکنند و حتی اگر exception پرت شود، حتماً اجرا میشوند (بدون نیاز به try/finally دستی).
۶. Test Context قوی و در دسترس
[After(Test)]
void TakeScreenshotOnFailure(TestContext context)
{
if (context.Result.IsFailed)
ScreenshotHelper.Capture();
}دیگر نیازی به هک و eventهای داخلی نیست.
۷. سادگی رادیکال — بدون جارگون
در TUnit فقط یک attribute برای تست داریم:
[Test] // نه Fact، نه Theory → فقط Test!
public void SimpleTest() { }
[Test]
public void ParameterizedTest(int x, string y) { } // خودکار تشخیص داده میشودهمه چیز از بالا به پایین خواناست. دیگر لازم نیست مدام فکر کنید «الان باید Theory بنویسم یا Fact؟»
۸. Assertionهای Type-Safe و Fluent
value.Should().BeEquivalentTo(expected);
age.Should().BeGreaterThan(18);
name.Should().NotBeNullOrEmpty();
کامپایلر فقط assertionهای منطقی با نوع داده را نشان میدهد — دیگر نمیتوانید روی string از BeGreaterThan استفاده کنید.
۹. وراثت و تکرارپذیری قوی (DRY++)
// یک بار روی اسمبلی بنویسید، همه تستها بگیرند
[assembly: Retry(3)]
[assembly: Timeout("00:05:00")]
// یا روی کلاس پایه
[Retry(2)]
[Timeout("00:01:00")]
public abstract class DatabaseTestsBase { }
// همه فرزندها خودکار این رفتار را دارند
نتیجهگیری و دعوت به همکاری
TUnit دیگر فقط یک پروژهٔ شخصی نیست — الان یک فریمورک تولید-محور با بیش از 3.6k star در GitHub، پشتیبانی کامل Native AOT، performance عالی و جامعهای رو به رشد است.
اگر شما هم از برخی رفتارهای قدیمی فریمورکهای تست خسته شدهاید، پیشنهاد میکنم یک پروژهٔ کوچک را با TUnit امتحان کنید. نصب خیلی ساده است:
<PackageReference Include="TUnit" Version="1.0.0" />