حالات الاستخدام

أتمتة اختبار تدفق التسجيل عند وجود CAPTCHA

عندما يحرس اختبار CAPTCHA نموذج التسجيل، تتوقف أدوات الاختبار الآلي عند أول خطوة: لا يستطيع Selenium النقر على مربع «لست روبوتًا» ولا اجتياز Cloudflare Turnstile. الحل ليس تعطيل الحماية في بيئة الاختبار — فذلك يجعل الاختبار بعيدًا عن الواقع — بل إضافة طبقة تحلّ reCAPTCHA v2 أو Turnstile برمجيًا داخل بيئتك المملوكة، ثم تكمل مسار التسجيل بالكامل. في هذا الدليل نبني فاحصًا لتدفق التسجيل بلغة Python وSelenium، يطلب التوكن من CaptchaAI، ثم يتحقق من نجاح إنشاء الحساب أو ظهور رسالة الخطأ المتوقعة في حالات الحافة.


لماذا يعطّل CAPTCHA اختبارات التسجيل الآلية

نماذج التسجيل تكاد تكون دائمًا محمية باختبار CAPTCHA لمنع الحسابات الآلية المسيئة. هذه الحماية ضرورية للإنتاج، لكنها تُسقط خطوط اختبار ضمان الجودة التي تحتاج إلى إنشاء حسابات جديدة في كل تشغيل. فبدون طبقة حل، تفشل أول محاولة أتمتة قبل أن يصل الفاحص إلى منطق التحقق الفعلي الذي تريد اختباره.

الفريق الذي يملك التطبيق يحتاج، على مسار التسجيل المحمي، إلى:

  • التأكد من أن التسجيل يعمل من البداية إلى النهاية في بيئتي التدريج (Staging) والإنتاج.
  • تغطية حالات الحافة: البريد الإلكتروني المكرر، وكلمة المرور الضعيفة، والحقول الناقصة أو غير الصالحة.
  • تشغيل اختبارات الانحدار تلقائيًا مع كل عملية نشر جديدة.
  • التأكد من أن موضع اختبار CAPTCHA داخل النموذج لا يكسر بقية الحقول أو زر الإرسال.

الفكرة الجوهرية: أنت تختبر تطبيقك أنت، في بيئة تملكها ولديك إذن صريح باختبارها. طبقة الحل ما هي إلا وسيلة لتمرير حاجز التحقق نفسه حتى يستمر الفاحص في قياس ما يهم فعلًا — سلوك النموذج ومنطق التحقق من الصحة.


معمارية الفاحص

يمر كل اختبار عبر أربع مراحل متسلسلة: توليد بيانات مستخدم فريدة، ثم تعبئة النموذج مع حقن حالة الحافة، ثم حل اختبار CAPTCHA للحصول على التوكن، وأخيرًا التحقق من الحساب الناتج.

┌──────────┐     ┌───────────────┐     ┌────────────┐     ┌────────────┐
│ Test Data │────▶│ Fill Form +   │────▶│ Solve      │────▶│ Verify     │
│ Generator │     │ Edge Cases    │     │ CAPTCHA    │     │ Account    │
└──────────┘     └───────────────┘     └────────────┘     └────────────┘

هذا الفصل بين المراحل يجعل كل جزء قابلًا لإعادة الاستخدام: مولّد البيانات يضمن عدم تعارض الحسابات بين التشغيلات، وطبقة الحل معزولة في صنف مستقل يمكن استبداله أو محاكاته في الاختبارات التي لا تحتاج إلى تحقق حقيقي.


بناء الفاحص خطوة بخطوة

توليد بيانات مستخدمين فريدة

المشكلة الأولى في اختبار التسجيل المتكرر هي تعارض البيانات: البريد نفسه لا يُسجَّل مرتين. لذلك نولّد لكل تشغيل بريدًا واسم مستخدم فريدين بالاعتماد على الطابع الزمني وسلسلة عشوائية قصيرة، ونضع بادئة test_ تسهّل التعرف على الحسابات التجريبية وتنظيفها لاحقًا بشكل مجمّع.

import random
import string
import time


class TestUser:
    def __init__(self, prefix="test"):
        ts = int(time.time())
        rand = ''.join(random.choices(string.ascii_lowercase, k=4))
        self.first_name = f"{prefix}_{rand}"
        self.last_name = "User"
        self.email = f"{prefix}_{ts}_{rand}@testmail.example.com"
        self.username = f"{prefix}_{ts}_{rand}"
        self.password = f"Test!{ts}{rand.upper()}"

    def as_dict(self):
        return {
            "first_name": self.first_name,
            "last_name": self.last_name,
            "email": self.email,
            "username": self.username,
            "password": self.password,
        }

