یک تست واحد را در نظر بگیرید که تابع تخفیف را اجرا می‌کند و حتی پوشش خط آن ۱۰۰٪ است. حالا عملگر >= را به > تغییر دهید. اگر همهٔ تست‌ها هنوز سبز بمانند، پوشش کد دروغ نگفته است: خط واقعاً اجرا شده؛ اما هیچ تستی مرز «تخفیف برابر مبلغ سفارش» را به‌طور معنادار بررسی نکرده است.

تست جهش یا Mutation Testing دقیقاً چنین شکاف‌هایی را آشکار می‌کند. ابزار، تغییرهای کوچک و کنترل‌شده‌ای به کد تولید می‌زند؛ هر نسخهٔ تغییر‌یافته یک Mutant است. سپس تست‌ها اجرا می‌شوند. اگر تستی شکست بخورد Mutant «کشته» شده است؛ اگر تست‌ها سبز بمانند Mutant «زنده مانده» و باید دلیلش بررسی شود.

پاسخ کوتاه: Mutation Testing تست‌های شما را با خطاهای مصنوعیِ مشخص به چالش می‌کشد. خروجی مفید آن فقط یک Mutation Score نیست؛ فهرستی از Mutantهای Killed، Survived، No Coverage، Timeout و Error به‌همراه محدوده، اپراتور، نسخهٔ کد و Testهای اجراشده است. Survivor می‌تواند نشانهٔ داده یا Oracle ضعیف باشد، اما ممکن است جهش معادل، کد کم‌ریسک یا نویز ابزار هم باشد؛ پس «هر Survivor = یک تست جدید» قاعدهٔ درستی نیست.

تست جهش چیست و چه چیزی را اندازه می‌گیرد؟

Mutation Testing یک تکنیک Fault-based برای ارزیابی حساسیت Test Suite است. ابزار با Mutation Operatorهایی مانند تغییر مرز شرط، وارونه‌کردن Boolean، جایگزینی عملگر حسابی، حذف فراخوانی یا تغییر مقدار بازگشتی، نسخه‌های کوچکِ متفاوتی از کد تولید می‌کند. هر Mutant معمولاً یک تغییر فعال دارد تا رابطهٔ آن تغییر و نتیجهٔ تست قابل‌تحلیل بماند.

این تکنیک می‌پرسد: «اگر این نوع تغییر در کد رخ دهد، آیا مجموعه‌تست فعلی سیگنال قابل‌مشاهده‌ای ایجاد می‌کند؟» بنابراین پاسخ آن به چهار چیز وابسته است:

  1. کد در دامنه: کدام فایل‌ها و خط‌ها Mutation شده‌اند؟
  2. اپراتورها: چه خانواده‌ای از تغییرها تولید شده است؟
  3. تست‌ها و محیط: کدام Testها با چه نسخه و تنظیمی اجرا شده‌اند؟
  4. رأی ابزار: Killed، Survived، No Coverage، Timeout یا وضعیت دیگر چگونه تعریف شده است؟

مستندات مفاهیم پایهٔ PIT نیز همین چرخه را نشان می‌دهد: PIT ابتدا پوشش خط را برای انتخاب Testهای مرتبط می‌سنجد، Mutant را جای کد اصلی اجرا می‌کند و نتیجه را در وضعیت‌هایی مانند Killed، Survived، No Coverage، Non-viable، Timed Out و Run Error گزارش می‌دهد.

Mutation Testing چه چیزی را ثابت نمی‌کند؟

  • نبود Defect واقعی یا کامل‌بودن Requirement را ثابت نمی‌کند.
  • کیفیت کلی محصول، امنیت، کارایی، دسترس‌پذیری یا تجربهٔ کاربر را اندازه نمی‌گیرد.
  • نشان نمی‌دهد تمام انواع خطای ممکن شبیه‌سازی شده‌اند.
  • یک Score بالا تضمین نمی‌کند Assertionها معنای کسب‌وکاری درستی دارند.
  • جای Risk Analysis، Review، تست یکپارچه‌سازی، E2E یا اکتشاف را نمی‌گیرد.

بهتر است به‌جای عبارت کلی «کیفیت تست»، بگوییم: توان تشخیص Suite برای Mutantهای معتبرِ در دامنه و اپراتورهای انتخاب‌شده. برای دیدن جای این شواهد در سبد کل تست، راهنمای هرم تست و طراحی سبد Unit/Integration/E2E را ببینید.

تفاوت Code Coverage و Mutation Testing

پرسش Code Coverage Mutation Testing
چه چیزی دیده می‌شود؟ اینکه خط/Branch/شرط هنگام اجرا لمس شده است. اینکه یک تغییر مصنوعیِ مشخص توسط تست تشخیص داده شده است.
مخرج چیست؟ خط، Branch، Condition یا واحد تعریف‌شدهٔ ابزار. Mutantهای معتبر در Scope و Operator set مشخص.
Oracle ضعیف را می‌بیند؟ معمولاً نه؛ اجرای خط برای Coverage کافی است. گاهی بله؛ Mutant اجرا می‌شود ولی چون اثر Assert نشده زنده می‌ماند.
کد اجرا‌نشده مستقیم به‌عنوان Uncovered دیده می‌شود. اغلب Mutantهای No Coverage می‌سازد.
هزینه معمولاً نزدیک به یک اجرای Instrumented است. چندین اجرای انتخابی و تحلیل Mutant لازم دارد.
محدودیت اصلی اجرا را با بررسی معنا اشتباه نمی‌گیرد، اما قدرت Oracle را نمی‌سنجد. به کیفیت/نمایندگی Mutantها، مخرج و طبقه‌بندی وابسته است.

