بايثون لأتمتة DevOps

اختبار كود الأتمتة

18 دقيقة الدرس 9 من 28

اختبار كود الأتمتة

سكريبت النشر الذي ينجح في بيئة الاختبار ثم يُفسد الإنتاج بصمت، أشد خطورةً من السكريبت الذي يفشل صراحةً في أول تشغيل. كود الأتمتة يتفاعل مع بنية تحتية حقيقية — واجهات برمجية سحابية، قواعد بيانات، نظام ملفات، خدمات شبكية — مما يجعله بالغ الخطورة إذا شُحن دون اختبار. في شركات كـ Google وNetflix، يخضع كود البنية التحتية لنفس بوابات مراجعة الكود وتغطية الاختبارات المطبّقة على كود المنتج. تغطي هذه الدرس كيفية تطبيق منهجية اختبار احترافية على أتمتة Python: أساسيات pytest، والمحاكاة بالـ mocks، واستراتيجيات اختبار السكريبتات التي تلمس البنية التحتية الحقيقية.

لماذا يصعب اختبار كود الأتمتة

التحدي الجوهري هو الآثار الجانبية. كل سطر مثير للاهتمام في سكريبت الأتمتة يُحدث شيئاً في العالم خارج العملية: يستدعي AWS API، يكتب ملفاً، ينفّذ subprocess، أو يرسل رسالة Slack. اختبار ساذج يستدعي تلك APIs فعلاً سيكلّف مالاً، ويتطلب بيانات اعتماد، ويترك موارد سحابية وراءه، وقد يستغرق دقائق. الحل هو عزل الآثار الجانبية بـ mocks — كائنات تتظاهر بأنها التبعية الحقيقية وتسجّل ما يُستدعى عليها. يتحقق الاختبار بعد ذلك من سجل استدعاءات الـ mock لا من حالة السحابة.

إعداد pytest

pytest هو مشغّل الاختبارات المعياري لكود أتمتة Python. يكتشف الاختبارات تلقائياً، ويُنتج مخرجات فشل واضحة، ولديه نظام إضافات غني. ثبّته مع تبعيات مشروعك:

# تثبيت pytest والمكتبات المساعدة في بيئتك الافتراضية pip install pytest pytest-mock moto[s3,ec2] responses # هيكل المشروع الموصى به my-ops-tool/ ├── src/ │ └── ops/ │ ├── __init__.py │ ├── s3.py │ ├── ec2.py │ └── deploy.py ├── tests/ │ ├── conftest.py # fixtures مشتركة │ ├── unit/ │ │ ├── test_s3.py │ │ └── test_deploy.py │ └── integration/ │ └── test_ec2_integration.py ├── pyproject.toml └── pytest.ini

يتحكم pytest.ini (أو [tool.pytest.ini_options] داخل pyproject.toml) في الاكتشاف والمخرجات:

# pytest.ini [pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v --tb=short --strict-markers markers = unit: سريع، لا استدعاءات خارجية integration: قد يستدعي AWS الحقيقي (بطيء، يحتاج بيانات اعتماد) smoke: فحص أدنى من البداية للنهاية
فكرة أساسية: ضع علامة على كل اختبار بـ @pytest.mark.unit أو @pytest.mark.integration. في CI، شغّل pytest -m unit فقط على كل pull request (سريع، لا يحتاج أسراراً). احتفظ بـ -m integration لخط أنابيب ليلي يحقن بيانات اعتماد السحابة عبر secrets manager. هذا النمط مُطبَّق حرفياً في كثير من الشركات الكبرى.

المحاكاة بـ pytest-mock وunittest.mock

تُعرِّض إضافة pytest-mock fixture اسمه mocker يُغلّف مكتبة unittest.mock القياسية في Python. أهم كائنَين هما MagicMock (mock للأغراض العامة) وpatch (مدير سياق أو مزخرف يستبدل اسماً في فضاء أسماء الوحدة طوال فترة الاختبار).