الفاحص الكامل لتدفق التسجيل

الصنف التالي يجمع كل شيء. يحتوي على CaptchaSolver الذي يرسل الطلب إلى نقطة النهاية in.php ويستطلع النتيجة من res.php حتى يعود التوكن، ثم RegistrationTester الذي يملأ النموذج ويكتشف نوع اختبار CAPTCHA تلقائيًا: إن وجد .cf-turnstile استخدم مسار Turnstile وحقن القيمة في الحقل cf-turnstile-response، وإلا فمسار reCAPTCHA v2 مع الحقل g-recaptcha-response. لاحظ أن الحل يأتي مباشرة قبل الإرسال حتى لا تنتهي صلاحية التوكن.

import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class CaptchaSolver:
    BASE = "https://ocr.captchaai.com"

    def __init__(self, api_key):
        self.api_key = api_key

    def solve_recaptcha(self, sitekey, pageurl):
        return self._solve({
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        })

    def solve_turnstile(self, sitekey, pageurl):
        return self._solve({
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": pageurl,
        })

    def _solve(self, params, initial_wait=10):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(resp["request"])
        task_id = resp["request"]
        time.sleep(initial_wait)
        for _ in range(60):
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(result["request"])
        raise TimeoutError("Timed out")


class RegistrationTester:
    def __init__(self, api_key, base_url):
        self.solver = CaptchaSolver(api_key)
        self.base_url = base_url
        self.driver = webdriver.Chrome()
        self.wait = WebDriverWait(self.driver, 10)
        self.results = []

    def _fill(self, selector, value):
        el = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, selector)))
        el.clear()
        el.send_keys(value)

    def _solve_captcha(self):
        html = self.driver.page_source
        page_url = self.driver.current_url

        # Turnstile
        turnstile = self.driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile")
        if turnstile:
            sitekey = turnstile[0].get_attribute("data-sitekey")
            token = self.solver.solve_turnstile(sitekey, page_url)
            self.driver.execute_script(
                f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
            )
            return

        # reCAPTCHA
        recaptcha = self.driver.find_elements(By.CSS_SELECTOR, "[data-sitekey]")
        if recaptcha and "recaptcha" in html.lower():
            sitekey = recaptcha[0].get_attribute("data-sitekey")
            token = self.solver.solve_recaptcha(sitekey, page_url)
            self.driver.execute_script(
                f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
            )

    def _get_errors(self):
        """Collect any visible error messages on the page."""
        error_selectors = [
            ".error", ".alert-danger", ".form-error",
            "[role='alert']", ".validation-error",
        ]
        errors = []
        for sel in error_selectors:
            for el in self.driver.find_elements(By.CSS_SELECTOR, sel):
                text = el.text.strip()
                if text:
                    errors.append(text)
        return errors

    def _check_success(self):
        """Check if registration succeeded."""
        html = self.driver.page_source.lower()
        url = self.driver.current_url.lower()
        success_indicators = [
            "welcome", "account created", "verify your email",
            "registration successful", "thank you for registering",
        ]
        return any(ind in html or ind in url for ind in success_indicators)

    # --- Test Cases ---

    def test_valid_registration(self):
        """Test: Valid registration should succeed."""
        user = TestUser()
        self.driver.get(f"{self.base_url}/register")

        self._fill("[name='firstName'], #first-name", user.first_name)
        self._fill("[name='lastName'], #last-name", user.last_name)
        self._fill("[name='email'], #email", user.email)
        self._fill("[name='username'], #username", user.username)
        self._fill("[name='password'], #password", user.password)

        confirm_fields = self.driver.find_elements(By.CSS_SELECTOR, "[name='confirmPassword'], #confirm-password")
        if confirm_fields:
            confirm_fields[0].clear()
            confirm_fields[0].send_keys(user.password)

        self._solve_captcha()

        self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
        time.sleep(3)

        success = self._check_success()
        self.results.append({
            "test": "valid_registration",
            "passed": success,
            "user": user.email,
            "errors": self._get_errors() if not success else [],
        })
        return success

    def test_duplicate_email(self):
        """Test: Duplicate email should show error."""
        user = TestUser()

        # First registration
        self.driver.get(f"{self.base_url}/register")
        self._fill("[name='email'], #email", user.email)
        self._fill("[name='password'], #password", user.password)
        self._fill("[name='firstName'], #first-name", user.first_name)
        self._solve_captcha()
        self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
        time.sleep(3)

        # Second registration with same email
        self.driver.get(f"{self.base_url}/register")
        self._fill("[name='email'], #email", user.email)
        self._fill("[name='password'], #password", user.password)
        self._fill("[name='firstName'], #first-name", "Duplicate")
        self._solve_captcha()
        self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
        time.sleep(3)

        errors = self._get_errors()
        has_error = len(errors) > 0 or not self._check_success()
        self.results.append({
            "test": "duplicate_email",
            "passed": has_error,
            "errors": errors,
        })
        return has_error

    def test_weak_password(self):
        """Test: Weak password should be rejected."""
        user = TestUser()
        self.driver.get(f"{self.base_url}/register")

        self._fill("[name='email'], #email", user.email)
        self._fill("[name='password'], #password", "123")  # Weak password
        self._fill("[name='firstName'], #first-name", user.first_name)
        self._solve_captcha()
        self.driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
        time.sleep(3)

        errors = self._get_errors()
        rejected = len(errors) > 0 or not self._check_success()
        self.results.append({
            "test": "weak_password",
            "passed": rejected,
            "errors": errors,
        })
        return rejected

    def run_all(self):
        """Run all registration tests."""
        tests = [
            self.test_valid_registration,
            self.test_duplicate_email,
            self.test_weak_password,
        ]
        for test_fn in tests:
            try:
                test_fn()
            except Exception as e:
                self.results.append({
                    "test": test_fn.__name__,
                    "passed": False,
                    "errors": [str(e)],
                })
        return self.results

    def report(self):
        passed = sum(1 for r in self.results if r["passed"])
        total = len(self.results)
        lines = [f"Registration Tests: {passed}/{total} passed", "-" * 40]
        for r in self.results:
            status = "PASS" if r["passed"] else "FAIL"
            lines.append(f"  [{status}] {r['test']}")
            if r.get("errors"):
                for err in r["errors"]:
                    lines.append(f"         {err}")
        return "\n".join(lines)

    def close(self):
        self.driver.quit()