این دو رقیب نیستند. Coverage برای یافتن کد لمس‌نشده و انتخاب Test مرتبط ارزشمند است؛ Mutation برای پرسیدن «اگر رفتار عوض شود آیا تست متوجه می‌شود؟» لایهٔ دیگری می‌افزاید. حتی ابزارهای Mutation از Coverage برای کاهش هزینه استفاده می‌کنند.

چرخهٔ کامل Mutation Testing

  1. Scope و هدف: ماژول، فایل، Diff، ریسک و اپراتورهای در دامنه مشخص شوند.
  2. Baseline/Dry run: کد بدون Mutation Build شود و Testها پایدار و سبز باشند.
  3. تولید Mutant: ابزار تغییرهای تعریف‌شده را روی Production code ایجاد کند.
  4. Test selection: فقط Testهای پوشاننده/مرتبط برای هر Mutant انتخاب شوند، اگر ابزار پشتیبانی می‌کند.
  5. اجرا در ایزوله: هر Mutant فعال شود و Result/Timeout/Crash ثبت گردد.
  6. طبقه‌بندی: Killed، Survived، No Coverage، Timeout، Error/Invalid و Ignored جدا شوند.
  7. Triage: Survivorهای ارزشمند بر اساس ریسک، امکان کشتن و علت تحلیل شوند.
  8. اقدام: Test/Oracle/Data اصلاح، کد ساده، Mutant معادل مستند یا Scope/Operator کالیبره شود.
  9. بازاجرا و Evidence: نتیجه روی همان نسخه/تنظیم تأیید و Report نسخه‌بندی شود.

کنترل ایمنی: Artifact جهش‌یافته فقط در محیط Ephemeral تست است. خروجی Mutation نباید وارد Package، Image یا مسیر Deployment شود. Build/Artifact محصول پس از این مرحله باید از Source پاک و شناسهٔ Commit معتبر دوباره ساخته یا Promote شود.

مثال عملی: تخفیف ریالی و مرز صفر

تابع زیر مبلغ قابل‌پرداخت را با واحد canonical ریال محاسبه می‌کند:

function payableRial(totalRial, discountRial) {
  if (totalRial < 0 || discountRial < 0) {
    throw new RangeError("negative amount");
  }
  if (discountRial >= totalRial) {
    return 0;
  }
  return totalRial - discountRial;
}

Test Suite اولیه فقط این مورد را دارد:

expect(payableRial(1_000_000, 200_000)).toBe(800_000);

این Test مسیر عادی را خوب بررسی می‌کند، ولی قرارداد کامل تابع را نه. چند Mutant ممکن و تفسیر آن‌ها:

Mutant نتیجهٔ محتمل با Test فعلی شکاف احتمالی
- به + Killed Oracle خروجی مسیر عادی اختلاف را می‌بیند.
>= به > Survived یا No Coverage دادهٔ مرزی discount == total وجود ندارد.
return 0 به return 1 Survived یا No Coverage خروجی Branch سقف تخفیف Assert نشده است.
discountRial < 0 به false Survived رفتار ورودی منفی و Exception بررسی نشده است.
حذف شرط سقف تخفیف Survived Testی برای جلوگیری از مبلغ منفی وجود ندارد.

Testهای بهتر، نه Testهای بیشتر برای Score

expect(payableRial(1_000_000, 0)).toBe(1_000_000);
expect(payableRial(1_000_000, 200_000)).toBe(800_000);
expect(payableRial(1_000_000, 1_000_000)).toBe(0);
expect(payableRial(1_000_000, 1_200_000)).toBe(0);
expect(() => payableRial(1_000_000, -1)).toThrow(RangeError);

این مجموعه فقط Mutantها را تعقیب نمی‌کند؛ Contract دامنه را بیان می‌کند: مبلغ منفی نامعتبر، تخفیف صفر، مسیر عادی، مرز برابر و تخفیف بیش از مبلغ. در UI ممکن است تومان نمایش داده شود، اما Test واحد باید واحد canonical ریال را صریح نگه دارد و تبدیل نمایش را جدا بیازماید تا یک Test صرفاً برای کشتن Mutant، خطای واحد پول را پنهان نکند.

Mutation Operator چیست؟

Operator قاعدهٔ تبدیل است و Mutant یک نمونهٔ اعمال‌شده از آن. خانواده‌های رایج:

