gomonkey
تست نوشتن یکی از مهمترین بخشهای توسعه نرمافزار است. تستها کمک میکنند باگها را زودتر پیدا کنیم، رفتارهای غیرمنتظره را کنترل کنیم و با خیال راحتتر تغییرات جدید اضافه کنیم.
در Go به صورت پیشفرض پکیج testing وجود دارد و علاوه بر آن ابزارهای مختلفی مثل test suiteها، mocking و patching داریم که نوشتن تست را راحتتر میکنند. این ابزارها از نظر ایده شبیه چیزی هستند که در زبانهای دیگر هم میبینیم؛ مثلا در Java ابزارهایی مثل JUnit و Mockito داریم و در Python هم PyTest و unittest.mock.
مدتی پیش وقتی روی یک پروژه کار میکردم و در حال یادگیری عمیقتر تستنویسی در Go و ابزارهایی مثل monkey patching بودم، با مشکلی برخورد کردم که باعث میشد تستها بعضی وقتها کار کنند و بعضی وقتها نه. بعد از کمی بررسی فهمیدم که بهینهسازیهای کامپایلر دلیل اصلی این رفتار هستند. در این مقاله میخواهم مشکل و راهحل آن را به زبان ساده توضیح بدهم تا شما وقتتان را برای پیدا کردن راه حل همین مشکل هدر ندهید.
گاهی در تستها لازم است یک تابع یا متد را موقتا با یک نسخهی ساختگی (mock) جایگزین کنیم. این کار کمک میکند:
خطاها را شبیهسازی کنیم
مسیرهای خاصی از کد اجرا شوند
وابستگیهایی مثل دیتابیس یا شبکه را حذف کنیم
coverage را بالا ببریم
به این تکنیک monkey patching گفته میشود.
به زبان ساده:
monkey patching یعنی جایگزین کردن موقت یک تابع یا متد با نسخهی mock فقط در زمان تست.
مثلا:
func GetTime() int64 { return time.Now().Unix() }
در تست میتوانیم آن را اینطور عوض کنیم:
patches := gomonkey.ApplyFunc(GetTime, func() int64 { return 1234567890 }) defer patches.Reset()
از این لحظه داخل تست، هر بار GetTime() صدا زده شود، مقدار ثابت برمیگرداند.
Monkey patching اصلا مناسب production نیست.
این روش:
thread-safe نیست
رفتار قابل پیشبینی ندارد
وابسته به جزئیات داخلی runtime است
فقط برای تست طراحی شده
پس هرگز در کد اصلی برنامه از آن استفاده نکنید.
دو کتابخانهی معروف وجود دارد:
bou.ke/monkey
agiledragon/gomonkey
هر دو با دستکاری pointer توابع در runtime کار میکنند.
در عمل، gomonkey گزینهی بهتری است چون:
با نسخههای جدید Go سازگارتر است
پایدارتر است
API سادهتری دارد
نصب:
go get github.com/agiledragon/gomonkey/v2
patches := gomonkey.ApplyFunc(Add, func(a, b int) int { return 42 }) defer patches.Reset()
patches := gomonkey.ApplyMethod( &UserService{}, "FetchUser", func(_ *UserService, id int) (*User, error) { return nil, errors.New("mock error") }, ) defer patches.Reset()
خیلی مهم است:
defer patches.Reset()
اگر reset نکنید، patch ممکن است روی تستهای بعدی هم اثر بگذارد و خطاهای عجیب ایجاد کند.
ممکن است با این حالتها روبهرو شوید:
patch اعمال نمیشود
تابع اصلی اجرا میشود
تستها بعضی وقتها fail میشوند
روی یک سیستم کار میکند ولی روی سیستم دیگر نه
دلیل اصلی: بهینهسازیهای کامپایلر Go
کامپایلر Go از روش های زیادی برای optimize میکند. مثلا:
inlining
حذف کدهای اضافی
reorder کردن دستورات
حذف سمبلها
اگر یک تابع inline شود، دیگر به عنوان یک تابع مستقل وجود ندارد. یعنی چیزی برای patch کردن باقی نمیماند.
مثلا:
func Add(a, b int) int { return a + b }
کامپایلر ممکن است این را مستقیم داخل caller کپی کند. در این حالت patch کردن Add هیچ اثری ندارد.
ممکن است دیده باشید:
وقتی با breakpoint یا debugger اجرا میکنید، patch کار میکند.
دلیلش این است که در حالت debug معمولا بهینهسازیها غیرفعال میشوند. بنابراین تابع inline نمیشود و patch درست کار میکند.
ولی در اجرای عادی تستها، optimize فعال است و patch خراب میشود.
راهحل ساده است:
موقع اجرای تستها optimization و inlining را غیرفعال کنید.
با gcflags:
go test -gcflags="all=-N -l"
-N → غیرفعال کردن optimization
-l → غیرفعال کردن inlining
all= → برای همهی پکیجها اعمال شود (خیلی مهم)
اگر all= را نگذارید، بعضی پکیجها هنوز optimize میشوند و patch ممکن است باز هم fail شود.
میتوانید alias بسازید:
alias gotest='go test -gcflags="all=-N -l" -count=1'
و بعد:
gotest ./...
-count=1 جلوی cache شدن تستها را میگیرد.
فقط وقتی:
dependency و legacy دارید
refactor کردن سخت است
interface کافی نیست
نیاز به کنترل دقیق اجرای کد دارید
در حالت عادی، بهتر است از interface و dependency injection استفاده کنید. monkey patching باید آخرین گزینه باشد.
Monkey patching میتواند ابزار قدرتمندی برای تست در Go باشد. کتابخانههایی مثل gomonkey این کار را ساده میکنند و کمک میکنند مسیرهای خاص را تست کنیم و coverage را بالا ببریم.
اما بهینهسازیهای کامپایلر، مخصوصا inlining، میتوانند باعث شوند patch اصلا اعمال نشود و تستها رفتار عجیب داشته باشند.
راهحل خیلی ساده است:
go test -gcflags="all=-N -l"
بهینهسازی را در زمان تست غیرفعال کنید تا patch قابلاعتماد شود.
اگر از monkey patching استفاده میکنید، همیشه این سه نکته را یادتان باشد:
فقط برای تست، حتما reset کنید، و optimization را غیرفعال کنید.