تشغيل الاختبارات وقراءة النتائج

بعد تجهيز الصنف، يقتصر التشغيل على سطرين: أنشئ الفاحص بمفتاح الـ API وعنوان بيئة التدريج، ثم استدعِ run_all() واطبع التقرير. كتلة finally تضمن إغلاق المتصفح حتى لو تعثّر أحد الاختبارات.

tester = RegistrationTester("YOUR_API_KEY", "https://staging.example.com")

try:
    tester.run_all()
    print(tester.report())
finally:
    tester.close()

الإخراج:

Registration Tests: 3/3 passed
----------------------------------------
  [PASS] valid_registration
  [PASS] duplicate_email
  [PASS] weak_password

التقرير يلخّص عدد الاختبارات الناجحة ويسرد حالة كل حالة على حدة، مع طباعة أي رسائل خطأ ملتقطة من الصفحة عند الفشل — وهو ما يختصر زمن التشخيص عند تعطّل تدفق التسجيل.


الدمج مع pytest وخطوط CI

لتشغيل هذه الاختبارات ضمن خط تكامل مستمر، غلّف الفاحص في تجهيزة (fixture) على مستوى الوحدة، ثم اكتب كل حالة كدالة اختبار مستقلة تؤكّد النتيجة المتوقعة. بهذا الشكل تظهر النتائج في تقرير pytest القياسي وتتكامل مع أي منصة CI تستخدمها.

import pytest


@pytest.fixture(scope="module")
def tester():
    t = RegistrationTester("YOUR_API_KEY", "https://staging.example.com")
    yield t
    t.close()


def test_valid_registration(tester):
    assert tester.test_valid_registration(), "Valid registration should succeed"


def test_duplicate_email_rejected(tester):
    assert tester.test_duplicate_email(), "Duplicate email should be rejected"


def test_weak_password_rejected(tester):
    assert tester.test_weak_password(), "Weak password should be rejected"

في بيئة CI، شغّل Chrome بوضع بلا واجهة عبر options.add_argument("--headless")، ومرّر مفتاح الـ API كمتغير بيئة سري بدلًا من كتابته داخل الكود.