خانواده نمونه چه ضعفی را به چالش می‌کشد؟
Conditional boundary >= → > مقادیر مرزی و Off-by-one
Negate conditional == → != مثال‌های مثبت/منفی و Branch Oracle
Arithmetic - → + محاسبه و Assertion نتیجه
Boolean true → false رفتار Flag و Guard
Return value مقدار → صفر/خالی/null قدرت بررسی خروجی و Null/Empty semantics
Remove call/statement حذف Side effect Oracle پیام، ذخیره، Audit یا تعامل
Increment ++ → -- Loop، شمارش و Termination

فهرست Mutation Operatorهای PIT نشان می‌دهد Mutatorها Default، اختیاری یا آزمایشی‌اند و برخی فیلترهای Equivalent دارند. فعال‌کردن همهٔ اپراتورها لزوماً بهتر نیست؛ ممکن است زمان و Mutant کم‌ارزش را زیاد کند. Operator set را نسخه‌بندی کنید و تغییر آن را مثل تغییر مخرج یک KPI گزارش دهید.

وضعیت Mutantها را درست بخوانید

وضعیت معنا اقدام درست
Killed حداقل یک Test در حضور Mutant Fail شده است. بررسی کنید Fail از Assertion معنادار است، نه Crash یا آلودگی نامرتبط.
Survived کد Mutant لمس شده، ولی Testهای اجراشده سبز مانده‌اند. Reachability/Data/Propagation/Oracle/Equivalent و ریسک را Triage کنید.
No Coverage هیچ Test منتخب خط/ناحیهٔ Mutant را اجرا نکرده است. ابتدا مالکیت و نیاز به پوشش آن رفتار را تصمیم بگیرید.
Timeout اجرای Mutant از بودجه گذشته؛ شاید Loop بی‌نهایت ساخته است. Timeout واقعی را از کندی/Flaky محیط جدا کنید؛ سیاست ابزار را برای Score بدانید.
Compile/Runtime Error یا Invalid Mutant قابل Build/Load/اجرای معتبر نبوده است. معمولاً قدرت Test را نشان نمی‌دهد؛ نویز و نسخهٔ ابزار را پایش کنید.
Ignored/Excluded طبق تنظیم یا Suppression اجرا نشده است. دلیل، مالک، Scope و تاریخ بازبینی داشته باشد.
Pending/Not run هنوز رأی نهایی ندارد یا Run ناقص است. نباید مخفیانه Pass یا خارج از مخرج گزارش شود.

نام و فرمول دقیق وضعیت‌ها بین ابزارها فرق می‌کند. صفحهٔ رسمی Mutant states and metrics در Stryker مثلاً Timeout را Detected حساب می‌کند، Compile/Runtime Error را Invalid و خارج از Mutation Score می‌گذارد، و هم Score کل معتبر و هم Score بر مبنای کد Covered را تعریف می‌کند. هنگام مقایسهٔ دو Report، فقط درصد را مقایسه نکنید.

Mutation Score؛ فرمول، مخرج و دام‌های تفسیر

یک فرمول رایج در خانوادهٔ Stryker چنین است:

Detected   = Killed + Timeout
Undetected = Survived + NoCoverage
Valid      = Detected + Undetected
Mutation Score = Detected / Valid × 100

اما این فرمول یک قانون جهانی مستقل از ابزار نیست. برخی Reportها Mutation Coverage، Test Strength یا مخرج متفاوت ارائه می‌کنند؛ برخی Mutantهای Equivalent را خودکار فیلتر نمی‌کنند و برخی Operatorها/خط‌ها از ابتدا Exclude شده‌اند. همراه هر Score این Manifest را نگه دارید:

  • Commit و Build ID؛
  • ابزار، نسخه و Plugin/Test Runner؛
  • Scope فایل/ماژول/Diff؛
  • Operator set و Exclusion/Suppression؛
  • تعداد هر Status، نه فقط درصد؛
  • Baseline و Test selection mode؛
  • Timeout، Concurrency و منابع Runner؛
  • Full، incremental یا partial بودن Run.

مثال عددی

فرض کنید ۱۰۰ Mutant معتبر دارید: ۶۰ Killed، ۵ Timeout، ۲۰ Survived و ۱۵ No Coverage. با فرمول بالا Score برابر ۶۵٪ است. اما این درصد به‌تنهایی نمی‌گوید ۲۰ Survivor در محاسبهٔ مالی‌اند یا در Formatter کم‌ریسک، ۵ Timeout واقعاً Loop بوده‌اند یا Runner شلوغ، و ۱۵ No Coverage کد مرده‌اند یا رفتار حیاتیِ بی‌تست. تصمیم از Breakdown می‌آید، نه رنگ Badge.

آیا Mutation Score باید ۸۰٪، ۹۰٪ یا ۱۰۰٪ باشد؟

آستانهٔ جهانی معتبری وجود ندارد. اعداد پیش‌فرض ابزار برای رنگ Report، استاندارد کیفیت صنعت شما نیستند. Score به زبان، Operator، Scope، فیلتر، معماری، معادل‌ها و مخرج وابسته است. ۸۵٪ در یک Run کوچکِ Diff با Operatorهای قوی قابل‌مقایسه با ۸۵٪ کل Repository و Operator set دیگر نیست.

