كيفية اختبار تطبيقات Serverless على AWS باحترافية
مقدمة: لماذا أصبح اختبار تطبيقات Serverless أكثر أهمية؟
خلال السنوات الماضية، اكتسبت بنية Serverless انتشاراً واسعاً، لأنها خففت عبء إدارة البنية التحتية التقليدية مثل الخوادم والتخزين وقواعد البيانات والتوسع. ورغم أن مصطلح serverless قد يبدو مضللاً للوهلة الأولى، فإن الخوادم ما تزال موجودة فعلياً، لكن مزود الخدمة السحابية هو من يتولى إدارتها وتشغيلها.
هذا النموذج يمنح الفرق التقنية مساحة أكبر للتركيز على بناء تطبيقات سريعة وفعالة ومنخفضة التكلفة بدلاً من الانشغال بالتشغيل والصيانة. لكن مقابل هذه البساطة في التطوير والنشر، تظهر تحديات جديدة في جانب الاختبار، خصوصاً عندما يعتمد التطبيق على خدمات سحابية مترابطة مثل AWS Lambda وAPI Gateway وDynamoDB.

ما التحديات الحقيقية عند اختبار تطبيقات Serverless؟
اختبار هذا النوع من التطبيقات ليس معقداً من حيث المبدأ، لكنه يختلف عن التطبيقات التقليدية في عدة نقاط:
- الاختبار المحلي أصعب لأن التطبيق يعتمد على خدمات سحابية خارجية.
- اختبارات Unit Testing تحتاج غالباً إلى mocking وstubbing للخدمات.
- اختبارات Integration Testing أكثر حساسية بسبب تعدد المكونات المترابطة.
- الأخطاء قد لا تكون برمجية فقط، بل قد تظهر أيضاً في الإعدادات والصلاحيات والبنية التحتية.
لذلك تظهر أسئلة مهمة مثل:
- ما الذي يجب اختباره فعلاً؟
- كيف نختبر كل مكوّن بشكل مستقل؟
- كيف نضمن أن التطبيق سيعمل في البيئة الحقيقية إذا كنا نستخدم mocks محلياً؟
- كيف نختبر تكامل التطبيق مع خدمات AWS دون أن نعيد اختبار AWS نفسها؟
الهدف هنا هو بناء استراتيجية اختبار ذكية، تركز على منطق التطبيق وطريقة تفاعله مع الخدمات، لا على اختبار الخدمة السحابية نفسها.
ما التطبيق الذي سنعتمد عليه في الشرح؟
سنعتمد على مثال لتطبيق بسيط يعرض قائمة بشركات وهمية مدرجة في السوق. البنية الخلفية للتطبيق تتكوّن من:
- Lambda لتنفيذ منطق التطبيق.
- API Gateway لاستقبال الطلبات وتوجيهها.
- DynamoDB لتخزين بيانات الشركات.
يتصل الواجهة الأمامية بالـ API، ثم يمر الطلب عبر API Gateway إلى Lambda، والتي تستعلم من DynamoDB وتعيد النتيجة.