مثال تطبيقي: تشغيل ليلي لفريق ضمان جودة

تخيّل فريق ضمان جودة في متجر إلكتروني بالرياض يشغّل اختبار انحدار ليلي على بيئة التدريج قبل كل إصدار. تدفق التسجيل محمي بـ Cloudflare Turnstile، ويحتاج الفريق إلى إنشاء بضعة حسابات جديدة في كل تشغيل والتحقق من رفض البريد المكرر وكلمة المرور الضعيفة.

مثل هذا العبء منخفض التزامن: بضعة اختبارات متسلسلة مرة واحدة ليلًا. تسعير CaptchaAI قائم على عدد الخيوط المتزامنة (Threads) لا على عدد عمليات الحل، وكل خطة تشمل عمليات حل غير محدودة خلال الشهر. لذلك تكفي خطة BASIC ‏(15 دولارًا شهريًا، 5 خيوط) بأريحية لتشغيل ليلي كهذا، وتبقى التكلفة ثابتة ومتوقعة بصرف النظر عن عدد مرات إعادة التشغيل. الأسعار بالدولار الأمريكي، وللاطلاع على الخطط الأحدث راجع صفحة التسعير الرسمية.

عند الحاجة إلى تشغيل عشرات الاختبارات بالتوازي — مثلًا مصفوفة متصفحات كاملة في كل دمج — انتقل إلى خطة بعدد خيوط أعلى مثل STANDARD ‏(30 دولارًا شهريًا، 15 خيطًا) دون أي رسوم إضافية على كل عملية حل.


معالجة الأعطال الشائعة

المشكلة السبب المحتمل الحل
التسجيل نجح فعليًا لكن الاختبار يسجّله فشلًا نص مؤشر النجاح لا يطابق صفحتك أضف عبارة النجاح الخاصة بموقعك إلى _check_success()
لم يُكتشف اختبار CAPTCHA يُحمَّل عنصر CAPTCHA بعد تأخير أضف time.sleep(2) قبل استدعاء _solve_captcha()
لم يُعثر على حقول النموذج بنية HTML مختلفة عن الافتراضي حدّث محددات CSS لتطابق موقعك
انتهت صلاحية التوكن قبل قبوله تم الحل قبل الإرسال بوقت طويل قرّب _solve_captcha() من لحظة النقر على الإرسال

الأسئلة الشائعة

ما الخطة المناسبة لتشغيل اختبارات تسجيل متوازية؟

لأن التسعير قائم على عدد الخيوط المتزامنة مع عمليات حل غير محدودة، اختر الخطة بناءً على أعلى تزامن تحتاجه. التشغيل الليلي المتسلسل يكتفي بخطة BASIC ‏(15 دولارًا شهريًا، 5 خيوط)، بينما مصفوفة اختبارات متوازية تستفيد من STANDARD ‏(30 دولارًا شهريًا، 15 خيطًا) أو أعلى.

هل يدعم هذا النمط reCAPTCHA v3 أيضًا؟

نعم، يحلّ CaptchaAI reCAPTCHA v2 وv3 وCloudflare Turnstile وChallenge وGeeTest v3 والصور/OCR ضمن الخطط نفسها. لتغطية reCAPTCHA v3 أضف مسار حل مماثلًا في _solve_captcha() يمرّر قيمة الإجراء (action) ودرجة الحد الأدنى المتوقعة من نموذجك.

كيف أمنع انتهاء صلاحية التوكن قبل الإرسال؟

احرص على استدعاء الحل مباشرة قبل النقر على زر الإرسال، لا في بداية ملء النموذج. توكن reCAPTCHA وTurnstile قصير العمر، لذا كلما قلّت الفجوة بين الحصول عليه وإرساله قلّ احتمال رفضه.

كيف أنظّف الحسابات التجريبية بعد كل تشغيل؟

أضف خطوة تفكيك (teardown) تحذف الحسابات عبر واجهة الإدارة لديك، أو اعتمد على بادئة التسمية test_ لتصفية الحسابات التجريبية وحذفها دفعة واحدة. توليد بريد فريد لكل تشغيل يمنع تراكم التعارضات أساسًا.


أدلة ذات صلة


حوّل تدفق التسجيل المحمي إلى اختبار آلي قابل للتكرار — ابدأ مع CaptchaAI.

التعليقات غير مفعّلة لهذا المقال.