روش عملی‌تر:

  1. دو یا سه Full Baseline پایدار با Manifest یکسان بگیرید.
  2. Survivorهای ماژول‌های پرریسک را دستی نمونه‌برداری کنید.
  3. برای PR ابتدا Gate کیفی بگذارید: «Survivor جدیدِ پرریسک بدون تصمیم مالک نداریم».
  4. پس از کالیبراسیون، کف Score را برای Scope همگن و با حداقل تعداد Mutant تعریف کنید.
  5. در کنار درصد، Regression تعداد No Coverage/Survived و Run completeness را Gate کنید.
  6. Waiverها باید دلیل، Risk owner و تاریخ انقضا داشته باشند.

مستندات ابزار ممکن است Threshold پیش‌فرض داشته باشد، اما حتی تنظیمات فعلی StrykerJS بین آستانه‌های رنگ گزارش و Break threshold فرق می‌گذارد و حالت پیش‌فرض را طوری می‌گذارد که Build صرفاً به‌خاطر Score Fail نشود. عدد را از Baseline و ریسک خود استخراج کنید.

چرا یک Mutant زنده می‌ماند؟ مدل تشخیصی R-I-P-R

برای کشته‌شدن یک Mutant چهار مرحله باید رخ دهد:

  1. Reachability: Test باید کد Mutation‌شده را اجرا کند.
  2. Infection: داده باید باعث شود حالت داخلی Mutant با کد اصلی فرق کند.
  3. Propagation: این اختلاف باید تا مرز قابل‌مشاهده منتشر شود.
  4. Reveal/Oracle: Test باید اختلاف قابل‌مشاهده را Assert یا تشخیص دهد.
شکست کدام مرحله؟ نمونه درمان محتمل
Reachability Branch Refund هرگز اجرا نمی‌شود. Test سطح مناسب، حذف کد مرده یا تصمیم آگاهانهٔ خارج از Scope.
Infection >=→> با داده‌های دور از مرز تفاوتی نمی‌سازد. دادهٔ مرزی/کلاس هم‌ارزیِ هدفمند.
Propagation اختلاف داخلی بعداً Normalize یا Overwrite می‌شود. مرز مشاهدهٔ درست، طراحی ساده‌تر یا تشخیص Equivalent.
Reveal پاسخ محاسبه شده، ولی Test فقط Status ۲۰۰ را Assert می‌کند. Oracle نتیجه/اثر جانبی قوی و مستقل.

بازبینی Oracle و «Negative proof» باید بخشی از Review کد تست باشد. راهنمای بازبینی تست‌کیس و کد تست نشان می‌دهد چگونه Test Basis، داده، اثر جانبی و شواهد Run را در Peer Review بررسی کنید.

Equivalent Mutant چیست؟

Mutant معادل در دامنهٔ رفتار قابل‌مشاهده با برنامهٔ اصلی تفاوتی ندارد؛ بنابراین هیچ Test معتبری نمی‌تواند آن را بکشد. مثال:

// invariant: count همیشه عدد صحیح مثبت است
if (count >= 1) return "non-empty";

// mutant
if (count > 1) return "non-empty";

این مثال فقط اگر count=1 واقعاً در دامنه ممکن باشد غیرمعادل است. اگر یک Invariant اثبات‌شده مقدار را همیشه حداقل ۲ نگه دارد، تغییر در ورودی‌های reachable تفاوتی ندارد. تشخیص معادل‌بودن در حالت کلی دشوار است؛ ابزارها فقط برخی الگوها را فیلتر می‌کنند. توضیح رسمی Equivalent mutants در Stryker نیز هشدار می‌دهد راه قطعی عمومی برای شناسایی و Ignore خودکار آن‌ها وجود ندارد.

پروتکل تصمیم برای Survivor مشکوک به Equivalent

  1. Mutation و رفتار اصلی را روی ورودی‌های reachable مقایسه کنید.
  2. Precondition/Invariant/Type را با مدرک کد یا Contract ثبت کنید.
  3. بررسی کنید اختلافی در Exception، Side effect، Timing قراردادی یا Precision پنهان نیست.
  4. اگر Test قابل‌معنایی برای تمایز وجود دارد، Mutant معادل نیست.
  5. اگر معادل/نامرتبط است، Suppression کوچک و دقیق با دلیل، مالک و بازبینی دوره‌ای بدهید.
  6. برای بالا بردن Score، Source را به شکل عجیب یا Test بدون ارزش تغییر ندهید.

هر Survivor الزاماً Test جدید نمی‌خواهد

نوع Survivor تصمیم نمونه
رفتار پرریسک و Observable داده/Oracle را تقویت و Mutant را بکشید.
کد مرده یا Feature حذف‌شده به‌جای Test، کد را با Review ایمن حذف کنید.
کد Generated/Vendor از Scope خارج و در مرز Integration/Contract تست کنید.
Logging/Telemetry غیرقراردادی اگر برای عملیات لازم نیست Exclude؛ اگر SLO/امنیت به آن وابسته است Oracle بسازید.
Equivalent با اثبات Suppression دقیق و قابل‌بازبینی، نه Test مصنوعی.
کد به‌شدت سخت‌تست ممکن است نیاز به Refactor/Seam باشد، نه Mock بیشتر.
ریسک پذیرفته‌شده Owner، دلیل، مدت و Residual risk را ثبت کنید.

