راهنمای میزبانی شخصی API Mocking و تست روی VPS
تفاوت اجرای WireMock و Hurl را در سرور شخصی بیاموزید. با استفاده از Docker Compose، تستهای API و استابهای خود را بدون وابستگی به سرویسهای ابری و با پایداری کامل مدیریت کنید.
دو وظیفه که از یک مخزن مشترک استفاده میکنند
میزبانی شخصی (self-hosted) برای شبیهسازی API (mocking) و تست کردن، دو وظیفهٔ متفاوت هستند و یکی دانستن آنها باعث اتلاف وقت میشود. یک سرور mock جایگزین وابستگیهایی میشود که نمیتوانید از CI فراخوانی کنید: مانند یک ارائهدهندهٔ خدمات پرداخت، API یک شریک تجاری، یک سرویس بالادستی با محدودیت نرخ (rate limited)، یا سرویسی که تیم دیگری هنوز آن را منتشر (ship) نکرده است. یک اجراکنندهٔ تست API، نقاط پایانی (endpoints) شما را به ترتیب مشخصی فراخوانی کرده و پاسخها را بررسی (assert) میکند و مقادیر را از یک پاسخ به درخواست بعدی منتقل مینماید.
این دو وظیفه هیچ همپوشانی با هم ندارند. یک سرور mock هرگز وضعیت موفقیت یا شکست را گزارش نمیکند. یک اجراکنندهٔ تست نیز هیچ نظری دربارهٔ آنچه یک ارائهدهندهٔ خدمات پرداخت هنگام رد شدن کارت بازمیگرداند، ندارد. اکثر تیمهایی که از قبل یک سرور اجاره کردهاند، در نهایت هر دو را اجرا میکنند؛ سرویسهایی که توسط یک فایل Docker Compose مشترک راهاندازی شده و در یک pull request واحد بررسی میشوند.
چرا باید API mocking و تست را بهصورت self-hosted اجرا کرد؟
فیکسچرهای شما دادههایی با ساختار تولید (production) هستند. بدنهٔ درخواست در یک تست API، یک رکورد واقعی از مشتری است که نام آن تغییر یافته، یا به دلیل عدم بررسی، تغییر نیافته است. استابهای ضبطشده (recorded stubs) وضعیت بدتری دارند: ضبطکنندهٔ پروکسی هر آنچه را که سرویس بالادستی بازگردانده ذخیره میکند؛ بنابراین، دایرکتوری استابهایی که با ضبط کردن ساخته شدهاند، حاوی توکنهای زنده و آدرسهای ایمیل مشتریان است تا زمانی که کسی تکتک فایلها را بررسی کند. در یک سرویس میزبانیشده، این دادهها به یک حادثه امنیتی برای دیگران و افشای اطلاعات برای شما تبدیل میشود.
دلیل دوم، قابلیت دسترسی است. سرویسی که به یک آدرس خصوصی متصل است، از طریق یک runner میزبانیشده در دسترس نیست، بنابراین تست اصلاً اجرا نمیشود. هر راهکار جایگزین هزینهای دارد. انتشار API روی اینترنت برای تست کردن آن، دلیل خصوصی بودن آن را از بین میبرد. یک تونل یا یک نسخه staging عمومی، سیستم دیگری برای نگهداری است و نسخه staging بین انتشارها (releases) از نسخه تولید فاصله میگیرد. یک runner در همان شبکه خصوصی، سرویس را مستقیماً فراخوانی میکند و به هیچکدام از این موارد نیاز ندارد؛ این همان استدلال عملی پشت یک GitHub Actions runner خود-میزبانیشده است.
کدام mock server خودمیزبان (self-hosted) را باید اجرا کنید؟
هر یک از این موارد به عنوان یک container روی سروری که مالک آن هستید اجرا میشوند. پرسش مهم این است که هر کدام چه چیزی را به عنوان منبع حقیقت (source of truth) در نظر میگیرند، زیرا این موضوع تعیین میکند که آیا بازسازی container برای شما هزینهای ندارد یا یک بعدازظهر کامل وقتتان را میگیرد.
- WireMock هر stub را به عنوان یک فایل JSON در دایرکتوری
mappings/نگه میدارد و بدنه پاسخهای حجیم را در__files/ذخیره میکند. ایمیج آنwiremock/wiremockاست، دایرکتوری ریشه آن در داخل container مسیر/home/wiremockاست و همچنین به عنوان یک پروکسی ضبطکننده (recording proxy) عمل میکند. وجود فایلها روی دیسک به این معنی است که mock مانند هر کد دیگری در git زندگی میکند. - Mockoon CLI کل یک API مجازی را در یک فایل داده JSON نگه میدارد. آن را با
npm install -g @mockoon/cliنصب کرده و باmockoon-cli start --data ./data-file.jsonاجرا کنید، یا ایمیجmockoon/cliرا با bind mount کردن آن فایل اجرا کنید. اپلیکیشن دسکتاپ همان فایل را ویرایش میکند، بنابراین طراحی در محیط UI و commit کردن نتیجه با هم سازگار باقی میمانند. - MockServer از ایمیج
mockserver/mockserverاجرا میشود و روی پورت 1080 گوش میدهد. انتظارات (expectations) از طریق API اختصاصی REST آن ارسال میشوند که برای کدهای تست مناسب است اما به عنوان یک deployment ریسک دارد: انتظاری که با یک فراخوانی HTTP ایجاد شده باشد، با restart شدن container از بین میرود. برای stubهایی که قرار است دائمی باشند، از فایل مقداردهی اولیه JSON آن استفاده کنید. - Prism به جای استفاده از فایلهای stub جداگانه، mock را از روی سند OpenAPI شما میسازد. آن را با
npm install -g @stoplight/prism-cliنصب کرده و سپسprism mock openapi.yamlرا اجرا کنید. در داخل container عبارت-h 0.0.0.0را اضافه کنید، زیرا Prism بهطور پیشفرض به localhost متصل میشود و در غیر این صورت از خارج container غیرقابل دسترس است. - Microcks گزینه بزرگتری است: یک رابط کاربری وب که اسناد OpenAPI و مجموعههای Postman را وارد کرده، سپس آنها را به عنوان mock ارائه میدهد و تستهای قرارداد (contract tests) را اجرا میکند. نصب کامل آن به MongoDB و Keycloak، به علاوه Kafka برای ویژگیهای ناهمگام (async) نیاز دارد. ایمیج همهکاره
microcks-uberشامل یک MongoDB درونحافظهای (in-memory) است که مستندات پروژه آن را برای استفادههای موقت مناسب میداند؛ بنابراین با هر چیزی که در آن UI ایجاد میشود به عنوان دادههای دورریختنی برخورد کنید و مصنوعات منبع (source artifacts) را در git نگه دارید.
کدام ابزار تست API برای میزبانی شخصی (self-hosted) مناسب است؟
وظیفه در اینجا یک توالی است: احراز هویت، ایجاد یک سفارش، خواندن مجدد آن و تایید تغییر وضعیت. این کار نیازمند مقداری است که از یک پاسخ دریافت شده و در درخواست بعدی استفاده شود. ابزاری که نتواند وضعیت را بین فراخوانیها حفظ کند، یک ابزار بررسی سلامت (health check) است، نه یک ابزار تست API.
- Hurl فایلهای متنی ساده از درخواستهای HTTP را از یک باینری واحد اجرا میکند. یک بخش
[Captures]مقادیر را از پاسخ استخراج میکند، یک بخش[Asserts]آنها را بررسی میکند و--testآن را به یک ابزار اجرای تست با خلاصه و کد خروج تبدیل میکند. نسخه 8.0.1 تا آگوست 2026 نسخه فعلی است. - Bruno CLI پوشهای از فایلهای
.bruرا اجرا میکند. باnpm install -g @usebruno/cliنصب کنید و سپسbru run folder --env Local --reporter-junit results.xmlرا اجرا کنید. فرمت مجموعه (collection) بهگونهای طراحی شده که فایلهای متنی در یک دایرکتوری باشند، بنابراین تفاوتها (diffs) در بازبینی کد خوانا هستند. - Newman مجموعههای Postman را خارج از محیط Postman اجرا میکند:
npm install -g newmanو سپسnewman run collection.json -r cli,junit --reporter-junit-export results.xml. نکته مهم فرمت آن است. مجموعه یک فایل JSON خروجیگرفتهشده است، بنابراین ویرایش در Postman انجام میشود و فایلی که در git قرار دارد یک کپی است که ممکن است قدیمی شود. - Schemathesis نوع متفاوتی از بررسی است. این ابزار یک طرحواره OpenAPI را میخواند و مواردی را تولید میکند که سعی دارند پاسخهایی ایجاد کنند که طبق طرحواره شما غیرممکن هستند:
uvx schemathesis run https://your.api/openapi.json. این ابزار خرابیها و نقض قراردادها را پیدا میکند و از قوانین کسبوکار شما اطلاعی ندارد، بنابراین بهجای جایگزینی، در کنار یک مجموعه تست اسکریپتنویسیشده قرار میگیرد. - Hoppscotch نسخه self-hosted گزینه رابط کاربری وب است و به یک نمونه Postgres نیاز دارد. پیش از نصب، این بدهبستان را درک کنید: مجموعهها در یک پایگاه داده ذخیره میشوند، نه در مخزن (repository) شما.
موردی که باید از آن اجتناب کرد. Step CI همچنان در بررسی ابزارها ظاهر میشود و فرمت گردشکار YAML آن خوانایی خوبی دارد، اما آخرین commit این مخزن در آگوست 2024 انجام شده است. برنامهای که بین CI و API شما قرار میگیرد، مکان مناسبی برای کدهای بدون پشتیبانی نیست.
قرار دادن سرور مجازی پشت فایروال
پیکربندی زیر WireMock را به عنوان جایگزینی برای یک ارائهدهنده خدمات پرداخت اجرا میکند. اگر فرمت فایل compose برای شما جدید است، Docker Compose روی VPS دستورات مربوط به چرخه حیات را که این بخش فرض میگیرد، پوشش میدهد.
services:
mock-payments:
image: wiremock/wiremock:3.13.2
command: ["--verbose"]
volumes:
- ./mocks/payments:/home/wiremock
ports:
- "127.0.0.1:8080:8080"
restart: unless-stoppedپیشوند 127.0.0.1: روی پورت، بخش مهم ماجراست. یک 8080:8080 خالی، سرور مجازی را روی تمام اینترفیسها از جمله IP عمومی شما منتشر میکند و حتی با وجود مسدود بودن آن پورت در ufw، همچنان در دسترس باقی میماند؛ زیرا Docker قوانین خود را در زنجیره iptables با نام DOCKER مینویسد و این قوانین پیش از قوانین INPUT در ufw ارزیابی میشوند. به جای آن، به آدرس loopback یا آدرس یک اینترفیس خصوصی متصل شوید تا هسته سیستمعامل هرگز اتصالات از خارج را نپذیرد.
سرویس تحت تست شما سپس به این سرور مجازی اشاره میکند. هنگامی که سرویس در همان پروژه compose اجرا میشود، URL پایه سرور مجازی http://mock-payments:8080 است، زیرا compose نام سرویسها را در شبکه داخلی خود حل میکند. هنگامی که سرویس روی host اجرا میشود، این آدرس http://127.0.0.1:8080 است. آن را از طریق یک متغیر محیطی تنظیم کنید و هرگز در کد قرار ندهید، در غیر این صورت URL تست به محیط production منتقل میشود.
فایلهای Stub در ./mocks/payments/mappings/ قرار میگیرند، برای هر کدام یک فایل JSON.
{
"request": {
"method": "POST",
"urlPath": "/v1/charges",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "ch_test_001", "status": "succeeded", "amount": 4200 }
}
}آن را اجرا کنید و سپس بررسی کنید چه چیزی واقعاً بارگذاری شده است.
docker compose up -d --wait mock-payments
curl -fsS http://127.0.0.1:8080/__admin/mappingsدستور --wait تا زمانی که container وضعیت سلامت (healthy) را گزارش نکند، منتظر میماند؛ این کار به این دلیل ممکن است که ایمیج WireMock یک HEALTHCHECK را در برابر endpoint خود یعنی /__admin/health اجرا میکند. فراخوانی mappings تمام stubهایی را که سرور خوانده است فهرست میکند. stubای که نوشتهاید و در آن لیست نیست، بارگذاری نشده است: بررسی کنید که فایل در مسیر mappings/ قرار داشته باشد (نه در ریشه mount شده) و صحت فرمت JSON را کنترل کنید.
هنگامی که درخواستی میرسد و هیچ stubای با آن مطابقت ندارد، WireMock پاسخ 404 را با بدنهای که با Request was not matched شروع میشود بازمیگرداند و به دنبال آن تفاوتی (diff) با نزدیکترین stub موجود ارائه میدهد. پیش از هر تغییری، آن diff را بخوانید، زیرا فیلد دقیقی که تفاوت دارد را مشخص میکند. این معمولاً مسیری با /v1/charge است در حالی که stub مقدار /v1/charges را میگوید.
نوشتن تست به صورت یک توالی با انتقال وضعیت بین فراخوانیها
فایلهای Hurl متن ساده هستند. بسته deb را از بخش releases پروژه نصب کنید.
VERSION=8.0.1
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb
sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.debیک مجموعه تست که API شما را در برابر mock در tests/checkout.hurl اجرا میکند.
POST {{base_url}}/orders
Content-Type: application/json
{
"sku": "ssd-1tb",
"amount": 4200
}
HTTP 201
[Captures]
order_id: jsonpath "$['id']"
GET {{base_url}}/orders/{{order_id}}
HTTP 200
[Asserts]
jsonpath "$.status" == "paid"
jsonpath "$.charge_id" == "ch_test_001"بلوک [Captures] همان چیزی است که این تست را به یک تست API تبدیل میکند، نه دو درخواست بیارتباط. مقدار order_id از پاسخ اول خوانده شده و در URL درخواست دوم جایگذاری میشود. assertion در charge_id هدف اصلی این تمرین است: این بخش ثابت میکند که سرویس شما با ارائهدهنده پرداخت تماس گرفته و پاسخ دریافتی را ذخیره کرده است، و مقداری که با آن مقایسه میشود همان مقداری است که شما در WireMock stub نوشتهاید. اکنون یک فایل، هر دو بخش جریان را پوشش میدهد.
hurl --test --variable base_url=http://127.0.0.1:3000 \
--report-junit reports/junit.xml \
--report-json reports/json \
tests/یک اجرای موفق، یک خط به ازای هر فایل و یک خلاصه چاپ میکند.
tests/checkout.hurl: Success (2 request(s) in 61 ms)
Executed files: 1
Executed requests: 2 (30.1/s)
Succeeded files: 1 (100.0%)
Failed files: 0 (0.0%)
Duration: 64 msدر صورت شکست، error: Assert failure به همراه نام فایل و شماره خط چاپ میشود، سپس مقداری که دریافت شده در برابر مقداری که انتظار میرفت قرار میگیرد، و hurl با کد خروجی غیر صفر پایان مییابد تا CI متوقف شود. اگر status مقدار pending را بخواند در حالی که شما انتظار paid را داشتید، یعنی سرویس شما پاسخ mock را پردازش نکرده است. گام بعدی، خواندن ژورنال درخواستهای WireMock در /__admin/requests است که نشان میدهد آیا اصلاً درخواستی به mock رسیده است یا خیر.
اجرای مجموعه تست از طریق CI runner اختصاصی
با ثبت یک runner روی همان سرور، گردش کار کوتاه میشود. از آنجا که runner یک پردازش ساده روی میزبان است، docker و hurl باید روی همان میزبان نصب شده باشند. هیچچیز از imageهای میزبانیشده (hosted) به ارث نمیرسد.
name: api-tests
on: [push]
jobs:
hurl:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Start the mock
run: docker compose up -d --wait mock-payments
- name: Run the suite
run: hurl --test --variable base_url=http://127.0.0.1:3000 --report-junit reports/junit.xml tests/
- name: Archive the reports
if: always()
run: install -d /srv/api-tests/reports/$GITHUB_SHA && cp -r reports/. /srv/api-tests/reports/$GITHUB_SHA/
- name: Stop the mock
if: always()
run: docker compose downاستفاده از if: always() در مرحله archive اهمیت دارد. بدون آن، در صورت شکست تست، عملیات کپی انجام نمیشود و شما دقیقاً گزارشی را که قصد بررسی آن را داشتید، از دست میدهید. همچنین عملیات کپی باید در مسیری خارج از workspace انجام شود، زیرا runner پیش از شروع job بعدی، workspace را پاکسازی میکند و گزارشها نیز همراه آن حذف خواهند شد.
نتایج را حفظ کنید، نه فقط آخرین اجرا
یک فایل JUnit XML برای هر commit تنها به یک پرسش پاسخ میدهد: آیا تست موفق بود یا خیر؟ این فایلها به شما نمیگویند که یک endpoint از چه زمانی کند شده است، زیرا پس از بستن فایل، دیگر کسی آن را نمیخواند. برای مشاهده روند تغییرات، به ازای هر اجرا یک ردیف به یک پایگاهداده کوچک روی همان سرور اضافه کنید. یک جدول واحد که شامل commit SHA، نام فایل، تعداد موفقیتها، تعداد شکستها و مدت زمان اجرا باشد کفایت میکند و استفاده از SQLite در محیط production روی VPS گزینه مناسبی برای این کار است: یک فایل واحد، بدون نیاز به پردازش سرور، و کل تاریخچه همراه با بکآپهایی که از قبل تهیه میکنید، ذخیره میشود. خروجی --report-json ابزار Hurl را به جای فایل JUnit XML تحلیل کنید، چرا که در میان این دو، این فرمت برای خواندن توسط ماشین مناسبتر است.
چه چیزی باید پس از بازسازی کانتینر باقی بماند
تعاریف Mock و مجموعههای تست، بخشی از کد منبع هستند. این موارد باید در مخزنی در کنار سرویسی که توصیف میکنند قرار بگیرند و در همان Pull Request که یک endpoint را تغییر میدهد، اصلاح شوند. Stubهایی که در یک رابط کاربری وب ویرایش میشوند یا انتظاراتی که در زمان اجرا از طریق REST API به MockServer ارسال میشوند، فقط در حافظهٔ آن کانتینر یا پایگاه دادهٔ آن ابزار وجود دارند. با اجرای docker compose down، این موارد از بین میروند و تا زمانی که یک تست به دلیل اشتباهی با موفقیت (pass) سپری نشود، کسی متوجه آن نخواهد شد. اگر مخازن شما روی سختافزار شخصی خودتان نیز اجرا میشوند، یک سرور git شخصی باعث میشود که فیکسچرها و سرویس در یک محدودهٔ اعتماد واحد باقی بمانند.
سپس قوانین عملیاتی مطرح میشوند. تگهای ایمیج را ثابت (Pin) کنید، زیرا latest میتواند نحوهٔ تطبیق درخواستها توسط Mock شما را بدون هیچ تغییری در مخزن تغییر دهد و ردیابی علت این خطا بسیار دشوار است. زمانی که ابزار نیازی به نوشتن در دایرکتوریهای Stub ندارد، آنها را به صورت read-only mount کنید. هرگز Stubهای یک Mock را در یک Docker volume نامگذاریشده قرار ندهید، زیرا در این صورت آن Volume به منبع اصلی حقیقت (source of truth) تبدیل میشود و نسخهای که در git قرار دارد به شکلی نامحسوس منسوخ و نادرست خواهد شد.
یک نکتهٔ دیگر که بسیاری را غافلگیر میکند: اگر Stubها را با ضبط ترافیک واقعی از طریق یک پروکسی میسازید، پیش از commit کردن، تمام فایلهای تولیدشده را بخوانید. یک فایل ضبطشده دقیقاً شامل همان چیزی است که سرویس بالادستی (upstream) بازگردانده است، از جمله توکنهای احراز هویت (bearer tokens) و آدرسهای ایمیل مشتریان. commit کردن این فایلها باعث میشود که اطلاعات مذکور برای همیشه در مخزن شما باقی بمانند، زیرا git محتوای حذفشده را در تاریخچهٔ خود نگه میدارد.
FAQ
تفاوت بین یک سرور mock برای API و یک test runner برای API چیست؟
یک سرور mock به درخواستها پاسخ میدهد. این سرور جایگزین وابستگیهایی میشود که نمیتوانید از داخل CI با آنها تماس بگیرید و هرگز وضعیت موفقیت یا شکست را گزارش نمیکند. یک API test runner درخواستها را به سرویس شما میفرستد، روی پاسخها assertion انجام میدهد، مقادیر را از یک فراخوانی به فراخوانی بعدی منتقل میکند و در صورت شکست یک assertion، با کد خروجی غیر صفر متوقف میشود. این دو ابزار مشکلات متفاوتی را حل میکنند و در یک پیکربندی معمول، هر دو همزمان اجرا میشوند: runner با سرویس شما تماس میگیرد و سرویس شما با mock تماس میگیرد.
آیا میتوانم یک API داخلی را از یک CI runner میزبانیشده تست کنم؟
بدون expose کردن آن، خیر. یک runner میزبانیشده خارج از شبکه شما قرار دارد، بنابراین نمیتواند به سرویسی که به یک آدرس خصوصی متصل است دسترسی پیدا کند. گزینههای شما شامل انتشار API، اجرای یک tunnel یا نگهداری یک نسخه staging عمومی است که هر کدام سیستمی را اضافه میکنند که ممکن است دچار خرابی یا نشت اطلاعات شود. یک runner در همان شبکه خصوصی مستقیماً با سرویس تماس میگیرد، که دلیل اصلی و عملی برای self-host کردن این فرآیند توسط تیمها است.
فایلهای stub برای mock و مجموعههای تست API باید کجا قرار بگیرند؟
در git، در کنار سرویسی که توصیف میکنند. ابزارهایی که تعاریف را به صورت فایل ذخیره میکنند، مانند دایرکتوری mappings/ در WireMock، فایل داده Mockoon، فایلهای Hurl و پوشه .bru در Bruno، به شما امکان code review میدهند و بازسازی container را بدون هزینه ممکن میسازند. ابزارهایی که تعاریف را در دیتابیس یا یک رابط کاربری وب ذخیره میکنند، به یک برنامه پشتیبانگیری و مرحله export نیاز دارند؛ و export همان بخشی است که افراد تا زمانی که container از بین نرفته است، فراموش میکنند.
چرا با وجود صحیح بودن stub، سرور mock من خطای 404 برمیگرداند؟
WireMock یک stub را فقط در صورت تطابق دقیق ارائه میدهد. یک درخواست بدون تطابق، خطای 404 را با بدنهای که با Request was not matched شروع میشود دریافت میکند؛ به دنبال آن یک diff نسبت به نزدیکترین stub نمایش داده میشود که فیلد متفاوت را مشخص میکند. دلایل رایج عبارتند از: وجود یک اسلش اضافی در انتهای مسیر، هدر Content-Type که stub به آن نیاز دارد اما کلاینت شما ارسال نکرده است، استفاده از urlPath در جایی که stub برای یک بخش متغیر به urlPathPattern نیاز دارد، و یک body matcher که با payload مطابقت ندارد. ابتدا /__admin/requests را بررسی کنید تا مطمئن شوید درخواست اصلاً به mock رسیده است یا خیر.
آیا با وجود محیط staging، همچنان به mock نیاز دارم؟
بله، به دو دلیل. یک نسخه staging از سرویس بالادستی که کنترل آن را ندارید، همچنان ممکن است از دسترس خارج شود یا شما را محدود به rate limit کند، بنابراین مجموعه تست شما به دلایلی که هیچ ارتباطی به کد شما ندارند، شکست میخورد. همچنین این محیط نمیتواند پاسخهایی که بیشترین نیاز را به تست آنها دارید، مانند کارت رد شده یا timeout درگاه پرداخت، را تولید کند. یک mock این موارد را در صورت تقاضا و با سرعت شبکه محلی برمیگرداند، که باعث میشود مجموعهای از تستها که در برابر یک sandbox دقایق طول میکشید، در عرض چند ثانیه اجرا شود. محیط staging را برای بررسی نهایی پیش از release نگه دارید و در CI از mock استفاده کنید.