# src/ops/s3.py — الوحدة قيد الاختبار import boto3 def list_large_objects(bucket: str, threshold_mb: int = 100) -> list[dict]: """إرجاع الكائنات الأكبر من threshold_mb من دلو S3.""" s3 = boto3.client("s3") paginator = s3.get_paginator("list_objects_v2") large = [] for page in paginator.paginate(Bucket=bucket): for obj in page.get("Contents", []): if obj["Size"] > threshold_mb * 1024 * 1024: large.append({"key": obj["Key"], "size_mb": obj["Size"] // (1024 * 1024)}) return large # tests/unit/test_s3.py import pytest from ops.s3 import list_large_objects @pytest.mark.unit def test_list_large_objects_filters_correctly(mocker): # بناء paginator وهمي يُرجع صفحة واحدة fake_page = { "Contents": [ {"Key": "logs/small.log", "Size": 5 * 1024 * 1024}, # 5 MB — أقل من الحد {"Key": "dumps/large.sql", "Size": 500 * 1024 * 1024}, # 500 MB — أعلى من الحد ] } mock_paginator = mocker.MagicMock() mock_paginator.paginate.return_value = [fake_page] mock_s3 = mocker.MagicMock() mock_s3.get_paginator.return_value = mock_paginator # تصحيح boto3.client لمنع أي تواصل مع AWS mocker.patch("ops.s3.boto3.client", return_value=mock_s3) result = list_large_objects("my-bucket", threshold_mb=100) assert len(result) == 1 assert result[0]["key"] == "dumps/large.sql" assert result[0]["size_mb"] == 500 # التحقق من أننا طلبنا النوع الصحيح من paginator mock_s3.get_paginator.assert_called_once_with("list_objects_v2") mock_paginator.paginate.assert_called_once_with(Bucket="my-bucket")
ممارسة احترافية — صحّح حيث يُستخدَم الاسم لا حيث يُعرَّف. الكود المختبَر يستورد boto3 عبر ops.s3.boto3. يجب تصحيح ops.s3.boto3.client لا boto3.client. تصحيح فضاء الأسماء الخاطئ هو أكثر أخطاء الـ mock شيوعاً في اختبارات الأتمتة.

محاكاة HTTP بـ responses

عندما يستخدم السكريبت مكتبة requests لاستدعاء REST APIs (Datadog أو PagerDuty أو GitHub)، استخدم مكتبة responses لاعتراض تلك الاستدعاءات على مستوى الطبقة التحتية دون لمس الشبكة فعلياً:

# src/ops/pagerduty.py import requests PAGERDUTY_URL = "https://api.pagerduty.com" def get_oncall_user(schedule_id: str, token: str) -> str: """إرجاع بريد المهندس المناوب حالياً.""" resp = requests.get( f"{PAGERDUTY_URL}/oncalls", headers={"Authorization": f"Token token={token}"}, params={"schedule_ids[]": schedule_id, "limit": 1}, timeout=10, ) resp.raise_for_status() data = resp.json() return data["oncalls"][0]["user"]["email"] # tests/unit/test_pagerduty.py import responses as responses_lib import pytest from ops.pagerduty import get_oncall_user @pytest.mark.unit @responses_lib.activate def test_get_oncall_user_returns_email(): responses_lib.add( responses_lib.GET, "https://api.pagerduty.com/oncalls", json={"oncalls": [{"user": {"email": "sre@example.com"}}]}, status=200, ) email = get_oncall_user("SCH123", "fake-token") assert email == "sre@example.com" @pytest.mark.unit @responses_lib.activate def test_get_oncall_user_raises_on_403(): responses_lib.add( responses_lib.GET, "https://api.pagerduty.com/oncalls", status=403, ) import requests with pytest.raises(requests.exceptions.HTTPError): get_oncall_user("SCH123", "bad-token")

الاختبار بدلالات البنية التحتية عبر moto

لـ AWS تحديداً، تُعدّ مكتبة moto أداةً لا غنى عنها، إذ تُشغّل نقاط نهاية AWS وهمية داخل العملية ذاتها. خلافاً للـ mocks التقليدية، تفرض moto دلالات AWS الحقيقية — قيود مفاتيح S3، تقييم سياسات IAM، انتقالات حالة EC2 — مما يجعل اختباراتك أكثر واقعية بكثير من الـ mocks اليدوية:

# tests/unit/test_s3_moto.py import boto3 import pytest from moto import mock_aws from ops.s3 import list_large_objects @pytest.mark.unit @mock_aws def test_list_large_objects_with_moto(): # moto تعترض جميع استدعاءات boto3 داخل هذا النطاق s3 = boto3.client("s3", region_name="us-east-1") s3.create_bucket(Bucket="test-bucket") # رفع كائن صغير وآخر كبير s3.put_object(Bucket="test-bucket", Key="small.txt", Body=b"x" * (10 * 1024 * 1024)) # 10 MB s3.put_object(Bucket="test-bucket", Key="large.bin", Body=b"x" * (200 * 1024 * 1024)) # 200 MB result = list_large_objects("test-bucket", threshold_mb=100) assert len(result) == 1 assert result[0]["key"] == "large.bin"
خطر إنتاجي — لا تُضمّن بيانات اعتماد حقيقية في الاختبارات أبداً. كل ملف اختبار يستورد boto3 يجب إما أن يستخدم moto (التي تتجاهل بيانات الاعتماد) أو أن يعتمد على متغيرات البيئة. فشل شائع في CI: اختبار ينجح محلياً لأن المطوّر يملك بيانات اعتماد AWS حقيقية في ~/.aws/credentials، لكن يفشل في CI لأنها غائبة. تحقق دائماً أن مجموعة اختبارات الوحدة تنجح عبر: AWS_ACCESS_KEY_ID=fake AWS_SECRET_ACCESS_KEY=fake pytest -m unit قبل الدمج.