اگر بخش بزرگی از Survivorها فقط به‌خاطر Clock، Random، Static state، Thread یا Dependency پنهان قابل‌کنترل نیستند، مسئلهٔ پایه‌ای Testability دارید. الگوی Control→Observe→Oracle→Reset در راهنمای تست‌پذیری نرم‌افزار به درمان ریشه‌ای کمک می‌کند.

پیش‌نیازهای یک Pilot قابل‌اعتماد

  • Baseline Testها بدون Mutation سبز، مستقل و با نرخ Flaky شناخته‌شده باشند.
  • Build، Lockfile و Runtime قابل‌تکرار و نسخه‌بندی شده باشند.
  • Production code از Test، Generated، Migration و Vendor scope جدا باشد.
  • مالک Domain مشخص کند کدام Survivor واقعاً مهم است.
  • Testها Oracle رفتاری داشته باشند، نه فقط Snapshot/Interaction داخلی.
  • Runner منابع کافی، Timeout کالیبره و ایزولاسیون Process/State داشته باشد.
  • Report جزئی و ماشین‌خوان به‌عنوان Artifact با Retention مناسب ذخیره شود.
  • هیچ Source، Credential یا Report خصوصی به Dashboard عمومی فرستاده نشود.

ابزار تست جهش را چگونه انتخاب کنیم؟

اکوسیستم گزینهٔ شناخته‌شده نکتهٔ ارزیابی
Java/JVM PIT/Pitest Bytecode mutation، Test selection، Maven/Gradle integration و Operator filtering
JavaScript/TypeScript StrykerJS Incremental report، coverage analysis، runner/plugin compatibility و thresholds
.NET Stryker.NET Project scope، baseline، reporters و Mutator level
Scala Stryker4s sbt/Mill integration و مجموعهٔ اپراتورها
Python mutmut pytest selection، incremental cache، fork/WSL limitation و apply/browse workflow
PHP Infection AST mutators، PHPUnit/Pest integration و changed-files execution
C/C++ Mull Compiler/LLVM compatibility، build mode، runner و report schema

برای Python، مستندات فعلی mutmut اجرای Incremental، انتخاب Testهای مرتبط، TUI برای Browse/Apply و محدودیت Fork/WSL در Windows را توضیح می‌دهد. در PHP نیز راهنمای رسمی Infection اجرای محدود به فایل‌های تغییرکرده را برای Repositoryهای بزرگ نشان می‌دهد. این‌ها قابلیت‌های ابزارند؛ انتخاب نهایی را با یک Pilot روی کد و CI خود انجام دهید.

ماتریس Pilot ابزار

  • زبان/نسخهٔ Runtime و Test runner واقعاً پشتیبانی می‌شود؟
  • Dry run، test selection و نتیجه‌ها deterministic هستند؟
  • Operatorها قابل‌فهم، قابل‌نسخه‌بندی و قابل‌Exclude دقیق‌اند؟
  • Changed-code/incremental mode و Full baseline هر دو دارید؟
  • Report شامل Diff، Status، Test قاتل و JSON/SARIF یا فرمت قابل‌پردازش است؟
  • Monorepo، Parallel worker و منابع Runner چگونه مدیریت می‌شوند؟
  • Cache invalidation با تغییر Source، Test، Dependency و Config درست است؟
  • License، نگهداری، Registry/Proxy و استفادهٔ آفلاین در ایران مناسب است؟

هزینهٔ اجرا را بدون قربانی‌کردن معنا کم کنید

تقریب خام هزینه چنین است:

Cost ≈ تعداد Mutantهای اجراشده × میانگین زمان Testهای منتخب
       + Build/Instrumentation + Report/Triage

به‌جای اجرای کل Suite برای هر Mutant:

  • Test selection: Testهایی را اجرا کنید که خط Mutant را پوشش می‌دهند.
  • Changed/Diff mutation: در PR روی کد تغییرکرده و اثر آن تمرکز کنید.
  • Incremental cache: نتیجهٔ معتبر قبلی را با invalidation محافظه‌کارانه استفاده کنید.
  • Risk scope: ابتدا منطق مالی، امنیتی، مجوز و State transition را هدف بگیرید.
  • Stable operator set: Operatorهای پرنویز/کم‌ارزش را با Evidence کالیبره کنید.
  • Parallelism محدود: Worker را با CPU/RAM و Isolation هماهنگ کنید؛ بیشتر همیشه سریع‌تر نیست.
  • Fail fast و timeout: بعد از نخستین Test قاتل توقف کنید، ولی Timeout را درست کالیبره کنید.
  • Scheduled full run: محدودیت Diff/Incremental را با Baseline دوره‌ای جبران کنید.

مستندات Incremental mode در StrykerJS صریحاً می‌گوید ابزار Diff کد و Test را با Report پیشین تطبیق می‌دهد، اما تشخیص تغییر Test/فایل‌های جانبی محدودیت دارد و Dry run همچنان لازم است. Cache سرعت است، نه حقیقت دائمی؛ تغییر Lockfile، Config، Generated source یا Dependency باید سیاست invalidation داشته باشد.

