Consumer Driven Contract Testing
با داشتن یک API با دوجین استفاده کننده(Consumer) که گاهی اوقات نیازمندی های مختلف و متضادی هم ممکن است داشته باشند ایجاد و برپایی یک قرارداد روشن و واضح بین ارائه دهنده ی سرویس(Provider) و استفاده کنندگان از سرویس با بیشتر و بیشتر شدن تمایل و گرایش به سمت معماری مایکروسرویس ها و سیستمهای توزیع شده امری ضروری و غیر قابل اجتناب می باشد. یک نرمافزار آماده جهت انتشار در محیط مشتری نیازمند تست های پیش از انتشار نرمافزار از زوایا و دیدگاههای مختلف است. تست متدهای موجود در ماژول(های) تشکیلدهنده سیستم: تست سرهم بندی متدهای وابسته به هم؛ تست ماژول های سیستم در ارتباط با یکدیگر؛ تست سناریوهای کاربر پایانی سیستم و … .به همان نسبت بلوغ یافتگی و رشد یافتگی متدولوژی های توسعه نرمافزار و همچنین متدولوژی های طراحی نرمافزار روشهای مختلف نوشتن تست نرمافزار جهت اطمینان از صحت بخشهای مختلف پازل نرمافزار نیز طی سالهای گذشته رشد پیدا کرده و تکامل یافتند. دیگر روال های تستی؛ تست نرمافزار و تیم های تست نرمافزار و مهندسی کیفیت محصول با داشتن تعداد زیادی مشتری با نیازهای متفاوت نمیتواند جوابگو باشد. با توجه به اینکه در این نوشتار توجه بر API و نوشتن تست برای آنها است بیشتر بر روی همین مقوله تمرکز خواهم کرد.
اجازه دهید با یک مثال ادامه دهم.
فرض کنید یک سرویس داریم که اطلاعات سازهای موسیقی ایرانی شامل نام آنها به فارسی؛ انگلیسی و آذربایجانی؛ توضیحاتی در مورد ساز و همچنین اینکه آیا ساز جز طبقه بندی سازهای اصلی (تار – سه تار – سنتور – نی – تنبک – کمانچه سازهایی هستند که به عنوان سازهای اصلی موسیقی ایرانی شناخته می شوند). موسیقی ایرانی میباشد یا خیر را بر می گرداند. در طراحی اولیه به یک ساختار داده بصورت زیر رسیدیم:
{
"faName":"تار",
"enName":"Tar",
"azName":"tar",
"description":"",
"isOfficialInstrument":"true",
"moreInfo":"",
}
در حال حاضر یک استفاده کننده از این سرویس وجود دارد. اما پس از مدتی تعداد استفاده کنند گان سرویس ما بیشتر می شود. برخی از این استفاده کنندگان بدلیل اینکه خارج از کنترل میباشند و بر اساس تصمیم طراحی داخلی خود بر روی دادههای برگشتی از سرویس عملیاتی خاصی از جمله اعتبار سنجی اینکه آیا فیلد خاصی پر میباشد یا خیر انجام می دهند. از طرف دیگر در سمت استفاده کنندگان بهنگام Deserialize کردن دادههای برگشتی ممکن است وجود فیلدهای خاصی از جمله moreInfo لازم و ضروری باشد.
- سناریوی اول
فرض کنید یکی از استفاده کنند گان درخواست دارد که به همراه فیلد isOfficialInstrument سالی که ساز مورد نظر به عنوان ساز اصلی شناخته شده است با ساختار زیر ارسال گردد.
{
"isOfficialInstrument":"true",
"yearOfRegister":"1285",
"gregorianYearOfRegister":"1285",
"gregorianYearOfRegister":"1906",
}
با داشتن یک Publish API و استفاده کنندگانی که خارج از کنترل میباشند به چه صورت میتوان مطمئن شد که چه وقت سایر استفاده کنندگان نیز با ساختار دیتای خروجی جدید بروزرسانی شده اند؟
یک راه داشتن دو نسخه ی متفاوت از API جدید و قدیم بصورت همزمان میباشد بصورتی که یک API خروجی با ساختار دادهای قبلی را تولید میکند و دیگری اقدام به ارسال خروجی جدید می کند.
{
"faName":"تار",
"enName":"Tar",
"azName":"tar",
"description":"",
"registrationInfo":
{
"isOfficialInstrument":"true",
"yearOfRegister":"1285",
"gregorianYearOfRegister":"1285",
"gregorianYearOfRegister":"1906",
},
"moreInfo":""
}
هر چند با این روش مشکل شکسته نشدن سایر استفاده کنندگانی که هنوز با تغییرات جدید بروزرسانی نشده اند حل میشود اما به هر روی شما هنوز راهی ندارید که متوجه شوید چه موقع سایر استفاده کنندگان سرویس تان با تغییر جدید وفق پیدا کردهاند تا API قبلی را از مدار خارج کنید و تیم شما مجبور به تولید دو API متفاوت نشود. قصد ورود به الگوهایی از جمله Backends For Frontends(BFF) ندارم.
- سناریوی دوم
تیم تولید در حین Refactor به این نتیجه میرسد که بهتر است فیلدهای مربوط به نام ساز به زبانهای مختلف را به کمک روش Extract Class بازنویسی کند. در نتیجه فیلدهای مربوط به نام ساز به یک کلاس دیگر منتقل میشوند
{
"name":
{
"fa":"تار",
"en":"Tar",
"az":"tar",
}
"description":"",
"registrationInfo":
{
"isOfficialInstrument":"true",
"yearOfRegister":"1285",
"gregorianYearOfRegister":"1285",
"gregorianYearOfRegister":"1906",
},
"moreInfo":""
}
به چه صورت مطمئن خواهید شد که این تغییر منجر به شکسته شدن سایر استفاده کنندگان سرویس شما نخواهد شد؟
- سناریوی سوم
پس از مدتی تیم تولید به این نتیجه میرسد که فیلد moreInfo بلا استفاده میباشد و بهتر است که این فیلد را از ساختار داده ی خروجی حذف کند. تیم تولید به چه صورت میتواند مطمئن شود که این حذف کردن منجر به شکسته شدن هیچ کدام از استفاده کنندگان نمی شود.
فرض کنید استفاده کننده ی X از سرویس شما به هنگام اعتبار سنجی خروجی API شما وجود این فیلد را ضروری تلقی کرده است. و یا ابزار Deserialize مورد استفاده آنها وجود این فیلد را به هنگام عملیات Deserialize ضروری تلقی کرده است. در نتیجه حذف این فیلد باعث میشود که استفاده کننده ی X دچار مشکل شود.
در این حالت برای استفاده کنند گان از سرویس توجه به این توصیه ضروری است:
Be liberal in what you accept and conservative in what you end
در این حالت به چه صورت زمان مناسب برای اعمال این تغییر را خواهید یافت؟
نکته: هر چند در سناریوهای بالا تأکید بر روی تغییرات در ساختار داده ی خروجی سرویس وجود داشت اما همین سناریوها برای پارامترهای ورودی هر سرویس نیز صادق و محتمل می باشد.
بصورت کلی دو توافق بین سرویس دهنده و سرویس گیرنده(گان) وجود دارد:
- توسعه سرویس مبتنی بر خواست و نیاز سرویس دهنده Provider-Driven Contract
در این روش سرویس دهنده آزادانه قراداد(Contract) هر سرویسی که ارائه میدهد را مشخص میکند و هر زمان که تشخیص دهد میتواند بدون در نظر گرفتن سرویس گیرنده(گان) اقدام به تغییر سرویس ها نماید و سایر سرویس گیرندگان مجبور به پیروی از این قرارداد هستند. این روش از دید سرویس دهنده بهترین و راحت ترین روش می باشد. ولی برای زمانی که تعداد سرویس گیرندگان کم میباشد و هم چنین سرویس گیرندگان تحت کنترل می باشند(مثلا سرویس گیرندگان در همان تیم یا تیمی دیگر از مجموعه باشند) قابل اعمال می باشد.
- توسعه سرویس مبتنی بر سرویس گیرنده(گان)Consumer-Driven Contract
در این روش بر خلاف روش بالا سرویس دهنده اقدام به ارائه سرویس بر اساس نیاز و به خواست هر کدام از استفاده کنندگان از سرویس می کند. در این روش به هنگام اعمال هر تغییری در سرویس ارائه شده سرویس دهنده باید مطمئن شود که آیا این تغییر اعمال شده منجر به شکست هیچ کدام از سرویس گیرندگان میشود یا خیر؟ در صورتی که سرویس دهنده ای آمادگی اعمال تغییر مورد نظر را نداشته باشد سرویس دهنده باید تغییر را به گونهای اعمال کند که سرویس دهنده ی تحت تأثیر قرار گرفته دچار مشکل نشود. به عنوان مثال تا زمان آماده شدن سرویس دهنده ی مذکور برای اعمال تغییر باید صبر کند. یا دو نسخه ی متفاوت شامل سرویس فعلی و سرویسی جدید را همزمان آپ کند. در اینجا در صورتی که سرویس دهنده از یک API مشترک برای سرویس دهندگان استفاده میکند باید به درخواست های تمامی سرویس دهندگان توجه کند و سرویسی ارائه دهد که جامع تمامی درخواست ها باشد.
در بیشتر مواقع با شرایط دوم مواجه خواهید بود. Consumer-Driven Contract testing روشی است که سعی میکند در صورتی که ارتباط بین ارائه دهنده سرویس و استفاده کنندگان از سرویس از نوع CDC بود به سؤالات مطرح شده در سناریوهای بالا پاسخ دهد.
در اینجا هر کدام از سرویس گیرندگان یک Unit Test برای هر Interaction که با سرویس دهنده دارد می نویسد. مثلاً ممکن است سرویس دهنده 10 سرویس مختلف ارائه دهد و سرویس دهنده ای فقط از 6 تا از این سرویس ها استفاده می کند. پس سناریوی تست برای این استفاده کننده شامل 6 Unit Test است که هر تست تعاملی که سرویس گیرنده با سرویس دهنده دارد را شبیه سازی میکند.
هر تست شامل:
- یک آدرس و endpoint سرویس مورد نظر
- نوع عملیات مثلاً GET یا POST
- نوع و ساختار پارامترهای ارسالی و
- در نهایت خروجی مورد نیاز
می باشد.
خروجی باید شامل کمترین اطلاعاتی باشد که هر سرویس دهنده نیاز دارد. تمامی این سناریوها سپس در فایلی record میشود که Contract نامیده می شود. خب مشخص شد منظور از Contract چیست. فایل contract بصورت مشخص و واضع توصیف کننده ی تعاملات و سناریوهایی است که یک سرویس گیرنده به هنگام استفاده از یک سرویس انتظار دارد.
{
"سناریوی گرفتن اطلاعات":
{
"endpoint":"/Instruments",
"method" : "GET",
"parameters" : "",
"Response":[
{
"id":"1"
"name":"تست ۱"
},
{
"id":"2"
"name":"تست 2"
}
],
"سناریوی گرفتن اطلاعات با ای دی":
{
"endpoint":"/Instruments/{id}",
"method" : "GET",
"parameters" : "1",
"Response:{
"id":"1"
"name":"تست ۱"
}
}
}
در مرحله ی بعد این contract های تولید شده توسط تمامی سرویس دهنده ها به ارائه دهنده سرویس داده می شود. حال نوبت سرویس دهنده است. که باید سرویس خود را براساس این contract های ارائه شده توسعه دهد. خب مشخص شد که چرا به این روش Consumer-Driven گفته می شود. سمت سرویس دهنده هم عملیات با نوشتن یک تست واحد به ازای هر فایل Contract یا به عبارتی به ازای هر سرویس گیرنده آغاز می شود. میبینیم که هم در سمت سرویس گیرنده و هم در سمت سرویس دهنده از روش Test-Driven Development(TDD) استفاده خواهیم کرد. تست های واحد سمت سرویس دهنده به این صورت میباشد که تمامی سناریوهای تولید شده در هر فایل Contract را میخواند و endpoint مورد نظر را صدا میزند و خروجی تولید شده را با خروجی مشخص شده در سناریو مقایسه میکند در صورتی که خروجی تولید شده خروجی مشخص شده در سناریو را پوشش دهد تست مورد نظر پاس می شود.
تصویر از https://docs.pact.io/how_pact_works
سناریوهای بالا را مجدد در نظر بگیرید:
تیم توسعه API؛ تغییر مورد نظر را اعمال میکند و سپس تست را اجرا میکند مشاهده میکند سرویس گیرنده ی 1 و 2 با شکست مواجه می شوند (خروجی تست را با contract تولید شده توسط این دو سرویس گیرنده که مقایسه میکند تست پاس نمی شود.). پس از اینکه این دو سرویس گیرنده آماده اعمال تغییر بودند آنها تست های خود را بر اساس API جدید مجدداً اجرا کرده و خروجی فایل contract جدیدی را ایجاد کرده و آنها را به سرویس دهنده میدهد. در نهایت سرویس دهنده با اجرای مجدد تست متوجه میشود که تمامی تست ها پاس میشود و میتواند با خیال راحت تغییر مورد نظر را اعمال کند.
روش Consumer-Driven Contract نخستین بار توسط Ian Robinson به عنوانی راه حلی جهت حل مشکل تکامل تدریجی سرویس ارائه شد و در سالهای ۲۰۱۵ و ۲۰۱۶ به مرحله adopt گزارش Technology Radar رسید. ابزارهای مختلفی برای نوشتن تست CDC توسعه داده شده که مهمترین و معروف ترین آنها Pact میباشد که توسط ThoughtWorks و بصورت Open Source با زبان ruby توسعه داده شد. کلاینت های مختلفی از جمله جاوا و سی شارپ هم توسط این تیم بعدها پیادهسازی شد.
جهت نشان دادن موارد گفته شده در بالا یک مثال ساده با زبان سی شارپ پیادهسازی کردم که میتوانید از این آدرس به این مثال دسترسی داشته باشید.
در این مثال دو سرویس دهنده وجود دارد. سرویس دهنده ۱ و سرویس دهنده ۲.
جهت پیادهسازی تست ها از این پکیچ استفاده شده است.
Pact بصورت Built in دارای سرویسی جهت ماک کردن API میباشد و شما نیاز به ابزار دیگری برای اینکار نیستید.
به هنگام نوشتن تست برای هر کدام از سرویس گیرندگان یکی از مهمترین بخشها محلی است که باید خروجی تست ها که همان فایل contract است ذخیره شود.
//Create and Config PactBuilder
PactBuilder = new PactBuilder(new PactConfig
{
SpecificationVersion = "2.0.0",
PactDir = @"..\..\..\..\..\pacts",
LogDir = @".\pactlogs"
}).ServiceConsumer("Consumer1")
.HasPactWith("Provider");
//Using IPactBuilder to Mock a PactMockProvider
MockProviderService = PactBuilder.MockService(MockServerPort);
همانطور که در بالا هم اشاره شد به ازای هر سناریوی تعامل با سرویس دهنده یک تست واحد نوشته میشود که بصورت کامل سناریو را توصیف می کند. کلاس ProviderServiceRequest توصیف کننده درخواست ارسالی در هر سناریو و کلاس ProviderServiceResponse توصیف کننده پاسخ دریافتی پس از ارسال درخواست مورد نظر می باشد.
ساختار توصیف هر سناریو بصورت کامل بصورت زیر می باشد. در بالا اشاره شد هر سرویس گیرنده برای ایجاد فایل contract باید تست های واحدی بنویسد که با یک Mock API تعامل می کند. کلاس PactBuilder دارای متدی است که این سرویس را ارائه می دهد.
_mockProviderService
.Given("Instrument is not Formal")
.UponReceiving("A valid GET request for Instruments with a secoundary instrument name")
.With(new ProviderServiceRequest
{
Method = HttpVerb.Get,
Path = "/api/Instrument",
Query = $"name={instrument}"
})
.WillRespondWith(new ProviderServiceResponse
{
Status = 400,
Headers = new Dictionary<string, object>
{
{ "Content-Type", "application/json; charset=utf-8" }
},
Body = new
{
message = invalidRequestMessage,
result = "Failed",
data = ""
}
});
همانطور که در بالا مشاهده میکنید ساختار درخواست و همچنین پاسخ بصورت کامل مشخص شده است. مثلاً این سرویس دهنده انتظار دارد در این سناریو یک پاسخ با کد وضعیت ۲۰۰ و با فرمت json و با ساختار تعیین شده در قسمت body به ازای ارسال یک درخواست به endpoint به آدرس / api/instrumentو با پارامتر name و متد GET دریافت کند.
Pact جهت تفکیک سناریوها در سمت سرویس دهنده و همچنین مپ کردن سناریوهای مختلف در سمت سرویس دهنده از یک اسم برای هر سناریو استفاده می کند. که در بالا با Given مشخص شده است. UponReceiving شامل توضیحات اضافی در مورد سناریوی مورد نظر می باشد.
پس از نوشتن تمامی سناریو ها و اجرای تست ها فایل خروجی بصورت زیر تولید میشود.
{
"consumer": {
"name": "Consumer1"
},
"provider": {
"name": "Provider"
},
"interactions": [
{
"description": "A valid GET request for Instruments with successfully response",
"providerState": "Instrument is Formal",
"request": {
"method": "get",
"path": "/api/Instrument",
"query": "name=Tar"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"message": "",
"result": "Success",
"data": ""
}
}
},
{
"description": "An invalid GET request for Instruments with invalid name",
"providerState": "There is no Instrument",
"request": {
"method": "get",
"path": "/api/Instrument",
"query": "name=Ta"
},
"response": {
"status": 404,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"message": "Ta is not valid",
"result": "Failed",
"data": ""
}
}
},
{
"description": "A valid GET request for Instruments with a secoundary instrument name",
"providerState": "Instrument is not Formal",
"request": {
"method": "get",
"path": "/api/Instrument",
"query": "name=Oud"
},
"response": {
"status": 400,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"message": "Oud is not a formal instrument",
"result": "Failed",
"data": ""
}
}
},
{
"description": "A valid GET request for all Instruments with successfully response",
"providerState": "Get All Instruments",
"request": {
"method": "get",
"path": "/api/Instrument"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"message": "",
"result": "Suucess",
"data": {
"Tar": "",
"Setar": "",
"Santur": "",
"Ney": "",
"Kamancheh": "",
"Tonbak": ""
}
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "2.0.0"
}
}
}
در سمت ارائه دهنده سرویس Pact سرویس PactVerifier را ارائه می دهد. این سرویس با گرفتن یک فایل contract تولید شده توسط هر استفاده کننده از سرویس یکی یکی سناریوهای مشخص شده در فایل contract را در سمت ارائه دهنده سرویس اجرا کرده و خروجی واقعی را با خروجی مشخص شده در سناریو مقایسه میکند و در صورت هم خوانی تست مورد نظر پاس خواهد شد. به ازای هر سرویس گیرنده ای در سمت ارائه دهنده سرویس یک تست واحد نوشته می شود.
[Fact]
public void EnsureProviderApiHonoursPactWithConsumer1()
{
// Arrange
var config = new PactVerifierConfig
{
Outputters = new List<IOutput>
{
new XUnitOutput(OutputHelper)
},
Verbose = false
};
//Act / Assert
IPactVerifier pactVerifier = new PactVerifier(config);
pactVerifier.ProviderState($"{PactServiceUri}/provider-states")
.ServiceProvider("Provider", ProviderUri)
.HonoursPactWith("Consumer1")
.PactUri(@"..\..\..\..\..\pacts\consumer1-provider.json")
.Verify();
}
[Fact]
public void EnsureProviderApiHonoursPactWithConsumer2()
{
// Arrange
var config = new PactVerifierConfig
{
Outputters = new List<IOutput>
{
new XUnitOutput(OutputHelper)
},
Verbose = false
};
//Act / Assert
IPactVerifier pactVerifier = new PactVerifier(config);
pactVerifier.ProviderState($"{PactServiceUri}/provider-states")
.ServiceProvider("Provider", ProviderUri )
.HonoursPactWith("Consumer2")
.PactUri(@"..\..\..\..\..\pacts\consumer2-provider.json")
.Verify();
}