چرا وباپلیکیشنها بعد از راهاندازی خراب میشوند
نرمافزاری که در دمو کار میکند با نرمافزاری که زیر بار کاربر واقعی دوام میآورد، دو چیز متفاوتاند. این متن دربارهٔ فاصلهٔ میان این دو است: خرابیهایی که فقط بعد از راهاندازی پیدا میشوند، و اینکه هر لایهٔ تست جلوی کدامیک را میگیرد.
دمو، بخش آسان ماجراست
نرمافزار در دمو فقط یک کاربر دارد. دادهها تمیزند، چون سی ثانیه پیش یک نفر آنها را تایپ کرده. اینترنت همان وایفای دفتر است، یک متر آنطرفتر از مودم. ترتیب کلیکها هم دقیقاً همانی است که سازنده در ذهن داشته، چون خودِ سازنده دارد کلیک میکند.
محیط عملیاتی هیچکدام از اینها را ندارد. دو نفر در یک ثانیه یک سفارش را ویرایش میکنند. مقداری از اکسل کپی میشود و با یک فاصلهٔ اضافه و یک کاراکتر نامرئی وارد سیستم میشود. اینترنت گوشی وسط آپلود قطع میشود. کاربر بعد از ثبت فرم دکمهٔ بازگشت مرورگر را میزند و دوباره ثبت میکند. کاربری که یک فرم طولانی را باز گذاشته، وسط کار از سیستم بیرون انداخته میشود چون مدت اعتبار ورودش تمام شده. مشتریای در منطقهٔ زمانی دیگری نوبتی رزرو میکند که در محل سرور شما مربوط به دیروز است.
هیچکدام از اینها حالت نادری نیست؛ همهاش یک سهشنبهٔ معمولی است. و جملهٔ «روی سیستم من که کار میکند» دربارهٔ هیچکدامشان چیزی نمیگوید.
سؤال هیچوقت این نبوده که نرمافزار اجرا میشود یا نه. سؤال این است که از هزار اتفاقی که بعدش میافتد، ثابت شده از پس کدامهایش برمیآید.
چرا این وضعیت رایجتر شده است
نوشتن کد هیچوقت به این سرعت نبوده. نوشتن مسیر خوشبینانه از آن هم سریعتر است، چون دقیقاً همان چیزی است که از روی یک توضیح ساده بیرون میآید. فرم رزرو بخواهید، فرم رزرو تحویل میگیرید: فیلدها، دکمهٔ ثبت، پیام موفقیت. دمویش هم بینقص است.
چیزی که معمولاً در آن توضیح نیست، نیمهٔ دوم صورتمسئله است: همان بخشی که میگوید چه چیزی *نباید* اتفاق بیفتد. اگر درگاه پرداخت یازده ثانیه جواب ندهد چه میشود؟ اگر همان رزرو دو بار ثبت شود، چون پاسخ اول کند بوده، چه میشود؟ اگر مدیر سیستم همان رکورد را بین باز شدن صفحه و زدن دکمهٔ ذخیره حذف کند چه میشود؟
این نیمه از نظر فنی سخت نیست. فقط در دمو دیده نمیشود، پس کسی درخواستش نمیکند، و در نتیجه نوشته هم نمیشود.
این موضوع نه جدید است و نه مخصوص کدی که هوش مصنوعی تولید کرده؛ فاصلهٔ نمونهٔ اولیه با محصول همیشه بوده است. چیزی که عوض شده نسبتهاست. ساختن لایهٔ قابل دیدن حالا تقریباً رایگان است، پس نرمافزار خیلی بیشتری به دست کاربر واقعی میرسد که نیمهٔ نامرئیاش اصلاً نوشته نشده. ظاهرش تمامشده است. «تمام به نظر رسیدن» و «تمام بودن» هیچوقت اینقدر از هم دور نبودهاند.
چه چیزهایی واقعاً خراب میشوند
خرابیهایی که چند هفته بعد از راهاندازی سروکلهشان پیدا میشود، در چند دستهٔ مشخص جا میگیرند. شناختن همین دستهها بیشترِ کار است.
گم شدن یکی از دو تغییر همزمان. دو درخواست یک موجودی را میخوانند، هر دو در کد 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 — ایندکس ساده را از کار میاندازد، مگر اینکه ایندکس عبارتی متناظرش وجود داشته باشد. نشانه همان مورد قبلی است، علت فرق دارد، و تا کسی پلن اجرای کوئری را نخواند دیده نمیشود.
ثبت دوبارهٔ یک فرم. پاسخ کند، کاربر بیحوصله، دو سفارش یکسان. اینکه این موضوع پول ببرد یا نه، فقط به این بستگی دارد که کسی کلید یکتا برای جلوگیری از تکرار نوشته باشد یا نه.
زمان، تاریخ و تقویم. نوبتی که بدون منطقهٔ زمانی ذخیره شده. تاریخ شمسی که با تقویم میلادی نمایش داده میشود. «امروزی» که روی سرور در یک کشور حساب شده و به کاربری در کشور دیگر نشان داده میشود.
بررسی دسترسی که فقط در صفحه انجام شده. دکمه برای نقش اشتباه پنهان شده، پس قابلیت امن به نظر میرسد. اما مسیری که پشت آن دکمه است هیچ بررسیای نمیکند و به هر کسی که مستقیم صدایش بزند جواب میدهد.
سرویسهای بیرونی بدون مهلت زمانی. سرویس بیرونی بهجای اینکه خطا بدهد، کند میشود. حالا هر درخواست به آن سی ثانیه طول میکشد. نرمافزار شما هم خطا نمیدهد؛ فقط جواب نمیدهد، چون همهٔ پردازندههایش در صف انتظار نشستهاند.
منابعی که هیچوقت آزاد نمیشوند. اتصالی که بهازای هر درخواست باز میشود و بسته نمیشود. مصرف حافظه در طول چند روز بالا میرود. نرمافزار هر جمعه یک بار ریاستارت میشود و کسی هم اسمش را باگ نمیگذارد.
هر مورد این فهرست قابل گرفتن است، و هر کدام با نوع متفاوتی از تست گرفته میشود. دلیل اینکه بیش از یک نوع تست وجود دارد، دقیقاً همین است.
لایهها، و اینکه هر کدام چه چیزی میگیرند
تست یک کار نیست، چند کار است؛ با هزینه، سرعت و نقطهٔ کور متفاوت. اشتباه رایج این است که اینها را جای هم بگذاریم. نتیجهاش مجموعه تستی است که یک ساعت طول میکشد و باز هم خرابیهای واضح را نمیبیند.
تست واحد — منطق برنامه یک تابع، بدون پایگاه داده و بدون شبکه. آمادهسازی، اجرا، بررسی نتیجه. در چند میلیثانیه اجرا میشود، پس میتواند هزاران تا باشد و حتی حین نوشتن کد مدام اجرا شود.
*میگیرد:* محاسبهٔ مالیات که برخلاف قاعدهٔ سازمان مالیاتی گرد میکند، تخفیفی که روی یک قلم مرجوعی منفی میشود، و محاسبهٔ تاریخ که روی آخرین روز اسفند میشکند.
*نمیگیرد:* هر چیزی که به نحوهٔ اتصال بخشها به هم مربوط است. ممکن است تکتک قطعهها درست باشند و کل نرمافزار خراب باشد.
دام این لایه، جایگزینسازی است. جایی که یک تابع به همکارش نیاز دارد، جایگزین مفید یک Fake است — یک پیادهسازی واقعی اما در حافظه از همان رابط — نه یک Mock که فقط دربارهٔ فراخوانیها ادعا میکند. Mockی که میگوید «تابع save یک بار با این شیء صدا زده شد» تا ابد سبز میماند، حتی بعد از اینکه save بهخاطر محدودیتی که هفتهٔ پیش اضافه کردهاید خطا بدهد. چنین تستی فقط ثابت میکند کد شما هنوز یک تابع را صدا میزند، که کسی در آن شکی نداشت.
تست یکپارچگی — محل اتصال بخشها یک پایگاه دادهٔ واقعی که برای هر اجرا بالا میآید — با Testcontainers، یک اسکیمای یکبارمصرف، یا هر چیزی که در پروژهٔ شما ارزان درمیآید — با همان Migrationهای واقعی و همان محدودیتهای واقعی. بیشتر ایرادهای واقعی در همین لایهاند و همین لایه هم بیش از بقیه نادیده گرفته میشود، چون تنها لایهای است که زیرساخت میخواهد.
*میگیرد:* تغییری در ساختار پایگاه داده که اجرا نشده، CHECKی که اجازه میدهد نیمی از یک سفارش ذخیره شود، تراکنشی که در عمل تراکنش نبوده چون یک فراخوانی ناهمگام از آن بیرون زده، و کوئریای که سطرهای مشتری دیگر را هم برمیگرداند.
*نمیگیرد:* اینکه همهٔ اینها در مرورگر چطور دیده میشوند و چطور رفتار میکنند.
اگر از سطح دسترسی سطری استفاده میکنید، این تنها لایهای است که میتواند آن را اثبات کند. تست باید با همان نقش محدود به پایگاه داده وصل شود و خالی بودن نتیجه را بررسی کند؛ چون اتصال با نقش سرویس از همهٔ سیاستها عبور میکند و یک مجموعه تست سبز روی پایگاه دادهای نشتدار تحویلتان میدهد. اول حالت منفی را بنویسید: با کاربر الف وارد شوید، سطر کاربر ب را درخواست کنید، و انتظار داشته باشید چیزی برنگردد.
تست قرارداد — توافق میان بخشها سرویس میگوید این فیلد را برمیگرداند و مصرفکننده باور میکند. تست قرارداد همان لحظهای قرمز میشود که یک طرف بدون طرف دیگر تغییر کند.
*میگیرد:* فیلدی که نامش عوض شده و سه روز بعد از یک انتشار موفق در نسخهٔ وب، اپلیکیشن موبایل را از کار انداخته است.
*نمیگیرد:* درست بودن هیچکدام از دو طرف. فقط ثابت میکند این دو هنوز با هم هماهنگاند.
تست کامپوننت و رابط کاربری — خودِ صفحه یک کامپوننت با ورودی واقعی رندر میشود. حالت غیرفعال واقعاً غیرفعال میکند؟ پیام خطا ظاهر میشود؟ فهرست خالی حالت خالی را نشان میدهد یا هیچ چیز؟
*میگیرد:* دکمهای که هنگام ثبت فعال میماند — که همان سفارش تکراری بالا از آن میآید — فرمی که با رندر دوباره مقدارش را از دست میدهد، و نشانگر بارگذاریای که هیچوقت تمام نمیشود.
*نمیگیرد:* اینکه آن صفحه اصلاً به مسیر درستی وصل شده یا نه.
تست سرتاسری — مسیرهایی که درآمد از آنها میآید مرورگر واقعی و نرمافزار واقعی: ورود، جستوجو، افزودن به سبد، پرداخت، دیدن تأییدیه. کند و گران است، پس باید کم باشد — و باید همان مسیرهایی را بپوشاند که خراب شدنشان پول میبرد.
*میگیرد:* چیزی که همهٔ لایههای دیگر جا انداختهاند، چون تنها تستی است که کل مسیر کاربر را از اول تا آخر طی میکند.
*نمیگیرد:* چیز خاصی، و دام هم همینجاست. دویست تست سرتاسری یک ساعت طول میکشد و ظرف یک ماه نادیده گرفته میشود.
تستهای بیثبات هم از همینجا شروع میشوند. تقریباً همهشان از این میآیند که تست بهجای انتظار برای یک شرط، منتظر ساعت میماند: یک sleep ثابت روی ماشین سریع قبول میشود و روی سرور شلوغ CI رد. منتظر خودِ عنصر بمانید، منتظر پاسخ شبکه، منتظر تغییر وضعیت — هیچوقت منتظر یک مدت زمان مشخص. و وقتی تستی گاهبهگاه قرمز شد، جدایش کنید و همان تداخل همزمانی را که پیدا کرده رفع کنید. اضافه کردن «تلاش خودکار دوباره» یک باگ واقعی همزمانی را به یک تیک سبز تبدیل میکند.
تست سلامت — زنده است یا نه چند بررسی کوتاه که بعد از هر انتشار روی محیط واقعی اجرا میشود. صفحهٔ اصلی جواب میدهد. ورود کار میکند. یک رکورد خوانده و نوشته میشود.
*میگیرد:* متغیر محیطی که در محیط عملیاتی تنظیم نشده، تغییر ساختار پایگاه دادهای که همهجا اجرا شده جز محیط عملیاتی، و Buildی که روی پروژهٔ اشتباه منتشر شده. یک دستهٔ کامل از خرابی که از همهٔ تستها رد میشود و باز سایت را پایین میآورد.
*نمیگیرد:* هیچ چیز ظریفی — و همین هم هدفش است. این نبض گرفتن است، نه تشخیص بیماری.
تست رگرسیون — باگی که برگشته است هر باگی که ارزش رفع کردن دارد، ارزش یک تست را هم دارد؛ تستی که پیش از رفع باگ نوشته شود تا اول قرمز شود. تستی که هیچوقت قرمز نشده، ثابت نکرده که اصلاً چیزی را میسنجد.
*میگیرد:* برگشتن همان ایراد شش ماه بعد، وقتی کسی که رفعش کرده دیگر در تیم نیست.
این تنها معیار صادقانه برای کیفیت خودِ مجموعه تست هم هست. درصد پوشش، خطهایی را میشمارد که *اجرا* شدهاند، نه خطهایی که *بررسی* شدهاند؛ همهٔ ادعاهای تستهایتان را پاک کنید، درصد پوشش تکان نمیخورد. اگر میخواهید بدانید تستها اصلاً باگ را میبینند یا نه، معیار درست تست جهش است: عمداً > را به >= تبدیل میکند، خطی را حذف میکند، شرطی را وارونه میکند، و گزارش میدهد که مجموعه تست چند تا از این خرابیهای عمدی را گرفته است. کدی با نود درصد پوشش خطی و چهل درصد امتیاز جهش، کدی است که تستهایش بیشتر تماشا میکنند تا بررسی.
تست بار و کارایی صد کاربر همزمان روی دادهٔ واقعی، نه روی پایگاه دادهٔ خالی.
*میگیرد:* کوئریای که با بزرگ شدن داده کند میشود، ایندکس جاافتاده، و مخزن اتصالهایی که سر سی کاربر تمام میشود — یعنی کل دستهٔ «کمکم کند شد».
*نمیگیرد:* درستی را. سریع و غلط، باز هم غلط است.
تست دسترسپذیری بررسی خودکار کنتراست رنگ، برچسبها، ترتیب فوکوس و کار کردن با صفحهکلید.
*میگیرد:* ورودی بدون برچسب، پنجرهای که فوکوس را داخل خودش زندانی میکند، و دکمهای که در واقع یک div است و با صفحهکلید اصلاً نمیشود به آن رسید.
*نمیگیرد:* اینکه مسیر کار برای کسی که از صفحهخوان استفاده میکند معنا دارد یا نه. ابزار خودکار تقریباً نیمی از ایرادها را پیدا میکند؛ نیمهٔ دیگر آدم میخواهد.
تست امنیت مجوز دسترسی بهجای اینکه فرض شود، سنجیده میشود: رکورد مشتری دیگر را درخواست کنید و پاسخ باید رد باشد.
*میگیرد:* دسترسیای که فقط در رابط کاربری اعمال شده بوده. و همچنین چند مورد کلاسیک که هرکدام ارزش یک تست دارند: عوض کردن شناسهٔ یک شیء در آدرس صفحه، پذیرفته شدن فیلد «نقش» از بدنهٔ درخواست هنگام ثبتنام، و توکن منقضیای که هنوز پذیرفته میشود چون فقط امضایش بررسی شده است.
*نمیگیرد:* ایراد طراحی را. تست ثابت میکند قاعدهای که نوشتهاید کار میکند، نه اینکه قاعدهٔ درستی بوده.
شکل مجموعه تستی که دوام میآورد
ارزان و سریع در پایین، گران و کم در بالا:
- تستهای واحد، زیاد — در حد میلیثانیه، که مدام حین نوشتن کد اجرا میشوند.
- تستهای یکپارچگی، کمتر — در حد ثانیه، روی هر تغییر.
- تستهای سرتاسری، تعداد کم — در حد دقیقه، فقط روی مسیرهای مهم.
- چند تست سلامت — بعد از هر انتشار، روی محیط واقعی.
وارونه کردن این شکل، اشتباه رایج است. مجموعهای که بیشترش سرتاسری باشد کند است؛ مجموعهٔ کند بیثبات میشود؛ مجموعهٔ بیثبات نادیده گرفته میشود و «دوباره اجرا کن» به عادت تیم تبدیل میشود. از آن نقطه به بعد، داشتن چنین مجموعهای از نداشتنش بدتر است، چون بدون هیچ شاهدی به شما اطمینان میدهد.
تستی که بیدلیل قرمز شود، به همه یاد میدهد قرمز را نادیده بگیرند.
اگر امروز هیچ تستی ندارید
با درصد پوشش شروع نکنید. با پول و اعتبار شروع کنید، به همین ترتیب:
- یک تست سلامت روی مسیر حیاتی، که بعد از هر انتشار اجرا شود. یک بعدازظهر کار دارد و شرمآورترین نوع خرابی را میگیرد.
- تست سرتاسری برای همان دو سه مسیری که درآمد از آنها میآید. ثبتنام، پرداخت، رزرو؛ هر چه در محصول شما معادلش است.
- تست یکپارچگی روی قواعدی که از داده محافظت میکنند: دسترسیها، یکتا بودن، و هر چیزی که به پول یا موجودی مربوط است.
- تست واحد برای منطقی که قبلاً یک بار باگ داده است. سابقه، بهترین راهنما برای پیدا کردن باگ بعدی است.
- یک تست رگرسیون همراه هر رفع باگ، از این به بعد و برای همیشه.
این فهرست روی پروژهای که از قبل نوشته شده، در چند هفته شدنی است و بیشتر چیزهایی را میپوشاند که واقعاً نرمافزار را پایین میآورند. درصد پوشش هدف نیست: کاملاً ممکن است نود درصد پوشش داشته باشید و هیچ تستی برای صفحهٔ پرداخت نداشته باشید.
تستی که اجرا نشود، تست نیست
مجموعه تستی که فقط روی لپتاپ یک نفر است، شبکهٔ ایمنی نیست؛ یک عادت شخصی است و با رفتن آن شخص میرود. تست فقط وقتی هزینهاش را برمیگرداند که خودکار باشد، روی هر تغییر اجرا شود، و اجازه داشته باشد جلوی انتشار را بگیرد:
- هر Push کل مجموعه را اجرا میکند، بدون اینکه کسی تصمیم بگیرد.
- مجموعهٔ قرمز جلوی ادغام را میگیرد. نه هشدار — توقف.
- هر تغییر محیط پیشنمایش خودش را میگیرد، تا بررسی روی چیزی واقعی انجام شود.
- انتشار بعد از قبولی بررسیها خودکار انجام میشود، و اگر تست سلامت چیز دیگری بگوید، در یک قدم برگردانده میشود.
این تمام چیزی است که به آن یکپارچهسازی و تحویل پیوسته میگویند، و همان سازوکاری است که مجموعهٔ تست را از یک نیت خوب به بخشی از خودِ سیستم تبدیل میکند.
کاری که تست انجام نمیدهد
صداقت دربارهٔ مرزها، همان چیزی است که بقیهٔ حرفها را قابل اعتماد میکند.
تست، همان حالتهایی را اثبات میکند که کسی به آنها فکر کرده است. اثبات نبودِ باگ نیست و هیچ مجموعهای هم نخواهد بود. جای بررسی نوع دادهها را نمیگیرد، چون آن کار دستهٔ دیگری از خطا را زودتر و ارزانتر میگیرد. جای پایش محیط عملیاتی را هم نمیگیرد، چون خرابیهای جالبِ محیط عملیاتی همانهایی هستند که کسی پیشبینیشان نکرده بود. و مدل دادهٔ اشتباه را نجات نمیدهد؛ فقط همان اشتباه را محکم سر جایش میخکوب میکند.
هزینهٔ واقعی هم دارد: زمان نوشتن، زمان اجرا، و زمان نگهداری وقتی کد تغییر میکند. تستی که با هر دست زدن به هر چیزی میشکند، دارد پیادهسازی را میسنجد نه رفتار را، و پاک کردنش کار درستی است.
هدف این نیست که همهچیز تست شود. هدف این است که آگاهانه بدانید در برابر کدام خرابیها محافظت شدهاید — و قرار است خبر کدامها را از زبان مشتری بشنوید.