طراحی Mutation Testing در CI/CD

Lane Scope Gate Artifact
Local/on demand تابع/فایل هدف غیرمسدودکننده؛ برای Triage Diff Mutant و Test قاتل
Pull Request Changed code + ماژول‌های اثرپذیر Survivor پرریسک جدید، Run ناقص یا Regression توافق‌شده Summary + JSON/HTML + Manifest
Nightly ماژول‌های پرریسک با Operator set کامل‌تر Alert/Issue؛ نه لزوماً Block فوری Trend و صف Triage
Weekly/full Baseline همگن کل Scope Drift، cache miss و Score قابل‌مقایسه Full report نسخه‌بندی‌شده

Job باید ابتدا Unit Test عادی را اجرا کند؛ اگر Baseline قرمز یا Flaky است، Mutation Result نامعتبر/Inconclusive است. سپس Tool با نسخهٔ Pin‌شده روی Source مشخص اجرا شود. Exit code، Timeout و Report absence باید صریح تفسیر شوند. قواعد کلی Lane، Gate و Evidence در معماری Continuous Testing آمده است و جزئیات Artifact/JUnit/Runner در راهنمای تست خودکار در GitLab CI قابل استفاده است.

نمونهٔ قرارداد Job مستقل از ابزار

inputs:
  commit_sha, base_sha, tool_version, operator_set, scope
preconditions:
  build_passed, baseline_tests_passed, flaky_rate_within_policy
run:
  mutation_tool --changed-since base_sha --report json,html
gate:
  report_exists
  run_complete
  no_new_unreviewed_high_risk_survivor
outputs:
  mutation-report.json, report.html, manifest.json, logs
never:
  publish_or_deploy_mutated_artifact

Diff Mutation و Full Mutation چه تفاوتی دارند؟

Diff Mutation برای بازخورد PR مناسب است، اما فقط دربارهٔ Scope تغییر‌یافته شواهد می‌دهد. یک تغییر Test، Config یا Dependency می‌تواند قدرت Suite را در کد ظاهراً بدون تغییر عوض کند. Full Mutation Baseline وسیع‌تر و قابل‌مقایسه می‌سازد، ولی گران‌تر است.

پژوهش Practical Mutation Testing at Scale در Google رویکرد عملی را بر Mutation افزایشی در Code Review، فیلتر Mutantهای کم‌ربط، محدودکردن تعداد Mutant در هر خط/Review و انتخاب Operator بر اساس عملکرد تاریخی بنا می‌کند. این یک الگوی مقیاس است، نه نسخهٔ جهانی برای هر تیم؛ Full run یا نمونه‌برداری دوره‌ای هنوز برای دیدن Drift و محدودیت فیلتر لازم است.

Triage Survivorها؛ از Report تا تصمیم

  1. Scope: آیا فایل و Operator باید در دامنه باشند؟
  2. Reproduce: Mutant را روی همان Commit/Tool/Config بازاجرا کنید.
  3. Reach: Test قاتل بالقوه کد را لمس می‌کند؟
  4. Differentiate: ورودی‌ای هست که رفتار اصلی و Mutant را متفاوت کند؟
  5. Observe: اختلاف به خروجی یا Side effect قراردادی می‌رسد؟
  6. Risk: اگر این تغییر واقعی بود پیامدش چیست؟
  7. Action: Test/Oracle/Data، Refactor، Delete code، Suppress یا Accept risk.
  8. Evidence: Test نام‌دار باید ابتدا با Mutant Fail و سپس با کد اصلی Pass شود.

کارت تصمیم Survivor

فیلد نمونه
Mutant discount >= total → discount > total
Risk در مرز برابر، مبلغ قابل‌پرداخت منفی/غلط یا سفارش گیرکرده
Current gap هیچ داده‌ای با discount == total وجود ندارد
Oracle payableRial == ۰ و هیچ درخواست PSP برای مبلغ صفر
Decision Add boundary test
Negative proof Test جدید روی Mutant Fail و روی Commit اصلی Pass شد
Owner/expiry Checkout team / بدون Waiver

معیارهای سالم برای برنامهٔ Mutation Testing

  • Mutation Score با Manifest و Breakdown هر Status؛
  • تعداد و سن Survivorهای پرریسکِ بدون تصمیم؛
  • No Coverage در کد تغییرکرده و پرریسک؛
  • درصد Runهای Complete در برابر Inconclusive/Error؛
  • زمان PR feedback و Full baseline؛
  • نرخ Equivalent/Noise به تفکیک Operator؛
  • زمان Triage هر Mutant و Reopen rate تصمیم‌ها؛
  • درصد Testهای بهبودیافته با Negative proof؛
  • Mutation regression روی Scope همگن؛
  • Defectهای واقعی که Survivor/Operator مشابهشان قبلاً دیده شده بود.

این سنجه‌ها را به هدف تیم پیوند دهید و از Rank‌کردن افراد یا اجبار به تولید Test برای Badge پرهیز کنید. مقالهٔ متریک‌های تست نرم‌افزار روش تعریف مخرج، Freshness، Owner و تصمیم قابل‌اتکا را توضیح می‌دهد.

