چرا وباپلیکیشنها بعد از راهاندازی خراب میشوند
نرمافزاری که در دمو اجرا میشود با نرمافزاری که زیر کاربر واقعی دوام میآورد دو چیز متفاوتاند. این متن دربارهٔ فاصلهٔ میان این دو است: خرابیهایی که در این فاصله زندگی میکنند و هر لایهٔ تست جلوی کدامشان را میگیرد.
دمو، بخش آسان ماجراست
نرمافزار در دمو یک کاربر دارد. داده تمیز است، چون سی ثانیه پیش کسی آن را تایپ کرده. شبکه یک متر وایفای دفتر است. ترتیب کلیکها هم همان ترتیبی است که سازنده در ذهن داشته، چون خودِ سازنده دارد کلیک میکند.
محیط عملیاتی هیچکدام از این ویژگیها را ندارد. دو نفر در یک ثانیه یک سفارش را ویرایش میکنند. مقداری از اکسل کپی میشود و با یک فاصلهٔ اضافه و یک نیمفاصلهٔ عجیب وارد سیستم میشود. گوشی وسط آپلود از اینترنت میافتد. کاربر بعد از ثبت فرم دکمهٔ بازگشت مرورگر را میزند و دوباره ثبت میکند. نشست کاربر وقتی یک فرم طولانی باز است منقضی میشود. مشتری در منطقهٔ زمانی دیگری نوبتی رزرو میکند که در محل سرور شما مربوط به دیروز است.
هیچکدام از اینها حالت نادری نیست. همهٔ اینها یک سهشنبهٔ معمولی است. و جملهٔ «روی سیستم من کار میکند» دربارهٔ هیچکدامشان چیزی نمیگوید.
سؤال هیچوقت این نبوده که نرمافزار اجرا میشود یا نه. سؤال این است که از میان هزار اتفاقی که بعدش میافتد، از پس کدامها برآمدنش ثابت شده است.
چرا این وضعیت رایجتر شده است
نوشتن کد هیچوقت به این سرعت نبوده. نوشتن مسیر خوشبینانه از آن هم سریعتر است، چون دقیقاً همان چیزی است که از روی یک توضیح ساخته میشود. اگر فرم رزرو بخواهید، فرم رزرو تحویل میگیرید: فیلدها، دکمهٔ ثبت، پیام موفقیت. دموی آن هم بینقص است.
چیزی که معمولاً در آن توضیح نیست، نیمهٔ دیگر مشخصات است: همان بخشی که میگوید چه چیزی *نباید* اتفاق بیفتد. وقتی درگاه پرداخت یازده ثانیه جواب نمیدهد چه میشود. وقتی همان رزرو دو بار ثبت میشود، چون پاسخ اول کند بوده، چه میشود. وقتی مدیر سیستم همان رکورد را بین باز شدن صفحه و زدن دکمهٔ ذخیره حذف کرده، چه میشود.
این نیمه از نظر فنی سخت نیست. فقط در دمو دیده نمیشود، پس خواسته نمیشود و در نتیجه نوشته هم نمیشود.
این موضوع جدید نیست و مخصوص کد تولیدشده با هوش مصنوعی هم نیست؛ فاصلهٔ نمونهٔ اولیه با محصول همیشه وجود داشته است. چیزی که عوض شده نسبتهاست. ساختن لایهٔ قابل دیدن حالا تقریباً رایگان است، پس نرمافزار بسیار بیشتری به دست کاربر واقعی میرسد که نیمهٔ نامرئیاش اصلاً نوشته نشده. ظاهرش کامل است. «کامل به نظر رسیدن» و «کامل بودن» هیچوقت اینقدر از هم دور نبودهاند.
آنچه واقعاً خراب میشود
خرابیهایی که چند هفته بعد از راهاندازی خودشان را نشان میدهند، در چند خانوادهٔ مشخص جا میگیرند. شناختن همین خانوادهها بیشترِ کار است.
گم شدن یکی از دو نوشتن. دو درخواست یک موجودی را میخوانند، هر دو در کد qty - 1 را حساب میکنند و هر دو نتیجه را مینویسند. یکی از این دو کم کردن ناپدید میشود. نه خطایی رخ میدهد و نه چیزی در لاگ ثبت میشود؛ عدد فقط اشتباه میشود و اشتباه میماند.
دقیق گفتن علت اینجا مهم است، چون راهحل به آن بستگی دارد. یک دستور واحد مثل UPDATE stock SET qty = qty - 1 WHERE id = $1 حتی در سطح ایزولاسیون پیشفرض PostgreSQL یعنی READ COMMITTED هم امن است؛ چون تراکنش دوم پشت قفل سطر منتظر میماند و بعد مقدار بهروزشده را دوباره میخواند. چیزی که نوشتن را گم میکند، خواندن و محاسبه و نوشتنِ *داخل کد شماست*: SELECT یک تصویر لحظهای گرفته و تا وقتی UPDATE اجرا شود آن تصویر کهنه شده است. چهار راه بیرون آمدن، تقریباً به ترتیب اینکه کدام بیشتر جواب است: محاسبه را داخل SQL انجام دهید؛ سطر را اول با SELECT ... FOR UPDATE بگیرید؛ یک ستون version نگه دارید و با WHERE version = $seen بنویسید و صفر سطر بهروزشده را تعارض حساب کنید؛ یا تراکنش را در سطح SERIALIZABLE اجرا کنید و روی خطای 40001 دوباره تلاش کنید. هر چهار راه چند خط کد است. هیچکدام هم اتفاقی به وجود نمیآید.
خطاهایی که بلعیده میشوند. یک catch که همهچیز را میگیرد و هیچ چیزی ثبت نمیکند. قابلیت از کار میافتد، رابط کاربری حالت خالی را نشان میدهد و کاربر آن را «نتیجهای نیست» میخواند، نه «این بخش خراب است». اینها ماهها زنده میمانند.
کوئری N+۱. فهرستی که بهازای هر سطر یک کوئری اضافه میزند — یکی برای خود فهرست و N تا برای سطرها — روی پنجاه سطر اصلاً حس نمیشود و روی پنجاه هزار سطر کشنده است. دلیل رایج «کند شده و کسی نمیداند چرا» همین است، و نشانهاش این است که کد عوض نشده. داده عوض شده.
دو ابزار این را در یک بعدازظهر پیدا میکنند. pg_stat_statements کوئریها را بر اساس زمان کل در کل پایگاه داده مرتب میکند، و همین باعث میشود کوئری ارزانی که ده هزار بار اجرا شده خودش را نشان بدهد، نه کوئری گرانی که یک بار اجرا شده. بعد EXPLAIN (ANALYZE, BUFFERS) روی همان کوئری نشان میدهد که برنامهریز دارد حلقه میزند یا نه. راهحل یک join است، یا یک رفتوبرگشت دستهای با WHERE id = ANY($1).
فهرستهای بیسقف. صفحهای که همهٔ رکوردهای تاریخ سیستم را میخواند، هفتهٔ اول آنی بود. همان صفحه در ماه نهم، صفحهای است که هنگ میکند.
ایندکسِ نبوده یا غیرقابل استفاده. کوئریای که پایگاه داده میتوانست از روی ایندکس جواب بدهد، بهجایش کل جدول را میخواند. خیلی وقتها ایندکس هست و فقط قابل استفاده نیست: ایندکس ترکیبی روی (created_at, tenant_id) به کوئریای که فقط روی tenant_id فیلتر میکند کمکی نمیکند، چون به یک B-tree فقط از ستون اولش میشود وارد شد. پیچیدن ستون داخل یک تابع هم — مثل WHERE lower(email) = $1 — ایندکس ساده را از کار میاندازد، مگر اینکه ایندکس عبارتی متناظرش وجود داشته باشد. نشانه همان مورد قبلی است، علت فرق دارد، و تا کسی پلن اجرا را نخواند دیده نمیشود.
ثبت دوباره. پاسخ کند، کاربر بیحوصله، دو سفارش یکسان. اینکه این موضوع پول ببرد یا نه، فقط به این بستگی دارد که کسی کلید یکتا برای جلوگیری از تکرار نوشته باشد یا نه.
زمان، تاریخ و تقویم. نوبتی که بدون منطقهٔ زمانی ذخیره شده. تاریخ شمسی که با تقویم میلادی نمایش داده میشود. «امروزی» که روی سرور در یک کشور حساب شده و به کاربری در کشور دیگر نشان داده میشود.
بررسی دسترسی که فقط در صفحه است. دکمه برای نقش اشتباه پنهان شده، پس قابلیت امن به نظر میرسد. مسیر پشت آن دکمه هیچ بررسیای نمیکند و به هر کسی که مستقیم صدایش بزند جواب میدهد.
سرویسهای بیرونی بدون مهلت زمانی. سرویس بیرونی بهجای خطا دادن، کند میشود. حالا هر درخواست به آن سی ثانیه طول میکشد. نرمافزار شما هم خطا نمیدهد؛ فقط جواب نمیدهد، چون همهٔ پردازندهها در صف انتظار نشستهاند.
منابعی که هیچوقت آزاد نمیشوند. اتصالی که بهازای هر درخواست باز میشود و بسته نمیشود. مصرف حافظه در طول چند روز بالا میرود. نرمافزار هر جمعه یک بار ریاستارت میشود و کسی هم اسمش را باگ نمیگذارد.
هر مورد این فهرست قابل گرفتن است. هر کدام با نوع متفاوتی از تست گرفته میشود، و دلیل اینکه بیش از یک نوع تست وجود دارد همین است.
لایهها، و اینکه هر کدام چه چیزی میگیرند
تست یک کار نیست، چند کار است؛ با هزینه، سرعت و نقطهٔ کور متفاوت. اشتباه رایج این است که اینها را جای هم بگذاریم. نتیجهاش مجموعه تستی است که یک ساعت اجرا میشود و باز هم خرابیهای واضح را نمیبیند.
تست واحد — منطق برنامه یک تابع. بدون پایگاه داده، بدون شبکه. آمادهسازی، اجرا، بررسی. در چند میلیثانیه اجرا میشود، پس میتواند هزاران تا باشد و حتی حین نوشتن کد مدام اجرا شود.
*میگیرد:* محاسبهٔ مالیات که برخلاف قاعدهٔ سازمان مالیاتی گرد میکند، تخفیفی که روی یک قلم مرجوعی منفی میشود، و محاسبهٔ تاریخ که روی آخرین روز اسفند میشکند.
*نمیگیرد:* هر چیزی که به نحوهٔ اتصال بخشها مربوط است. ممکن است تکتک واحدها درست باشند و کل نرمافزار خراب باشد.
دام این لایه، جایگزینسازی است. جایی که یک واحد به همکارش نیاز دارد، جایگزین مفید یک پیادهسازی جعلی است — نسخهای واقعی اما در حافظه از همان رابط — نه ماکی که فقط روی فراخوانیها ادعا میکند. ماکی که میگوید «تابع save یک بار با این شیء صدا زده شد» تا ابد سبز میماند؛ از جمله بعد از اینکه save بهخاطر محدودیتی که هفتهٔ پیش اضافه کردید خطا بدهد. چنین تستی فقط ثابت میکند کد شما هنوز یک تابع را صدا میزند، که کسی در آن شک نداشت.
تست یکپارچگی — محل اتصالها یک پایگاه دادهٔ واقعی که برای هر اجرا بالا میآید — با Testcontainers، یک اسکیمای یکبارمصرف، یا هر چیزی که در پشتهٔ شما ارزان است — با همان مهاجرتهای واقعی و همان محدودیتهای واقعی. بیشتر ایرادهای واقعی در همین لایه زندگی میکنند و همین لایه بیش از بقیه نادیده گرفته میشود، چون تنها لایهای است که زیرساخت میخواهد.
*میگیرد:* مهاجرتی که اجرا نشده، CHECKی که اجازه میدهد نیمی از سفارش ذخیره شود، تراکنشی که در عمل تراکنش نبوده چون یک فراخوانی ناهمگام از آن بیرون زده، و کوئریای که سطرهای مشتری دیگر را برمیگرداند.
*نمیگیرد:* اینکه همهٔ اینها در مرورگر چطور دیده و رفتار میشود.
اگر از سطح دسترسی سطری استفاده میکنید، این تنها لایهای است که میتواند آن را اثبات کند. تست باید با همان نقش محدود وصل شود و خالی بودن نتیجه را بررسی کند؛ چون اتصال با نقش سرویس از همهٔ سیاستها عبور میکند و یک مجموعه تست سبز روی پایگاه دادهای نشتدار تحویلتان میدهد. اول حالت منفی را بنویسید: با کاربر الف وارد شوید، سطر کاربر ب را درخواست کنید، و انتظار داشته باشید چیزی برنگردد.
تست قرارداد — توافق میان بخشها سرویس میگوید این فیلد را برمیگرداند و مصرفکننده باور میکند. تست قرارداد همان لحظهای قرمز میشود که یک طرف بدون طرف دیگر تغییر کند.
*میگیرد:* فیلدی که نامش عوض شده و سه روز بعد از انتشار موفق نسخهٔ وب، اپلیکیشن موبایل را از کار انداخته است.
*نمیگیرد:* درست بودن هیچکدام از دو طرف. فقط ثابت میکند هنوز با هم هماهنگاند.
تست مؤلفه و رابط کاربری — رابط بهتنهایی یک مؤلفه با ورودی واقعی رندر میشود. حالت غیرفعال واقعاً غیرفعال میکند؟ پیام خطا ظاهر میشود؟ فهرست خالی حالت خالی نشان میدهد یا هیچ چیز؟
*میگیرد:* دکمهای که هنگام ثبت فعال میماند — که همان سفارش تکراری بالا از آن میآید — فرمی که با رندر دوباره مقدارش را از دست میدهد، و چرخ انتظاری که هیچوقت تمام نمیشود.
*نمیگیرد:* اینکه آن صفحه اصلاً به مسیر درستی وصل شده یا نه.
تست سرتاسری — مسیرهایی که درآمد از آنها میآید مرورگر واقعی، نرمافزار واقعی: ورود، جستوجو، افزودن به سبد، پرداخت، دیدن تأییدیه. کند و گران است، پس باید کم باشد — و باید همان مسیرهایی را بپوشاند که خرابیشان پول میبرد.
*میگیرد:* چیزی که همهٔ لایههای دیگر جا انداختهاند، چون تنها تستی است که کل مسیر کاربر را میپیماید.
*نمیگیرد:* چیز خاصی، و دام هم همینجاست. دویست تست سرتاسری یک ساعت طول میکشد و ظرف یک ماه نادیده گرفته میشود.
بیثباتی هم از همینجا زاده میشود. تقریباً همهاش از این میآید که تست بهجای انتظار برای یک شرط، منتظر ساعت میماند: یک sleep ثابت روی ماشین سریع قبول میشود و روی اجراکنندهٔ شلوغ CI رد. منتظر عنصر بمانید، منتظر پاسخ شبکه، منتظر وضعیت — هیچوقت منتظر یک مدت زمان. و وقتی تستی گاهبهگاه قرمز شد، قرنطینهاش کنید و همان شرایط رقابتی را که پیدا کرده رفع کنید. اضافه کردن تلاش خودکار دوباره، یک باگ واقعی همزمانی را به یک تیک سبز تبدیل میکند.
تست دودی — زنده است یا نه چند بررسی کوتاه که بعد از هر انتشار روی محیط واقعی اجرا میشود. صفحهٔ اصلی جواب میدهد. ورود کار میکند. یک رکورد خوانده و نوشته میشود.
*میگیرد:* متغیر محیطی که در محیط عملیاتی تنظیم نشده، مهاجرتی که همهجا اجرا شده جز محیط عملیاتی، بیلدی که روی پروژهٔ اشتباه منتشر شده. یک خانوادهٔ کامل از خرابی که از همهٔ تستها رد میشود و باز سایت را پایین میآورد.
*نمیگیرد:* هیچ چیز ظریفی. و همین هم هدفش است؛ این نبض گرفتن است، نه تشخیص بیماری.
تست بازگشتی — باگی که برگشته است هر باگی که ارزش رفع کردن دارد، ارزش یک تست را هم دارد؛ تستی که پیش از رفع باگ نوشته شود تا اول قرمز شود. تستی که هیچوقت قرمز نشده، ثابت نکرده چیزی را میسنجد.
*میگیرد:* برگشتن همان ایراد شش ماه بعد، وقتی کسی که رفعش کرده دیگر در تیم نیست.
این تنها سنجهٔ صادقانه برای کیفیت خود مجموعه تست هم هست. درصد پوشش، خطهایی را میشمارد که *اجرا* شدهاند، نه خطهایی که *بررسی* شدهاند؛ همهٔ ادعاهای تستهایتان را پاک کنید، درصد پوشش تکان نمیخورد. اگر میخواهید بدانید تستها اصلاً باگ را میبینند یا نه، سنجهٔ درست تست جهش است: عمداً > را به >= تبدیل میکند، خطی را حذف میکند، شرطی را وارونه میکند، و گزارش میدهد مجموعهٔ تست چند تا از این خرابیهای عمدی را گرفته است. کدی با نود درصد پوشش خطی و چهل درصد امتیاز جهش، کدی است که تستهایش بیشتر تماشا میکنند.
تست بار و کارایی صد کاربر همزمان روی دادهٔ واقعی، نه روی پایگاه دادهٔ خالی.
*میگیرد:* کوئریای که با داده رشد میکند، ایندکس جاافتاده، و استخر اتصالی که سر سی کاربر تمام میشود — یعنی کل خانوادهٔ «افت تدریجی».
*نمیگیرد:* درستی. سریع و غلط، باز هم غلط است.
تست دسترسپذیری بررسی خودکار کنتراست، برچسبها، ترتیب فوکوس و دسترسی با صفحهکلید.
*میگیرد:* ورودی بدون برچسب، پنجرهای که فوکوس را زندانی میکند، و دکمهای که در واقع یک div است و با صفحهکلید اصلاً قابل رسیدن نیست.
*نمیگیرد:* اینکه مسیر کار برای کسی که از صفحهخوان استفاده میکند معنا دارد یا نه. ابزار خودکار تقریباً نیمی را پیدا میکند؛ نیمهٔ دیگر آدم میخواهد.
تست امنیت مجوز دسترسی بهجای فرض شدن، سنجیده میشود: رکورد مشتری دیگر درخواست شود و پاسخ باید رد باشد.
*میگیرد:* دسترسیای که فقط در رابط کاربری اعمال شده بوده. و همچنین چند مورد کلاسیک که هرکدام ارزش یک تست دارند: عوض کردن شناسهٔ یک شیء در آدرس، پذیرفته شدن فیلد نقش از بدنهٔ درخواست هنگام ثبتنام، و توکن منقضیای که هنوز پذیرفته میشود چون فقط امضایش بررسی شده است.
*نمیگیرد:* ایراد طراحی. تست ثابت میکند قاعدهای که نوشتهاید کار میکند، نه اینکه قاعدهٔ درستی بوده.
شکل مجموعه تستی که دوام میآورد
ارزان و سریع در پایین، گران و کم در بالا:
- تستهای واحد، زیاد — در حد میلیثانیه، که مدام حین نوشتن کد اجرا میشوند.
- تستهای یکپارچگی، کمتر — در حد ثانیه، روی هر تغییر.
- تستهای سرتاسری، تعداد کم — در حد دقیقه، فقط روی مسیرهای مهم.
- چند بررسی دودی — بعد از هر انتشار، روی محیط واقعی.
وارونه کردن این شکل، اشتباه رایج است. مجموعهای که بیشترش سرتاسری باشد کند است؛ مجموعهٔ کند بیثبات میشود؛ مجموعهٔ بیثبات نادیده گرفته میشود و «دوباره اجرا کن» به عادت تیم تبدیل میشود. از آن نقطه به بعد، داشتن چنین مجموعهای از نداشتنش بدتر است، چون بدون هیچ شاهدی اطمینان تولید میکند.
تستی که بیدلیل قرمز شود، به همه یاد میدهد قرمز را نادیده بگیرند.
اگر امروز هیچ تستی ندارید
با درصد پوشش شروع نکنید. با پول و اعتبار شروع کنید، به همین ترتیب:
- یک تست دودی روی مسیر حیاتی، که بعد از هر انتشار اجرا شود. یک بعدازظهر کار دارد و شرمآورترین نوع خرابی را میگیرد.
- تست سرتاسری برای همان دو سه مسیری که درآمد از آنها میآید. ثبتنام، پرداخت، رزرو؛ هر چه در محصول شما معادلش است.
- تست یکپارچگی روی قواعدی که از داده محافظت میکنند: دسترسیها، یکتایی، و هر چیزی که به پول یا موجودی مربوط است.
- تست واحد برای منطقی که قبلاً یک بار باگ داده است. تاریخچه بهترین راهنما برای پیدا کردن باگ بعدی است.
- یک تست بازگشتی همراه هر رفع باگ، از این به بعد و برای همیشه.
این فهرست روی پروژهای که از قبل نوشته شده، در چند هفته شدنی است و بیشتر چیزهایی را میپوشاند که واقعاً نرمافزار را پایین میآورند. درصد پوشش هدف نیست: کاملاً ممکن است نود درصد پوشش داشته باشید و هیچ تستی برای صفحهٔ پرداخت نداشته باشید.
تستی که اجرا نشود، تست نیست
مجموعه تستی که روی لپتاپ یک نفر است، شبکهٔ ایمنی نیست؛ یک عادت شخصی است و با رفتن آن شخص میرود. تست فقط وقتی هزینهاش را برمیگرداند که خودکار، روی هر تغییر و با اختیار متوقف کردن انتشار اجرا شود:
- هر پوش کل مجموعه را اجرا میکند، بدون اینکه کسی تصمیم بگیرد.
- مجموعهٔ قرمز جلوی ادغام را میگیرد. نه هشدار — توقف.
- هر تغییر محیط پیشنمایش خودش را میگیرد، تا بررسی روی چیزی واقعی انجام شود.
- انتشار بعد از قبولی بررسیها خودکار است و اگر تست دودی چیز دیگری بگوید، در یک مرحله برگردانده میشود.
این تمام چیزی است که به آن یکپارچهسازی و تحویل پیوسته میگویند، و همان سازوکاری است که مجموعهٔ تست را از یک نیت خوب به یک ویژگیِ خودِ سیستم تبدیل میکند.
آنچه تست انجام نمیدهد
صداقت دربارهٔ مرزها همان چیزی است که بقیهٔ حرفها را قابل اعتماد میکند.
تست، حالتهایی را اثبات میکند که کسی به آنها فکر کرده است. اثبات نبودِ باگ نیست و هیچ مجموعهای هم نخواهد بود. جای تایپها را نمیگیرد، چون تایپ دستهٔ دیگری از خطا را زودتر و ارزانتر میگیرد. جای پایش محیط عملیاتی را هم نمیگیرد، چون خرابیهای جالبِ محیط عملیاتی همانهایی هستند که کسی پیشبینیشان نکرده بود. و مدل دادهٔ اشتباه را نجات نمیدهد؛ فقط آن اشتباه را محکم سر جایش میخکوب میکند.
هزینهٔ واقعی هم دارد: زمان نوشتن، زمان اجرا، و زمان نگهداری وقتی کد تغییر میکند. تستی که با هر دست زدن به هر چیزی میشکند، دارد پیادهسازی را میسنجد نه رفتار را، و پاک کردنش کار درستی است.
هدف این نیست که همهچیز تست شود. هدف این است که آگاهانه بدانید در برابر کدام خرابیها محافظت شدهاید — و قرار است خبر کدامها را از زبان مشتری بشنوید.