اختبار تطبيقات Serverless: من أين نبدأ؟
أفضل منهج عملي هو تقسيم الاختبارات إلى مستويات واضحة:
- Unit Tests لكل مكوّن أو دالة بشكل مستقل.
- Integration Tests للتحقق من تكامل الخدمات مع بعضها.
- E2E أو Full Flow Tests لاختبار المسار الكامل من الطلب حتى الاستجابة.
بهذا التدرج، نكشف الأخطاء مبكراً في الطبقات الأساسية قبل الوصول إلى السيناريوهات الكاملة.
اختبار DynamoDB على مستوى Unit
هل نختبر قاعدة البيانات نفسها؟
الإجابة المختصرة: لا. لسنا بحاجة لاختبار محرك DynamoDB نفسه، لأن AWS قامت بذلك بالفعل. ما نحتاجه هو اختبار كيفية تهيئة الجدول ونمط الوصول إلى البيانات وصلاحيات الوصول.
تعريف البنية التحتية للجدول قد يكون بالشكل التالي:
Resources : CompaniesTable: Type: AWS::DynamoDB::Table Properties : TableName: companies AttributeDefinitions : - AttributeName: CompanyId AttributeType : S - AttributeName: CompanyType AttributeType : S KeySchema : - AttributeName: CompanyId KeyType : HASH - AttributeName: CompanyType KeyType : RANGE ProvisionedThroughput : ReadCapacityUnits: 5 WriteCapacityUnits : 5 Tags : - Key: author Value : ali CompaniesTableReadOnlyAccessPolicy : Type: AWS::IAM::ManagedPolicy Properties : Description: Policy for Read only companies table ManagedPolicyName : companies-readonly-access Path : / PolicyDocument: Version: 2012 -10 -17 Statement : - Sid: CompaniesReadOnlyAccess Effect : Allow Action : - 'dynamodb:Scan' - 'dynamodb:GetItem' - 'dynamodb:Query' Resource : - !GetAtt CompaniesTable.Arn
ما الذي ينبغي التحقق منه؟
في هذا النوع من الجداول، يجب التركيز على:
- هل تم تعريف Primary Key بشكل صحيح؟
- هل نمط الوصول المتوقع مدعوم بالمفاتيح الحالية؟
- هل نحتاج إلى Secondary Index لبعض الاستعلامات؟
- هل صلاحيات IAM تسمح بالعمليات المطلوبة فقط؟
لأن تصميم DynamoDB يعتمد بقوة على Access Patterns، فإن الاختبار يجب أن ينطلق من حالات الاستخدام الفعلية في التطبيق.
اختبار نمط الوصول عبر سكربت Bash
من الطرق العملية كتابة سكربت بسيط يُنفذ بعد إنشاء الجدول للتأكد من أن الإدخال والاسترجاع والحذف تعمل كما هو متوقع:
#! /bin/ bash # Insert a record into the dynamoDB table aws dynamodb put-item \ --table-name companies \ --item '{ "CompanyId": { "S": "COMPANY#4" }, "CompanyType": { "S": "PRIVATE" }, "Details": { "M": { "name": { "S": "Awesome Company" }, "revenue": { "S": "1000000" } } } }' \ -- return -consumed-capacity TOTAL \ -- return -item-collection-metrics SIZE # Query the added record from the table RESULT=$( aws dynamodb get-item \ --table-name companies \ --key file: //key.json \ -- return -consumed-capacity TOTAL ) echo $RESULT ### OUTPUT ### # { # "Item" : { # "Details" : { # "M" : { "name" : { "S" : "Kousa" }, "revenue" : { "S" : "1000000" } } # }, # "CompanyType" : { "S" : "PRIVATE" }, # "CompanyId" : { "S" : "COMPANY#4" } # }, # "ConsumedCapacity" : { "CapacityUnits" : 0.5 , "TableName" : "companies" } # } # parse the name and verify it 's correct NAME=$(jq -r ' .Item.Details.M.name.S ' <<< "$RESULT") echo $NAME if [ "$NAME" = "Awesome Company" ]; then echo ' The item was retrieved correctly. ' exit 0 else echo ' Something went wrong. Double-check the schema or the query. ' exit 1 fi # Delete the added item aws dynamodb delete-item \ --table-name companies \ --key file://key.json
متى يكون هذا النوع من الاختبارات مفيداً؟
هذه الاختبارات تضيف قيمة خاصة عندما يكون الجدول مستخدماً من أكثر من خدمة أو فريق. في هذه الحالة، يصبح من المفيد جداً حماية تغييرات البنية التحتية عبر اختبارات تلقائية قبل النشر.
لكن يجب الانتباه إلى عدة اعتبارات:
- متى سيتم تشغيل السكربت؟
- هل سيعمل على قاعدة اختبار مستقلة أم على بيئة مشتركة؟
- هل هناك خطر على بيانات البيئة الحالية؟
- هل يتطلب الأمر تمرير بيانات اعتماد AWS داخل خطوط CI/CD؟
آلية أكثر أماناً داخل CI/CD
لخفض المخاطر، يمكن اعتماد هذا التسلسل:
- إنشاء جدول مؤقت في بيئة الاختبار على AWS.
- تشغيل اختبارات Bash عليه.
- عند النجاح: حذف الجدول المؤقت وتحديث جدول الاختبار الفعلي.
- عند الفشل: حذف الجدول المؤقت وإيقاف عملية النشر.
اختبار Lambda على مستوى Unit
المشكلة في وضع كل المنطق داخل Handler
من أشهر الأخطاء في تطبيقات Lambda وضع كل شيء داخل handler واحد كبير. هذا يجعل الاختبار أصعب، والصيانة أكثر تعقيداً. المثال التالي يوضح هذا النمط:
const { DynamoDB } = require ( '@aws-sdk/client-dynamodb' ); // importing library from aws-sdk that allows the interaction with dynamodb const { unmarshall } = require ( '@aws-sdk/util-dynamodb' ); // importing unmarshall function, which converts a DynamoDB record into a JavaScript object exports .lambdaHandler = async (event, context) => { try { console .log( 'here is the event received' , event); const dynamodb = new DynamoDB({ region : 'us-east-1' }); // creating a new instance of DynamoDB const params = { TableName : 'companies' , }; // if no query parameter was passed to the function, then update the dynamodb params to query all companies if (!event.queryStringParameters) { params.ExpressionAttributeValues = { ':companyType' : { S : 'PUBLIC' , }, }; params.FilterExpression = 'CompanyType = :companyType' ; } // if the companyId query paramater was passed, then update the dynamodb params to filter according that company exactly else { params.ExpressionAttributeValues = { ':companyId' : { S : `COMPANY# ${event.queryStringParameters.companyId} ` , }, }; params.FilterExpression = 'CompanyId = :companyId' ; } const results = await dynamodb.scan(params); // get the results from dynamodb according to the previously set params console .log( 'results' , results); let unmarshalledResults = []; // unmarshall every record returned (convert it into a JS object) for ( const item of results.Items) { const unmarshalledRecord = unmarshall(item); unmarshalledResults.push(unmarshalledRecord); } console .log( 'unmarshalled results' , unmarshalledResults); return { statusCode : 200 , headers : { 'Content-Type' : 'application/json' , 'Access-Control-Allow-Origin' : '*' , }, body : JSON .stringify(unmarshalledResults), }; } catch (e) { console .error( 'something went wrong' , e); return { statusCode : 500 , body : 'Something has gone wrong, please contact the support team' , }; } };
هذا الكود يجمع بين استقبال الطلب، وبناء الاستعلام، والاتصال بـ DynamoDB، وتحويل النتائج، وصياغة الاستجابة. الأفضل فصل هذه المسؤوليات لتسهيل الاختبار.
إعادة هيكلة الكود لتحسين قابلية الاختبار
يمكن استخراج منطق الاستعلام والتحويل إلى دالة مستقلة:
const { DynamoDB } = require ( '@aws-sdk/client-dynamodb' ); const { unmarshall } = require ( '@aws-sdk/util-dynamodb' ); const dynamodb = new DynamoDB({ region : 'us-east-1' }); const scanAndFilterData = async (params) => { const results = await dynamodb.scan(params); console .log( 'results' , results); let unmarshalledResults = []; for ( const item of results.Items) { const unmarshalledRecord = unmarshall(item); unmarshalledResults.push(unmarshalledRecord); } console .log( 'unmarshalled results' , unmarshalledResults); return unmarshalledResults; } module .exports = { scanAndFilterData }
بهذا يصبح handler مسؤولاً فقط عن:
- استقبال event.
- تحديد معايير الاستعلام.
- استدعاء الدالة المناسبة.
- إرجاع الاستجابة النهائية.
اختبار الدالة المعزولة مع Mocking
في اختبارات Unit Testing، من غير المنطقي الوصول الحقيقي إلى DynamoDB. لذلك يجب عمل mock لمكتبات @aws-sdk:
const { scanAndFilterData } = require ( '../dynamoDbData' ); jest.mock( '@aws-sdk/client-dynamodb' , () => { return { DynamoDB : jest.fn().mockReturnValue({ scan : jest.fn().mockReturnValue({ Items : [ 'item1' , 'item2' ]})}) } }) jest.mock( '@aws-sdk/util-dynamodb' , () => { return { unmarshall : jest.fn().mockReturnValue( 'test' ) } }) describe( 'scanAndFilterData' , () => { it( 'should scan the dynamoDB table and unmarchall the returned records' , async () => { const mockedDynamoDBInstance = require ( '@aws-sdk/client-dynamodb' ).DynamoDB; const params = 'test' const result = await scanAndFilterData(params); expect(mockedDynamoDBInstance.mock.results[ 0 ].value.scan).toHaveBeenCalledWith(params); const mockedUnmarshall = require ( '@aws-sdk/util-dynamodb' ).unmarshall; expect(mockedUnmarshall).toHaveBeenNthCalledWith( 1 , 'item1' ); expect(mockedUnmarshall).toHaveBeenNthCalledWith( 2 , 'item2' ); expect(result).toEqual([ 'test' , 'test' ]); }) })
الفكرة هنا هي التأكد من أن الكود:
- استدعى scan بالمعاملات الصحيحة.
- مرّ على العناصر المعادة.
- استدعى unmarshall لكل عنصر.
- أعاد النتيجة المتوقعة.
اختبار Lambda Handler
بعد فصل المنطق، يصبح اختبار handler أكثر وضوحاً، لأنه يركز على بناء المعاملات الصحيحة بناءً على الطلب الوارد:
const { lambdaHandler } = require ( "../app" ); jest.mock( '../dynamoDbData' , () => { return { scanAndFilterData : jest.fn() }; }) describe( 'lambda handler' , () => { it( 'should only filter by compay type and return the result accordingly' , async ()=> { const event = {} const result = await lambdaHandler(event); const mockedScanAndFilterData = require ( '../dynamoDbData' ).scanAndFilterData expect(mockedScanAndFilterData).toHaveBeenCalledWith({ "ExpressionAttributeValues" : { ":companyType" : { "S" : "PUBLIC" }}, "FilterExpression" : "CompanyType = :companyType" , "TableName" : "companies" }); }) it( 'should filter by passed company ID and return the result accordingly' , async ()=> { const event = { queryStringParameters : { companyId : '1' }}; const result = await lambdaHandler(event); const mockedScanAndFilterData = require ( '../dynamoDbData' ).scanAndFilterData expect(mockedScanAndFilterData).toHaveBeenCalledWith({ "ExpressionAttributeValues" : { ":companyId" : { "S" : "COMPANY#1" }}, "FilterExpression" : "CompanyId = :companyId" , "TableName" : "companies" }); }) })
هذه الاختبارات لا تختبر الخدمة السحابية نفسها، بل تتحقق من أن المنطق البرمجي يتصرف كما هو متوقع.
ملاحظة مهمة حول استخدام scan في DynamoDB
يجب الانتباه إلى أن عملية scan على DynamoDB قد تكون مكلفة في بيئات الإنتاج، لأنها تعمل على كامل الجدول. في التطبيقات الحقيقية، من الأفضل تقييم استخدام query أو إعادة تصميم المفاتيح والفهارس بما يلائم أنماط القراءة الفعلية.
اختبار التكامل لتطبيق Serverless
بعد الانتهاء من اختبارات الوحدات، ننتقل إلى مستوى أعلى: Integration Testing. هنا لا نختبر كل مكوّن بمعزل، بل نتحقق من صحة التعاون بين API Gateway وLambda وDynamoDB.
1) اختبار المسار الكامل Full Flow Testing
هذا النوع من الاختبارات يمثل اختبار API فعلياً، ويغطي الرحلة الكاملة للطلب داخل النظام. يمكن تنفيذه باستخدام أدوات متنوعة مثل:
- Postman
- Mocha
- Chai
- Cucumber
- أو أي إطار اختبار HTTP داخل المشروع نفسه
من السيناريوهات الأساسية التي ينبغي تغطيتها:
- جلب جميع الشركات العامة.
- جلب بيانات شركة عامة واحدة.
- محاولة جلب بيانات شركة خاصة.