الـ Fixtures: الإعداد والتنظيف المشترك

fixtures في pytest هي دوال مُزيَّنة بـ @pytest.fixture تُوفّر سياقاً قابلاً لإعادة الاستخدام للاختبارات. ضع الـ fixtures المشتركة في tests/conftest.py — يكتشف pytest هذا الملف تلقائياً عبر مجموعة الاختبارات بأكملها:

# tests/conftest.py import os import pytest import boto3 from moto import mock_aws @pytest.fixture(scope="function") def aws_credentials(): """ضمان عدم تسرّب أي استدعاء AWS حقيقي أثناء اختبارات الوحدة.""" os.environ["AWS_ACCESS_KEY_ID"] = "testing" os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" os.environ["AWS_DEFAULT_REGION"] = "us-east-1" yield # التنظيف: لا شيء (متغيرات البيئة تُعاد تعيينها بعزل الـ fixture) @pytest.fixture def s3_bucket(aws_credentials): """توفير دلو S3 مدعوم بـ moto ومملوء مسبقاً للاختبارات.""" with mock_aws(): s3 = boto3.client("s3", region_name="us-east-1") s3.create_bucket(Bucket="ci-test-bucket") yield "ci-test-bucket" # أي اختبار يمكنه طلب 's3_bucket' كمعطى: # def test_something(s3_bucket): # result = list_large_objects(s3_bucket, threshold_mb=50) # assert ...

اختبار السكريبتات المعتمدة على subprocess

كثير من سكريبتات الأتمتة تُنفّذ أوامر خارجية عبر subprocess.run. قم بالـ mock على مستوى subprocess.run لا على مستوى Shell، للحفاظ على سرعة الاختبارات وقابليتها للنقل:

# src/ops/deploy.py import subprocess def restart_service(host: str, service: str) -> bool: result = subprocess.run( ["ssh", host, f"sudo systemctl restart {service}"], capture_output=True, text=True, timeout=30, ) return result.returncode == 0 # tests/unit/test_deploy.py import pytest from unittest.mock import patch, MagicMock from ops.deploy import restart_service @pytest.mark.unit def test_restart_service_success(mocker): mock_run = mocker.patch("ops.deploy.subprocess.run") mock_run.return_value = MagicMock(returncode=0) assert restart_service("web-01.prod", "nginx") is True mock_run.assert_called_once_with( ["ssh", "web-01.prod", "sudo systemctl restart nginx"], capture_output=True, text=True, timeout=30, ) @pytest.mark.unit def test_restart_service_failure(mocker): mock_run = mocker.patch("ops.deploy.subprocess.run") mock_run.return_value = MagicMock(returncode=1) assert restart_service("web-01.prod", "nginx") is False

قياس التغطية

تغطية الأسطر هي حدٌّ أدنى لا سقف. تقرير 95% لا يعني شيئاً إذا كانت الـ 5% غير المغطاة هي فروع معالجة الأخطاء التي تُنفَّذ فقط عندما تُعيد cloud API خطأ 500. استخدم pytest-cov لقياس التغطية وفرض حدٍّ أدنى كبوابة في CI:

pip install pytest-cov # تشغيل مع تقرير التغطية pytest -m unit --cov=src/ops --cov-report=term-missing --cov-fail-under=85 # في pyproject.toml — فرض البوابة بشكل تعريفي [tool.coverage.run] source = ["src/ops"] omit = ["*/__init__.py"] [tool.coverage.report] fail_under = 85 show_missing = true
ممارسة احترافية — اختبر مسارات الخطأ أولاً. عند تحديد أولويات التغطية في كود الأتمتة، اكتب اختبارات لكتل except، وفروع if resp.status_code != 200، ومعالجات انتهاء المهلة قبل اختبار المسار السعيد. في الإنتاج، يعمل المسار السعيد 99% من الوقت؛ أما مسار الخطأ فهو الـ 1% الذي يتسبب خطؤه في حادثة إنتاجية.

بتطبيق هذه الأنماط — fixtures في pytest، وعزل الـ mocks، وmoto لدلالات AWS، وresponses لـ HTTP، وبوابات التغطية — تتحول سكريبتات الأتمتة إلى كود قابل للتدقيق والإعادة الهيكلية والتسليم للمهندس التالي في الفريق. هذا هو المعيار الذي تُطبّقه كل فرق SRE الجادة على كود البنية التحتية.