Mutation Testing در پروژه‌های ایرانی

  • واحد پول: Mutantهای مرزی و حسابی را روی مقدار canonical ریال اجرا کنید؛ تبدیل تومان را در مرز نمایش جدا نگه دارید.
  • Unicode: Operatorهای String ممکن است دربارهٔ رقم فارسی/عربی، نیم‌فاصله و Normalization رفتار واقعی دامنه را خوب نمایندگی نکنند؛ Testهای Data-driven مکمل لازم‌اند.
  • زمان: Clock تزریق‌پذیر و UTC/Asia-Tehran صریح باشد تا Mutantهای مرزی تاریخ/انقضا Flaky نشوند.
  • دسترسی ابزار: Package، Plugin و Container تاییدشده را در Registry/Proxy داخلی Cache و Digest/Checksum را ثبت کنید.
  • CI محدود: Diff Mutation در PR، ماژول پرریسک Nightly و Full چرخشی از اجرای کامل هر Commit اقتصادی‌تر است.
  • حریم کد: Report یا Source سازمانی را بدون مجوز به Dashboard خارجی نفرستید؛ Reporter محلی و Artifact کنترل‌شده کافی است.
  • درگاه پرداخت: Mutation واحد منطق داخلی، جای Sandbox/Contract/Idempotency و Callback واقعی PSP را نمی‌گیرد.

نقش‌ها و تصمیم‌گیری

نقش مسئولیت
Developer Scope، Reproduce، Test/Refactor و Negative proof
QA/SDET Oracle، داده، Triage model، Operator calibration و Report semantics
Domain/Product owner قرارداد رفتار، پیامد و پذیرش Residual risk
Platform/CI Runner، Cache، Isolation، Artifact، Secret و بودجهٔ اجرا
Test/Engineering lead Policy، Threshold همگن، Waiver، Trend و تصمیم Scale

مالکیت Testها و معماری کد تست نیز اهمیت دارد؛ Suite چسبیده به جزئیات داخلی با هر Refactor می‌شکند و Mutation را پرهزینه می‌کند. برای مرزبندی Driver، Fixture، Data و Assertion به راهنمای کد تست قابل نگهداری مراجعه کنید.

برنامهٔ ۳۰روزهٔ استقرار Mutation Testing

هفتهٔ اول: Baseline و Scope کوچک

  • یک ماژول ۲۰۰ تا ۵۰۰ خطی با منطق واقعی و Test سریع انتخاب کنید.
  • سه بار Baseline Test را برای Flaky/زمان ثبت کنید.
  • ابزار، نسخه، Operator default و Report محلی را Pin کنید.

هفتهٔ دوم: Triage دستی

  • ۱۰ تا ۲۰ Survivor را با R-I-P-R تحلیل کنید.
  • Testهای بهبود‌یافته را با Negative proof نشان دهید.
  • Equivalent/Noise و Operatorهای نامرتبط را مستند کنید.

هفتهٔ سوم: PR غیرمسدودکننده

  • Diff Mutation را پس از Baseline سبز اجرا کنید.
  • Report JSON/HTML، Manifest و Run completeness را Artifact کنید.
  • زمان بازخورد، False alarm و ظرفیت Triage را بسنجید.

هفتهٔ چهارم: Policy و تصمیم Scale

  • Gate را ابتدا بر Survivor پرریسکِ بی‌تصمیم و Report ناقص بگذارید.
  • Full baseline دوره‌ای و Cache invalidation را اضافه کنید.
  • فقط پس از دادهٔ کافی Threshold درصدی همگن تعریف کنید.
  • هزینه، ارزش کشف و بار نگهداری را با گزینهٔ توقف/اصلاح مقایسه کنید.

گزارش State of Mutation Testing at Google نیز Mutation را در مقیاس Code Review و با تمرکز بر بازخورد قابل‌اقدام بررسی می‌کند. درس قابل‌انتقال، دنبال‌کردن Score سراسری نیست؛ نزدیک‌کردن Mutant مرتبط به تغییر و تصمیم Developer است.

خطاهای رایج در تست جهش

  1. Score به‌جای سؤال: تیم نمی‌داند کدام ریسک و Scope را می‌سنجد.
  2. آستانهٔ ۸۰/۹۰٪ کپی‌شده: عدد ابزار به استاندارد سازمان تبدیل می‌شود.
  3. فعال‌کردن همهٔ Mutatorها: هزینه و Equivalent/Noise بالا می‌رود.
  4. Test برای کشتن Mutant: Assertion مصنوعی بدون معنای Contract نوشته می‌شود.
  5. Ignore گسترده: Directory یا Operator کامل فقط برای سبزشدن حذف می‌شود.
  6. Baseline قرمز/Flaky: Kill و Timeout قابل‌اعتماد نیستند.
  7. مقایسهٔ Score ناهمگن: Scope، نسخه یا Operator عوض شده ولی Trend ادامه می‌یابد.
  8. هر Survivor = Bug: Equivalent، کد مرده، Risk accepted و Tool noise نادیده می‌ماند.
  9. فقط Unit: Mutant Side effect یا Contractی در سطح نامناسب تحلیل می‌شود.
  10. Full run در هر Commit: CI کند و Mutation خاموش می‌شود.
  11. Cache بی‌اعتبار: تغییر Config/Dependency/Test در Incremental result دیده نمی‌شود.
  12. Artifact جهش‌یافته: کد Mutation‌شده با محصول قابل‌انتشار مخلوط می‌شود.
  13. Dashboard عمومی: Source/Report حساس بدون بررسی داده ارسال می‌شود.
  14. KPI فردی: افراد برای Score بازی می‌کنند و Testهای بی‌ارزش اضافه می‌شوند.