السيناريو الأخير يكشف ثغرة أمنية مهمة: قد يتمكن المستخدم من الوصول إلى بيانات شركة خاصة إذا مرر معرّفاً معيناً في الطلب. وهذه بالضبط فائدة اختبارات التكامل، فهي لا تكتفي بفحص نجاح الاستجابة، بل تفضح الأخطاء المنطقية والأمنية أيضاً.
هل من الأفضل استخدام Postman أم كتابة اختبارات داخل المشروع؟
رغم أن Postman ممتاز لاختبار الـ APIs، فإن بناء الاختبارات داخل نفس مستودع الكود غالباً يكون أكثر فاعلية للأسباب التالية:
- سهولة إدارة الاختبارات مع الإصدارات عبر version control.
- تحفيز المطورين على كتابة الاختبارات وصيانتها.
- تبسيط التعاون بين أعضاء الفريق.
- سهولة دمجها داخل خطوط CI/CD.
كيف نجهز البيانات لاختبارات التكامل؟
نجاح هذا النوع من الاختبارات يعتمد كثيراً على وجود بيانات متوقعة داخل البيئة. ويمكن التعامل مع ذلك بعدة طرق:
- الاعتماد على بيانات ثابتة موجودة مسبقاً.
- إنشاء API مخصص لإعداد البيانات، وآخر لتنظيفها بعد الاختبار.
الخيار الثاني أفضل غالباً، لأنه يزيل الاعتماد على حالة البيئة الحالية، ويجعل الاختبار قابلاً للتكرار بشكل موثوق.
2) اختبار تكامل Lambda أثناء النشر
هنا ننتقل إلى زاوية مختلفة من الاختبار: التأكد من أن النسخة الجديدة من Lambda تعمل بشكل صحيح أثناء النشر نفسه، وليس فقط قبله. وهذا مهم جداً لتقليل مخاطر الأعطال والانقطاعات.
يمكن تنفيذ ذلك باستخدام CodeDeploy مع آلية traffic shifting، بالإضافة إلى الخطافات:
- Before Traffic Hook
- After Traffic Hook
هذه الخطافات هي دوال Lambda مستقلة تُستخدم لإجراء sanity checks قبل تحويل الزيارات إلى النسخة الجديدة وبعدها.
اختبار Before Traffic Hook
في هذا السيناريو، يتم استدعاء النسخة الجديدة من الدالة قبل أن تتلقى أي طلبات حقيقية من المستخدمين. إذا نجحت الفحوصات، يسمح CodeDeploy باستمرار النشر. وإذا فشلت، يوقف عملية التحويل فوراً.
exports .handler = ( event, context, callback ) => { const deploymentId = event.DeploymentId; const lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId; const functionToTest = process.env.NewVersion; // Create parameters to pass to the updated Lambda function that // include the newly added "time" option. If the function did not // update, then the "time" option is invalid and function returns // a statusCode of 400 indicating it failed. const lambdaParams = { FunctionName : functionToTest, InvocationType : "RequestResponse" , }; const lambdaResult = "Failed" ; lambda.invoke(lambdaParams, function ( err, data ) { if (err) { console .log(err, err.stack); lambdaResult = "Failed" ; } else { const result = JSON .parse(data.Payload); if (result.statusCode != "400" ) { console .log( "Validation succeeded" ); lambdaResult = "Succeeded" ; } else { console .log( "Validation failed" ); } var params = { deploymentId : deploymentId, lifecycleEventHookExecutionId : lifecycleEventHookExecutionId, status : lambdaResult, // status can be 'Succeeded' or 'Failed' }; codedeploy.putLifecycleEventHookExecutionStatus( params, function ( err, data ) { if (err) { console .log( "CodeDeploy Status update failed" ); console .log(err, err.stack); callback( "CodeDeploy Status update failed" ); } else { console .log( "CodeDeploy status updated successfully" ); callback( null , "CodeDeploy status updated successfully" ); } } ); } }); };
في المثال أعلاه، يتم استدعاء الدالة الجديدة، ثم يتم إبلاغ CodeDeploy بنتيجة الفحص عبر:
codedeploy.putLifecycleEventHookExecutionStatus
ويمكن تعديل هذا الفحص ليتحقق من:
- صحة صيغة الاستجابة.
- إمكانية الوصول إلى DynamoDB.
- صحة البيانات المعادة.
- استجابة سيناريو محدد متوقع.

اختبار After Traffic Hook
بعد تحويل الزيارات إلى النسخة الجديدة، يمكن تنفيذ اختبارات إضافية للتأكد من أن التطبيق يعمل فعلياً في الوضع الحقيقي. غالباً يكون هذا المكان مثالياً لاختبار الـ API نفسه باستخدام مكتبة مثل axios.
exports .handler = ( event, context, callback ) => { const deploymentId = event.DeploymentId; const lifecycleEventHookExecutionId = event.LifecycleEventHookExecutionId; let lambdaResult = "Failed" ; axios .get( "https://nfc079xjo3.execute-api.us-east-1.amazonaws.com/Prod/companies" ) .then( function ( response ) { if (response.data.length == 2 ) { lambdaResult = "Succeeded" ; } }) .catch( function ( error ) { console .log( "An error occured" , error); }) .then( function ( ) { const params = { deploymentId : deploymentId, lifecycleEventHookExecutionId : lifecycleEventHookExecutionId, status : lambdaResult, // status can be 'Succeeded' or 'Failed' }; codedeploy.putLifecycleEventHookExecutionStatus( params, function ( err, data ) { if (err) { // Validation failed. console .log( "AfterAllowTestTraffic validation tests failed" ); console .log(err, err.stack); callback( "CodeDeploy Status update failed" ); } else { // Validation succeeded. console .log( "AfterAllowTestTraffic validation tests succeeded" ); callback( null , "AfterAllowTestTraffic validation tests succeeded" ); } } ); }); };
إذا فشل هذا الاختبار، يستطيع CodeDeploy تنفيذ rollback تلقائي وإعادة النسخة السابقة المستقرة.

هل تكفي اختبارات النشر لتعويض اختبارات API؟
لا يُنصح بالاعتماد عليها وحدها. اختبارات Before Traffic وAfter Traffic ممتازة لزيادة أمان النشر وتقليل المخاطر الإنتاجية، لكنها ليست بديلاً كاملاً عن اختبارات API وIntegration وUnit.
الأفضل هو الجمع بين المستويات التالية:
- Unit Tests لاختبار المنطق الداخلي بسرعة.
- Integration Tests لاختبار تكامل المكونات.
- API أو E2E Tests لاختبار السلوك الكامل.
- Deployment Hooks لحماية الإطلاق التدريجي في الإنتاج.
أفضل الممارسات لاختبار تطبيقات Serverless على AWS
| المستوى | ما الذي يُختبر؟ | الهدف |
|---|---|---|
| Unit Testing | الدوال والمنطق الداخلي | كشف أخطاء الكود مبكراً |
| Integration Testing | تكامل Lambda مع DynamoDB وAPI Gateway | التحقق من صحة الترابط بين الخدمات |
| API / E2E Testing | المسار الكامل للطلبات | اختبار السلوك الفعلي من منظور المستخدم |
| Deployment Validation | اختبارات أثناء النشر عبر CodeDeploy | تقليل المخاطر ومنع الأعطال في الإنتاج |
ومن التوصيات العملية أيضاً:
- افصل منطق الأعمال عن Lambda handler.
- استخدم mocking في اختبارات الوحدات لتجنب الاعتماد على الخدمات السحابية.
- صمم اختبارات تكامل تغطي سيناريوهات النجاح والفشل والثغرات الأمنية.
- جهّز بيانات اختبار قابلة للإنشاء والتنظيف تلقائياً.
- ادمج الاختبارات داخل خطوط CI/CD.
- استخدم traffic shifting لتقليل أثر الإصدارات الجديدة على المستخدمين.
هل يمكن الاختبار محلياً؟
نعم، يمكن ذلك إلى حد ما باستخدام أدوات مثل localstack، والتي تساعد في محاكاة جزء من خدمات AWS محلياً. لكن حتى مع هذه الأدوات، سيظل الاختبار الحقيقي في بيئة سحابية قريبة من الإنتاج ضرورياً، لأن بعض الفروقات في الإعدادات والصلاحيات والشبكات قد لا تظهر محلياً.
الخلاصة التقنية
اختبار تطبيقات Serverless على AWS لا يختلف في جوهره عن اختبار أي نظام حديث، لكنه يتطلب وعياً أكبر بحدود كل طبقة: ما الذي نختبره في الكود، وما الذي نختبره في التكامل، وما الذي نؤمنه أثناء النشر. من الناحية العملية، أفضل استراتيجية هي تقليل التعقيد داخل Lambda، واستخدام Unit Tests قوية، ثم دعمها باختبارات تكامل وAPI، وأخيراً تأمين النشر عبر CodeDeploy hooks. هذا المزيج يمنحك ثقة أعلى في الجودة، ويقلل احتمالات الأعطال، ويحسن جاهزية التطبيق للإنتاج بشكل ملحوظ.