معضل تست‌های متزلزل/flaky

(ویرایش: یک سیستم نمونه در نوشتار دیگری ارائه شده که برخی مفاهیم نوشتار زیر را ملموس‌تر می‌کند.)برای داشتن یک سیستم نرم‌افزاری قابل اعتماد، سیستم شما لایه‌های فزاینده‌ای از تست خواهد داشت و همه می‌دونیم چرا. ولی در رویِ کمتر بحث شده‌ی سکه، شاهد تیم‌های گوناگونی بوده‌ام که زیر هزینه‌های فزاینده‌ی این تست‌ها کمر خم می‌کنن/می‌کنیم علی‌رغم تلاش‌های فراوان (حتی تشکیل تیم‌های مختص) برای خروج از این وضعیت. از پیش بگم که راه حل جادویی عمومی وجود نداره و کاملا به طبیعت سیستم و نیازمندی‌های کسب و کار بستگی داره. هدف این گفتار به بحث گذاشتن تله‌ایست که سیستم به سمتش می‌ره. همچنین نیاز به گفتن نیست که این مشکلات به هیچ وجه به معنی «تست‌های کمتری داشته باشیم» نیست.جریانِ کار استاندارد و تمیزمرور خیلی خیلی کوتاهی بکنیم بر «چه تست‌هایی و کِی» تا همه برسیم سر یک صفحه. اگر سیستمی دارید با continuous integration یا CI خوب و مکفی، می‌تونید از این بخش رد بشید.سیستم CI (مثلا Jenkins) قراره با منبع کُد (مثلا Git) و سیستم بازبینی کد (مثلا Gerrit) کار کنه، و اولا سرِ منبع کد (HEAD) رو به طور پیوسته و دوما هر changelist/patch توسعه‌دهندگان رو پیش از اجازه‌ی commit، در برابر همه‌ی تست‌های موجود بسنجه. البته شاید تصور بشه که با وجود دومی، اولی زیادیه ولی چنین نیست، و نیز شاید به ازای «هر» changelist شدنی نباشه که دیگه جزییاته. خلاصه سرِ منبعِ کد همیشه تمیز است و آماده‌ی برون‌دهی (release). از برون‌دهی و قناری‌گری اگر عمری بود بعدا خواهم نوشت.توجه کنید که این برخلاف رویه‌ی منسوخی‌ست که بیشتر تست‌ها رو به زمان برون‌دهی موکول می‌کنه و برخی جاها رایجه ولی نه در شرکتی که سرش به تنش بیارزه. (ادعای نادقیق برای تمثیل)حالا چه تست‌هایی؟ برای یک سیستم معمولی شامل چند سرویس که با هم تعامل می‌کنن---اون مستطیل‌هایی که به عنوان معماری سیستم روی تخته می‌کشید و در قالب یک درخت یا DAG از فراخوانی‌ها با پیکان به هم وصل می‌کنید---در نقطه‌ی بلوغ احتمالا صدها یا هزارها تست واحد (unit test) برای کُدها، چندین و چند جور تست یک‌پارچه‌سازی (integration test)، تست‌های بار و چکش‌خواری سرورها (load/stress/resilience test)، تست‌های الف/ب (A/B) و پیش‌گیری از پس‌رفت (regression)، و شاید تست‌های دیگر بسته به ماهیت سرویس‌ها خواهیم داشت. سپس احتمالا یک یا چند مرحله قناری برای فرآیند برون‌دهی هر یک از سرویس‌ها.نام تست‌ها مهم نیست، شاید شما به نام‌های دیگری بشناسید. تست واحد نیازی به توضیح نداره، فقط یک خطای رایج به ویژه توسط توسعه‌دهنده‌های تازه‌کار که باید هنگام بازبینی کُدشون حواسمون بهش باشه اینه که برخی تست‌ها نوشته نمی‌شه چون زیادی بدیهی‌اند، ولی فراموش می‌شه که تست واحد فقط برای امروز نیست بلکه حِفاظی برای سوتی‌های احتمالی در تغییرات آینده (که کد شاید نابدیهی می‌شه) هم هست. سپس جزءهایی که در انزوا (isolation) تست شدن رو کنار هم می‌ذاریم و مجموعه‌شون رو تست می‌کنیم: از مجموعه‌ی زیرمولفه‌ها به یک مولفه، به یک سرویس، به مجموع سیستم، و خلاصه هر چیزی این میان می‌شه تست یک‌پارچه‌سازی، مثلا ورودی الف رو می‌دیم که بره در مجموعه بچرخه و در پایان انتظار خروجی ب رو داریم. تست‌های بار برای سنجیدن کارایی سرویس و واکنش به فشار است و تست الف/ب برای اطمینان از چیزی که اصطلاحا بهش می‌گن «گذار سبز به سبز»، مثلا بین HEAD کنونی و changelist جدید یا بین نسخه‌ی برون‌دهی‌شده‌ی کنونی و کاندید بعدی نباید تفاوت غیرمنتظره‌ای باشه.تا اینجا زندگی به نظر ساده‌ست و انواع تست‌های نام برده و نام نبرده هم در منابع فراوان تشریح شده‌اند.در پرانتز: همه‌ی آن‌چه گفته شد،‌در حالت ایده‌آل،‌ بدون مداخله‌ی انسانی‌ست، یعنی رسیدن به کیمیای «push on green» - در این باره این مقاله‌ی خواندنی از گوگل رو ببینید. ولی بسیاری از تیم‌های خود گوگل به این کیمیا نرسیدن. البته باز هم مداخله‌ی انسانیِ باقی مانده، در خط دوم دفاعی است مثلا مرور نتیجه‌هایی که اتوماتیک سبز نشدن. اگر در سیستم شما، مداخله‌ی انسانی در خط اول دفاعی‌ست، مثلا هر بار دستی اجرا کردن تست‌ها و دستی چک کردن نتایج، جا داره یک بازنگری با دید بلند مدت بکنید - احتمالا کارهای بهتری بشه با اون نیروی انسانی کرد.روی دیگر سکهمثال برای بقیه‌ی متن: فرض کنید یک فروشگاه اینترنتی داریم با قابلیت‌های ساده‌ای مثل فروش کالاهای خودمون و نیز فروشنده‌های بیرونی، امکان جستجو و خرید برای مشتری، امکانی برای فروشنده‌های بیرونی برای ترویج کالا در نتایج جستجو و پیشنهاددهی به کاربر و غیره.سیستم ما که همه‌ی تست‌های گفته شده رو پاس کرده بود، ناگهان در دنیای واقعی سوتی می‌ده علی‌رغم این که پوشش تست (test coverage) خوب داشت. مثلا برای نصف شهر خطای ارسال کالا می‌دیم یا کالاهای فروشنده‌های بیرونی رو نمی‌فروشیم. چرا؟ چون مثلا فلان field فلان protocol buffer که یک تکه از نسخه‌ی جدیدمون روش حساب می‌کنه مدتی‌ست که دیگه در ورودی set نمی‌شه چون apple یک update جدید داده، یا چون فروشنده‌های بیرونی ابزار اتوماسیون راه انداختن برای تعامل با سیستم ما و رفتارشون عوض شده، یا هر اتفاق ارگانیک و غیرارگانیک دیگری. خلاصه: تست «ورودی الف رو بده و خروجی ب رو ببین»، در دنیای واقعی که از دیروز به امروز و از اینجا به اونجا زمین بازی عوض می‌شه، واقع‌گرایانه نیست.طبیعتا این شکاف رو با تست‌های واقعی‌تر پر می‌کنیم: درخواست‌های واقعی و تازه از محیط عملیاتی (به جای درخواست‌های مصنوعی یا حتی واقعی ولی ایستا)، داده‌ها/datasetهای زنده و به-روز، مدل‌های یادگیری شده‌ی زنده، حتی اجازه دادن به سرورِ زیرِ تست برای صحبت با backendهای واقعی به جای ماکت.احتمالا حدس زدید که درِ چه لونه زنبوری رو باز کردیم: عدم تَعَیّن (non-determinism). یعنی:ورودی و محیط نامعین رو به چیزی که داره تست می‌شه می‌دیم و انتظار داریم خروجی‌ش «مشابه» فلان انتظار کلّی (مثلا آماری)‌ باشه.متداول‌تر: ورودی و محیط نامعین رو به چیزی که داره تست می‌شه می‌دیم و همچنین به یک مرجع/baseline (مثلا نسخه‌ی قبلی) می‌دیم و انتظار خروجی «مشابه» داریم.«چیزی که داره تست می‌شه» یعنی مثلا یکی از سرویس‌های وسط سیستم شامل همه‌ی سرویس‌های پُشتش (یک زیردرخت از سیستم)، مثلا فرستادن دو درخواست به سرویس پیشنهاددهی در مثال بالا: بین نسخه‌ی قبلی و جدیدِ سرویس، یا بین HEAD و یک changelist تازه که منتظر اجازه‌ی submit است، و سپس انتظار پاسخ مشابه. منظور از پاسخ مشابه طبیعتا فقط اون کالای نهایی که پیشنهاد داده می‌شه نیست (یک چکِ به این گَل و گشادی بیشتر bugها رو شکار نخواهد کرد) بلکه شامل مجموعه‌ی داده‌ساختاری‌ست که سرویس برمی‌گردونه مثلا شامل fieldهایی سطح بالا مثل «احتمال کلیک» و «از سفارش دهنده چه پولی بگیریم» و سطح پایین مثل «تاخیر» و «چندتا بر ثانیه»، که همه قراره بین دو نسخه‌ی آزمایش شده مشابه دربیان - اگر مشابه در نیان یعنی نیاز به بررسی انسانی.مشابه یعنی چی؟ یعنی باتلاق. یعنی اگر بگید «الف مشابه ب = الف تا ۱۰٪ی ب» (تازه به این فرضِ غلط که همه‌ی fieldها اعداد حقیقی‌اند و نه مثلا رشته)، به دلیل عدم تعیّن، گاه و بی‌گاه روی چندتا از فیلدهای مقایسه شده خطای الکی می‌گیرید. یعنی اگر الف رو با خود الف هم مقایسه کنید اختلاف/خطا می‌گیرید. هنگام تست کردن یک سرویس با ورودی و داده‌ها و مدل‌های زنده و backendهای واقعی، دیگه مساله فقط ثابت نگه داشتن چندتا random seed محلی برای بازتولیدپذیری (reproducibility) نیست. سپس برای هر یک field از ده‌ها و صدها field مقایسه شده، شروع می‌کنید به شُل کردن آستانه‌ها (thresholds) برای این field و اون field، ده‌ها و صدها تا. فرسایشی بودنِ این امر برای تیم به کنار، خودِ شل کردن آستانه‌ها یعنی دانه‌درشت‌تر کردن توری که برای شکار تغییرات پهن شده بوده، یعنی چیزهای بیشتری که قرار بوده شکار و جلوگیری بشن دیگه نمی‌شن. پس به همین راحتی هم آستانه رو برای این field و اون field شل نمی‌کنیم بلکه برای هر یک از اون ده‌ها و صدها مورد شل کردن، وقت و انرژی صرف بررسی می‌کنیم.نتیجه؟ وقتی کُدتون رو نوشتید و changelist شما رفت توی سیستم CI، در تست‌ها رد می‌شه - اگر فقط ۴تا تست داشته باشید که فقط ۲۰٪ مواقع متزلزل باشن، changelist شما به احتمال ۵۹٪ حداقل یکیش رو رد می‌شه حتی اگر فقط مثلا rename کردن یک متغیر باشه. این مشکل از قضا در تیم‌های ما زیاده چون سیستم و تست‌ها در طی سالیان دیگه زیادی بزرگ پیچیده شدن و بازگشت و بازطراحی سخته. نتیجه؟ضربه به سرعت توسعه - development velocity یکی از شاخص‌های مهم برای یک کسب و کارِ نوآوره.بار روانی و کاهش رضایت کاری توسعه‌دهندگان.اتلاف وقت و انرژی تیم که باید صرف کارهای کلیدی‌تر بشه تا سر و کله زدن با تست‌های متزلزل (flaky).راه حلاگر در هر یک از مراحل بالا به ذهنتون رسید که «سخت نگیریم»---مثلا در مثالِ سیستم پیشنهاددهی فکر کردید تنها پیشنهاد نهایی رو چک کنیم یا در مثالِ شُل کردن آستانه‌ها به ازای هر ‌field فکر کردید حالا به ازای هر field نباشه و یک آستانه‌ی کلّی رو برای مجموعه‌ی پاسخ شل کنیم---باید بگم فرق یک محصول با کیفیت جهانی و یک محصول که down‌ بودنش برای مشتری عادی شده در همین وسواس روی کوتاه نیامدن سرِ کیفیته - البته قصدم پُز دادن و نگاه بالا به پایینِ معلّمی نیست بلکه نشان دادن جدیّت کاره.چنان که گفته شد، راه حل جادویی و عمومی وجود نداره و به طبیعت سیستم بستگی داره.یک راه حل گول‌زننده اینه که اگر سرویس الف رو تست می‌کنیم، backendهای الف رو ماکت بذاریم - ورودی مشخص از الف بگیرن یا اصلا ورودی الف رو نادیده بگیرن و همیشه خروجی مشخص بدن. البته این تست خوب و لازمیه برای تست کردن الف در انزوا (isolation)، ولی کافی نیست چنان که گفته شد.یک راه حل گول‌زننده‌ی دیگه اینه که آستانه‌ها برای مقایسه‌ی خروجی رو بر اساس تست الف/الف تعیین کنیم، یعنی بگیم اگر یک چیز رو با خودش مقایسه کنیم ۳۰٪ اختلاف در مقادیر می‌گیریم پس آستانه‌ی خطا رو بذاریم ۳۰٪ به بالا، ولی این هم غلطه چون اون وقت خطاهای زیر ۳۰٪ هیچ وقت شکار نمی‌شن - توجه کنید که خطای ۲۵٪ی که شکار نمی‌شه شاید در اصل یک خطای ۱۰۰٪ی برای یک چهارم کاربران (مثلا کاربران android نسخه‌ی X) است. همچنین اصلا اومدیم و این ۳۰٪ شد ۸۰٪.یک راه حل استاندارد برای بالا بردن معناداریِ آماری (و پایین آوردن اون ۳۰٪ در مثال بالا) اینه که فضای نمونه رو بزرگ کنیم یعنی مثلا به جای صد درخواست، صدهزار درخواست تصادفی به چیزی که داره تست می‌شه بفرستیم، یا replicaهای بیشتری بالا بیاریم، یا هر چیزی از این دست. ولی شدنی بودن چنین کارهایی کاملا بستگی به چیزی که تست می‌شه داره.یک راه حل دیگه، نه ماکت گذاشتن برای backendها بلکه cache گذاشتن جلوی backendها برای درخواست‌های تستی‌ست، برای این که پاسخ اول همچنان واقعی و زنده باشه ولی پاسخ دوم هم دقیقا همون اولی باشه، تا عدم تعیّن در سرویسِ زیرِ تست (فراخواننده‌ی اون backend) کنترل بشه.یک راه حل دیگه که ایده‌آل نیست ولی ناگزیره اینه که مثلا داده‌ساختارها (مثلا protocol bufferها) fieldهایی داشته باشن که بگه این یک درخواست تست است. علاوه بر کاربر از جنسِ اطلاع دادن این که مثلا «یه وقت اشتباهی پول از حساب مشتری کم نکنی»، می‌شه به دُم این flag منطق در حد کمینه (minimal) بست، مثلا فلان cache وسط راه (که سرچشمه‌ای برای عدم تعیّن بین نمونه‌ی اول و دومِ یک درخواست یکسان است) دور زده بشه. البته هرگونه چنین دخالتی یعنی دوری از دنیای واقعی، لذا روی کمینه تاکید می‌کنم.شما چه راه حل‌هایی به کار می‌گیرید؟