چک‌لیست نهایی

  • هدف، ریسک، Scope و Non-goal نوشته شده‌اند.
  • Baseline Test سبز، پایدار و زمان‌سنجی شده است.
  • ابزار/نسخه/Runner/Operator set Pin شده‌اند.
  • Production code از Test/Generated/Vendor دقیق جدا شده است.
  • هر Result status و مخرج Score طبق ابزار فهمیده شده است.
  • Manifest شامل Commit، Scope، Mode، Timeout و منابع است.
  • Survivor با R-I-P-R و Risk، نه فقط Score، Triage می‌شود.
  • Equivalent/Suppression دلیل، Owner و تاریخ بازبینی دارد.
  • Test جدید Contract را بیان و Negative proof دارد.
  • PR Diff Mutation با Full baseline دوره‌ای تکمیل می‌شود.
  • Report ناقص، Error و Flaky به‌عنوان Inconclusive پنهان نمی‌شوند.
  • Mutation Artifact هرگز Deploy نمی‌شود.
  • Threshold فقط روی Scope/مخرج همگن و پس از Baseline تعریف شده است.
  • هزینهٔ اجرا/Triage و ارزش شواهد مرتب بازبینی می‌شوند.

پرسش‌های متداول دربارهٔ Mutation Testing

آیا تست جهش واقعاً «تستِ تست‌ها» است؟

به‌صورت آموزشی بله، اما دقیق‌تر این است که Mutation Testing حساسیت Test Suite را به تغییرهای مصنوعی مشخص می‌سنجد. نتیجه به Scope، Operator، داده، Oracle و وضعیت‌های ابزار وابسته است و دربارهٔ تمام انواع Defect یا کیفیت کلی محصول حکم نمی‌دهد.

تفاوت Survived و No Coverage چیست؟

در No Coverage هیچ Test منتخبی محل Mutant را اجرا نکرده است. در Survived کد معمولاً اجرا شده، اما تغییر باعث اختلاف قابل‌تشخیص نشده یا Test آن اختلاف را Reveal نکرده است. اولی بیشتر مسئلهٔ Reachability است؛ دومی می‌تواند داده، Propagation، Oracle یا Equivalent باشد.

آیا برای هر Mutant زنده باید Test بنویسیم؟

خیر. ابتدا ریسک و علت را تحلیل کنید. Test زمانی ارزشمند است که تغییر، رفتار قابل‌مشاهده و مهمی را نقض کند. کد مرده بهتر است حذف شود، Mutant معادل باید با مدرک Suppress شود و کد Vendor/Generated ممکن است در سطح دیگری Evidence بخواهد.

Mutation Score خوب چند درصد است؟

عدد جهانی وجود ندارد. Tool، Operator، Scope، Equivalent، No Coverage و فرمول روی درصد اثر می‌گذارند. Baseline همگن بسازید، Breakdown را ببینید، Survivor پرریسک را تصمیم‌گیری کنید و بعد برای همان Scope و مخرج Threshold تعیین کنید.

Mutation Testing را در هر Pull Request اجرا کنیم؟

برای Repository متوسط/بزرگ معمولاً Full run در هر PR پرهزینه است. Diff/Incremental روی کد تغییرکرده، Test selection و سقف زمان برای PR مناسب‌تر است؛ Nightly یا Full دوره‌ای محدودیت Cache و Scope را جبران می‌کند. اگر Baseline Test قرمز است، Job جهش باید Inconclusive شود، نه اینکه Score تولید کند.

جمع‌بندی

تست جهش زمانی ارزش می‌سازد که از Badge درصدی به یک حلقهٔ تصمیم تبدیل شود: Scope و ریسک → Baseline سبز → Mutant معتبر → Test منتخب → Status دقیق → Triage R-I-P-R → Test/Refactor/Suppression/قبول ریسک → Negative proof و Evidence.

از یک ماژول کوچک و پرریسک شروع کنید، اپراتورها و مخرج را ثابت نگه دارید، Survivorها را به زبان Contract تحلیل کنید و Diff Mutation را با Full baseline دوره‌ای ترکیب کنید. هدف کشتن همهٔ Mutantها نیست؛ هدف کشف تست‌هایی است که کد را اجرا می‌کنند اما تغییر مهم رفتار را نمی‌بینند—بدون اینکه تیم برای یک Score قابل‌بازی، Test بی‌معنا تولید کند.

دیدگاهتان را بنویسید