سيلينيوم ويب درايفر
Selenium WebDriver مكتبة مفتوحة المصدر تقود متصفحاً حقيقياً عبر بروتوكول WebDriver المعتمد من W3C. يستدعي اختبارك ربطاً لغوياً (language binding)، وهو هنا الحزمة selenium-webdriver للغة JavaScript؛ ويرسل الربط كل أمر طلبَ HTTP إلى مشغّل المتصفح (browser driver) مثل chromedriver أو geckodriver؛ ويجعل المشغّلُ المتصفحَ ينفّذه. فالسطر await driver.findElement(By.css('[data-testid=login-submit]')).click() يصبح طلبين: الأول يجد الزر، والثاني ينقره كما يفعل المستخدم.
Selenium مكتبة لا إطار اختبار. أنت تختار مشغّل الاختبارات (test runner): Mocha في هذا الدرس، أو JUnit أو TestNG في Java، أو pytest في Python، وتبني الباقي بنفسك: الإعدادات والانتظار ولقطات الشاشة والتقارير. يبني هذا الدرس مشروع JavaScript صغيراً، ويشغّله محلياً وفي CI، ويتعامل مع الجلسات المسجَّل دخولها ومربعات الحوار والتبويبات ورفع الملفات، ويبيّن أين يتوقف البروتوكول.
لماذا يهم
WebDriver معيار من W3C تنفّذه مشغّلات يوفّرها مصنّعو المتصفحات أنفسهم، ولـSelenium روابط رسمية للغات Java وPython وC# وJavaScript وRuby. وكثير من حِزم الاختبار القائمة، خاصةً لدى فرق Java، مبنية على Selenium، ويستخدم Appium لتطبيقات الجوال البروتوكول نفسه. فالمختبِر الذي ينضم إلى فريق كهذا عليه أن يشغّل هذه الاختبارات ويقرأها ويصلحها منذ الأسبوع الأول.
كما أن Selenium يفعل عنك أقل مما تفعله الأدوات الأحدث: باستثناء تحميل الصفحة لا شيء ينتظر التطبيق ما لم يطلب الاختبار ذلك، وهذا سبب شائع لاختبارات Selenium المتذبذبة. وحين تعرف أن كل استدعاء طلب واحد بجواب واحد تصبح لأسماء الأخطاء معانٍ واضحة: no such element وstale element reference وelement click intercepted يشير كل منها إلى سبب مختلف، فتميّز عيباً في المنتج من عيب في الاختبار.
رحلة الأمر: البروتوكول والمشغّلات والمحددات والانتظار وGrid
تبدأ الجلسة حين يرسل الربط POST /session مع الإمكانات (capabilities): اسم المتصفح وخيارات مثل الوضع بلا واجهة (headless). فيشغّل مشغّل المتصفح متصفحاً بملف تعريف جديد وفارغ ويعيد معرّف الجلسة. وكل استدعاء بعد ذلك طلب واحد على تلك الجلسة: driver.get(url) يعود بعد اكتمال تحميل الصفحة، وfindElement يعيد مرجعاً إلى عقدة واحدة في DOM، وclick() يعمل على ذلك المرجع. والجواب قيمة أو رمز خطأ تعرّفه مواصفة W3C. وحين يُعاد رسم الصفحة تختفي العقدة القديمة ويصبح المرجع قديماً (stale)، لذلك تبحث كائنات الصفحات عن العناصر من جديد بدلاً من الاحتفاظ بها.
يوفّر كل مصنّع متصفح المشغّل الخاص بمتصفحه: chromedriver لـChrome، وgeckodriver لـFirefox، وmsedgedriver لـEdge، وsafaridriver الذي يأتي مع macOS. ويجب أن يتوافق المشغّل مع المتصفح: فرقم الإصدار الرئيسي (major version) لـchromedriver وmsedgedriver يجب أن يساوي رقم الإصدار الرئيسي لـChrome أو Edge. ومنذ Selenium 4.6 يتضمن كل إصدار أداة Selenium Manager التي تجد مشغّلاً مطابقاً أو تنزّله إن لم يتوفر، وتستطيع الإصدارات اللاحقة أن تنزّل Chrome أو Firefox أو Edge نفسه أيضاً.
- عملية الاختبار
selenium-webdriver + Mocha - مشغّل المتصفح
chromedriver · geckodriver · Grid - المتصفح ملف تعريف جديد وفارغ
- 1 من عملية الاختبار إلى مشغّل المتصفح: ابدأ جلسة الإمكانات: اسم المتصفح وخيارات مثل الوضع بلا واجهة
POST /session - 2 من مشغّل المتصفح إلى المتصفح: يشغّل المتصفح
- 3 من مشغّل المتصفح إلى عملية الاختبار: معرّف الجلسة ردّ
- 4 من عملية الاختبار إلى مشغّل المتصفح: افتح صفحة تسجيل الدخول يجيب بعد اكتمال تحميل الصفحة
POST /session/{id}/url - 5 من عملية الاختبار إلى مشغّل المتصفح: ابحث عن زر تسجيل الدخول
css selectorو[data-testid=login-submit]POST /session/{id}/element - 6 من مشغّل المتصفح إلى المتصفح: يبحث في DOM الحالي مرة واحدة، لأن الانتظار الضمني 0
- 7 من مشغّل المتصفح إلى عملية الاختبار: مرجع العنصر، أو الخطأ
no such elementردّ - 8 عملية الاختبار داخليًا: الانتظار الصريح: كرّر البحث حتى ينجح أو تنتهي المهلة
driver.wait(until.elementLocated(…), 10000)يعمل هنا لا في المتصفح - 9 من عملية الاختبار إلى مشغّل المتصفح: انقر ذلك العنصر عبر مرجع العنصر
{eid}الذي أعاده البحثPOST /session/{id}/element/{eid}/click - 10 من مشغّل المتصفح إلى المتصفح: يمرّر العنصر إلى مجال الرؤية ويتحقق أن شيئاً لا يغطيه ثم ينقر وإلا فالخطأ
element click interceptedأوelement not interactable - 11 من عملية الاختبار إلى مشغّل المتصفح: أنهِ الجلسة في
afterEachليُنفَّذ حتى بعد الفشلDELETE /session/{id} - 12 من مشغّل المتصفح إلى المتصفح: يغلق المتصفح
النتيجة: كل استدعاء طلب واحد وجواب واحد؛ وباستثناء تحميل الصفحة لا ينتظر شيءٌ التطبيقَ ما لم يطلب الاختبار ذلك.
- المحددات (locators): يعرّف البروتوكول خمس استراتيجيات (محدد CSS، ونص الرابط، وجزء من نص الرابط، واسم الوسم، وXPath)، وتترجم الروابط اللغوية
By.idوBy.nameوBy.classNameإلى CSS. طبّق الترتيب من دليل استراتيجيات محددات العناصر (قسمه عن Selenium وCypress): معرّف اختبار مثلBy.css('[data-testid=login-email]')، ثم id أو name ثابت، وXPath فقط للنص أو البنية التي لا يعبّر عنها CSS. ولا يوجد مقابل مدمج لـgetByRoleالموجود في Playwright. - الانتظار الصريح (explicit wait): يكرّر
driver.wait(condition, ms)الشرط داخل عملية الاختبار حتى يتحقق، أو يرميTimeoutError. استخدم الشروط الجاهزة فيuntil: elementLocatedوelementIsVisibleوelementTextIsوurlContainsوalertIsPresent. وأبقِ الانتظار الضمني (implicit wait) عند 0، لأن خلطه بالانتظار الصريح يجعل المهل غير متوقعة. - Selenium Grid: يستقبل الطلب نفسه
POST /sessionويحوّله إلى عقدة (node) فيها المتصفح المطلوب، فلا يغيّر الاختبار إلا العنوان الذي يتحدث إليه. وصورة Docker الرسميةselenium/standalone-chrome، وهي حزمة جاهزة تضم Grid وChrome وchromedriver يشغّلها Docker حاويةً معزولة (container)، تمثّل شبكة Grid من عقدة واحدة على المنفذ4444.
تثبيت مشروع JavaScript وتنظيمه
في مجلد فارغ نفّذ npm init -y ثم npm install --save-dev selenium-webdriver mocha mocha-junit-reporter. لا حاجة إلى تنزيل أي مشغّل. يوفّر Mocha الدوال describe وit والخطافات (hooks) والمهل وإعادة المحاولة وأدوات التقارير (reporters)؛ وتوفّر node:assert/strict التأكيدات؛ ويكتب mocha-junit-reporter تقرير XML الذي تقرؤه خوادم CI.
توجد الإعدادات في مكانين. ملف .mocharc.json الخاص بمشغّل الاختبارات يحمل المهل وإعادة المحاولة وأداة التقرير. ووحدة إعدادات صغيرة تقرأ عنوان الموقع الأساسي والمتصفح والوضع بلا واجهة وعنوان Grid وحساب الاختبار من متغيرات البيئة، فتعمل الشيفرة نفسها على جهازك وعلى Grid وفي CI. ودالة واحدة تبني كل جلسة متصفح، فيحدث تغيير المتصفح أو خياراته في ملف واحد.
package.json: "test": "mocha"؛ ويشغّل Mocha افتراضياً كل ملف.jsفي المجلدtest/.- الوحدات (modules): تستخدم الأمثلة صيغة CommonJS في Node، أي
require()وmodule.exports، وهي المقابل الأقدم لـimportوexport، لأن المشروع الذي يُنشئهnpm init -yمشروع CommonJS، وأيimportفيه يفشل بالخطأCannot use import statement outside a module. ولاستخدامimportاضبط"type": "module"فيpackage.json؛ وعندها تحتاج الاستيرادات النسبية (relative imports) إلى امتداد الملف، كما في'../pages/LoginPage.js'. .mocharc.json: مهلة 60 ثانية لكل اختبار، لأن مهلة Mocha الافتراضية (ثانيتان) أقصر من أن تكفي متصفحاً؛ وإعادة المحاولة عند 0 لتبقى الاختبارات المتذبذبة ظاهرة؛ وأداة التقريرspecمحلياً.support/: config.jsوdriver.js(أدناه) وsession.js. وpages/: كائن صفحة لكل صفحة. وtest/: ملفات الاختبار. وscreenshots/: لقطات الشاشة عند الفشل، ويتجاهلها Git.
// package.json "scripts": { "test": "mocha" }
// .mocharc.json { "timeout": 60000, "retries": 0, "reporter": "spec" }
// support/config.js: values that differ between machines come from the environment
module.exports = {
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
browser: process.env.BROWSER ?? 'chrome', // or 'firefox'
headless: process.env.HEADLESS === '1',
gridUrl: process.env.GRID_URL, // e.g. http://localhost:4444
waitMs: 10_000,
user: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
};
// support/driver.js: the only place that knows how a browser session is built
const { Builder } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const firefox = require('selenium-webdriver/firefox');
const config = require('./config');
async function buildDriver() {
const chromeOptions = new chrome.Options();
const firefoxOptions = new firefox.Options();
if (config.headless) {
chromeOptions.addArguments('--headless=new');
firefoxOptions.addArguments('-headless');
}
const builder = new Builder()
.forBrowser(config.browser)
.setChromeOptions(chromeOptions)
.setFirefoxOptions(firefoxOptions);
if (config.gridUrl) builder.usingServer(config.gridUrl); // remote browser on a Grid
const driver = await builder.build();
await driver.manage().setTimeouts({ implicit: 0, pageLoad: 30_000, script: 10_000 });
return driver;
}
module.exports = { buildDriver };شغّل اختباراً واحداً وشاهده وصحّح أخطاءه، ثم شغّله في CI
مشغّل الاختبارات، لا Selenium، هو الذي يقرر أي الاختبارات تعمل. ولأن وحدة الإعدادات تقرأ متغيرات البيئة، يمكن للأمر نفسه أن يعمل مرئياً على جهازك، أو بلا واجهة، أو في Firefox، أو على Grid.
في CI يشغّل سير عمل GitHub Actions أدناه (ملف YAML في .github/workflows/) مهمة واحدة على جهاز Linux جديد. ويشغّل المفتاح services: صورة selenium/standalone-chrome الرسمية حاويةَ خدمة (service container) بجوار المهمة، فلا يحتاج جهاز CI إلى متصفح، ويجعل فحص الجاهزية (health check) بالسكربت check-grid.sh الموجود في الصورة نفسها المهمةَ تنتظر حتى تصبح Grid جاهزة. ثم تثبّت الخطوات المشروع بـnpm ci وتوجّه الاختبارات إلى Grid على المنفذ 4444؛ وتقرأ secrets.* وvars.* قيماً محفوظة في إعدادات المستودع، فلا تُكتب أي كلمة مرور في الملف. وهنا تفصيلان مهمان. المتصفح يعمل داخل الحاوية، وهناك يعني localhost الحاوية نفسها، لذا يجب أن يشير BASE_URL إلى بيئة اختبار منشورة أو إلى خدمة أخرى. وثبّت وسم إصدار كاملاً للصورة كما يوصي مشروع docker-selenium، حتى لا يغيّر إصدار جديد للمتصفح نتائجك بين ليلة وضحاها. ويحتاج Chrome إلى ذاكرة مشتركة أكبر من الافتراضي في Docker، ومن هنا --shm-size=2g؛ ويُرفع التقرير ولقطات الشاشة حتى عند فشل الاختبارات.
- الكل:
npm test. ملف واحد:npx mocha test/account.spec.js. اختبار واحد: أضف-g "wrong password"، وهو تعبير نمطي يُطابَق مع العنوان الكامل للاختبار. - الوضع المرئي (headed) هو الافتراضي في Selenium: يُفتح المتصفح على شاشتك. وللتشغيل بلا واجهة اضبط المتغير:
HEADLESS=1 npx mochaفي bash، و$env:HEADLESS='1'; npx mochaفي PowerShell. - تصحيح الأخطاء (debug): ضع العبارة
debugger;قبل الخطوة التي تريد فحصها وشغّلnpx mocha --inspect-brk test/account.spec.js، ثم اتصل منchrome://inspectأو من JavaScript Debug Terminal في VS Code وتقدّم خطوة خطوة والمتصفح مفتوح. ويُلغي--inspect-brkمهلة Mocha أيضاً. - متصفح آخر أو Grid:
BROWSER=firefox npx mocha، أو، إن كان Docker مثبّتاً لديك، شغّلdocker run -d -p 4444:4444 -p 7900:7900 --shm-size=2g selenium/standalone-chromeثم الاختبارات معGRID_URL=http://localhost:4444. ويعرض المنفذ7900متصفح الحاوية في صفحة ويب (noVNC، وكلمة المرور الافتراضيةsecret).
# .github/workflows/ui-tests.yml
name: ui-tests
on: [push, pull_request]
jobs:
selenium:
runs-on: ubuntu-latest
services:
selenium: # a one-node Grid with Chrome inside
image: selenium/standalone-chrome:latest # pin a full version tag in real use
ports: ['4444:4444']
options: >-
--shm-size=2g
--health-cmd /opt/bin/check-grid.sh
--health-interval 5s --health-retries 12
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npx mocha --reporter mocha-junit-reporter # writes test-results.xml
env:
GRID_URL: http://localhost:4444
BASE_URL: ${{ vars.STAGING_URL }} # a deployed site, not localhost
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: ui-test-results
path: |
test-results.xml
screenshots/جلسة الدخول المحفوظة، ومحاكاة الشبكة، ومربعات الحوار، والتبويبات، ورفع الملفات
تبدأ كل جلسة جديدة بملف تعريف فارغ، فيضطر كل اختبار إلى تسجيل الدخول عبر النموذج. سجّل الدخول مرة واحدة بدلاً من ذلك، واقرأ ملفات تعريف الارتباط (cookies) بـdriver.manage().getCookies()، وأضفها إلى كل متصفح جديد بـaddCookie(). ولا يقبل WebDriver ملف تعريف ارتباط إلا للموقع المفتوح، فحمّل أولاً صفحة صغيرة من ذلك الموقع (يقترح توثيق Selenium صفحة 404)، ثم أضف الكوكيز، ثم افتح الصفحة المختبَرة. وأبقِ اختباراً واحداً على الأقل يسجّل الدخول عبر النموذج. وإذا كان التطبيق يحفظ رمزه (token) في localStorage لا في كوكي، فنسخ الكوكيز لا يفيد شيئاً: اسأل المطورين أين تُحفظ الجلسة.
لا يملك WebDriver الكلاسيكي أي أمر لاعتراض طلب شبكة أو محاكاته. والحلول الشائعة تقع خارج المتصفح: وجّه بيئة الاختبار إلى خادم بديل (stub server)، أو هيّئ بيانات الخادم الخلفي ليعيد الحالة التي تحتاجها. أما الطريق المعياري داخل المتصفح فهو WebDriver BiDi، وهو مسودة من W3C تضيف إلى الجلسة اتصال WebSocket ثنائي الاتجاه يُطلب بالإمكانية webSocketUrl: true (أو options.enableBidi() في selenium-webdriver). وتستطيع وحدة الشبكة فيه إيقاف الطلبات المطابقة (network.addIntercept)، ثم الرد عليها بـnetwork.provideResponse أو إفشالها بـnetwork.failRequest. ويعمل Selenium على نقل تنفيذه إلى BiDi، وما زالت واجهته عالية المستوى للشبكة تختلف بين اللغات والإصدارات، فراجع التوثيق الحالي قبل أن تبني عليها.
- مربعات الحوار:
alertوconfirmوpromptليست جزءاً من الصفحة، فلا تراها المحددات. انتظرها بـuntil.alertIsPresent()، واقرأgetText()، ثمaccept()أوdismiss()؛ ويكتبsendKeys()في مربعprompt. وما دام مربع الحوار مفتوحاً تفشل الأوامر الأخرى بالخطأunexpected alert open. أما النافذة المنبثقة المبنية بـHTML فهي محتوى عادي في الصفحة: استخدم لها المحددات. - التبويبات الجديدة: الرابط ذو
target="_blank"يفتح مقبض نافذة (window handle) جديداً، لكنdriverيبقى على القديم. احفظ المقبض الحالي، وانتظر ظهور الثاني، وانتقل إليه، وتحقّق منه، ثم أغلقه وعُد إلى الأول. - رفع الملفات: لا يستطيع WebDriver تشغيل نافذة اختيار الملفات في نظام التشغيل، فأرسل المسار المطلق إلى
input[type=file]بـsendKeys(). وحين يعمل المتصفح على Grid اضبط كاشف الملفات (file detector) لينسخ الربطُ الملفَ إلى العقدة أولاً.
// support/session.js: sign in once, then give every new browser the session cookies
const { until } = require('selenium-webdriver');
const { buildDriver } = require('./driver');
const { LoginPage } = require('../pages/LoginPage');
const { baseUrl, waitMs, user } = require('./config');
async function captureSession() {
const driver = await buildDriver();
try {
const login = await new LoginPage(driver).open();
await login.signIn(user.email, user.password);
await driver.wait(until.urlContains('/account'), waitMs);
return await driver.manage().getCookies();
} finally {
await driver.quit();
}
}
async function restoreSession(driver, cookies) {
await driver.get(`${baseUrl}/404`); // cookies are accepted only for the open site
for (const cookie of cookies) await driver.manage().addCookie(cookie);
}
module.exports = { captureSession, restoreSession };
// Inside a test: a link that opens a new tab
const original = await driver.getWindowHandle();
await driver.findElement(By.css('[data-testid=returns-policy]')).click();
const twoTabs = async () => (await driver.getAllWindowHandles()).length === 2;
await driver.wait(twoTabs, waitMs);
const handles = await driver.getAllWindowHandles();
await driver.switchTo().window(handles.find((h) => h !== original));
assert.equal(await driver.getTitle(), 'Returns policy');
await driver.close(); // closes the tab, not the session
await driver.switchTo().window(original);
// Inside a test: upload by typing the path into the input, never via the file picker
// const remote = require('selenium-webdriver/remote'); const path = require('node:path');
driver.setFileDetector(new remote.FileDetector()); // only for a remote (Grid) browser
await driver.findElement(By.css('input[type=file]'))
.sendKeys(path.resolve('fixtures/receipt.pdf'));مثال تطبيقي: أربعة اختبارات للحساب مع كائنات الصفحات
في منطقة الحساب لمتجر إلكتروني نموذج تسجيل دخول بمعرّفات اختبار، وصفحة حساب تعرض بريد المستخدم وعناوينه المحفوظة، وزر حذف يطلب التأكيد عبر مربع الحوار الأصلي في المتصفح confirm(). وتغطي الاختبارات الأربعة: تسجيل دخول ناجحاً، وكلمة مرور خاطئة، وجلسة مستعادة، وحذفاً أُلغي.
كائنات الصفحات (Page Objects) تملك المحددات والانتظار، والاختبارات تملك التأكيدات، كما في درس كائن الصفحة. يلتقط before الجلسة مرة واحدة لكل ملف، ويعطي beforeEach كل اختبار متصفحاً جديداً، ويحفظ afterEach لقطة شاشة عند فشل الاختبار ويغلق المتصفح دائماً. والاختبار الأول لا يحتاج إلى assert: إن لم تُحمَّل /account أبداً يرمي الانتظار خطأً ويفشل الاختبار. ويتبع الربطان اللغويان لـJava وPython البروتوكول نفسه، ولا يتغير إلا الشكل:
- تشغيل متصفح: في JavaScript
await new Builder().forBrowser('chrome').build()؛ وفي Java new ChromeDriver()؛ وفي Python webdriver.Chrome(). - إيجاد عنصر:
driver.findElement(By.css(s))؛ وفي Java driver.findElement(By.cssSelector(s))؛ وفي Python driver.find_element(By.CSS_SELECTOR, s). - الانتظار:
driver.wait(until.elementLocated(loc), 10000)؛ وفي Java new WebDriverWait(driver, Duration.ofSeconds(10)).until(ExpectedConditions.presenceOfElementLocated(loc))؛ وفي Python WebDriverWait(driver, 10).until(EC.presence_of_element_located(loc)). - متصفح بعيد على Grid:
.usingServer(url)؛ وفي Java new RemoteWebDriver(new URL(url), new ChromeOptions())؛ وفي Python webdriver.Remote(command_executor=url, options=ChromeOptions()). - الكوكيز ومربعات الحوار والنوافذ: Java مطابقة لـJavaScript (
driver.manage().addCookie(c)وdriver.switchTo().alert()وdriver.switchTo().window(h))، وتستخدم Python driver.add_cookie(c)وdriver.switch_to.alertوdriver.switch_to.window(h). ومشغّلات الاختبارات: JUnit 5 أو TestNG في Java، وpytest في Python.
// pages/LoginPage.js: locators and actions of one page; assertions stay in the tests
const { By, until } = require('selenium-webdriver');
const { baseUrl, waitMs } = require('../support/config');
const EMAIL = By.css('[data-testid=login-email]');
const PASSWORD = By.css('[data-testid=login-password]');
const SUBMIT = By.css('[data-testid=login-submit]');
const ERROR = By.css('[data-testid=login-error]');
class LoginPage {
constructor(driver) { this.driver = driver; }
async open() {
await this.driver.get(`${baseUrl}/login`);
await this.driver.wait(until.elementLocated(EMAIL), waitMs);
return this;
}
async signIn(email, password) {
await this.driver.findElement(EMAIL).sendKeys(email);
await this.driver.findElement(PASSWORD).sendKeys(password);
await this.driver.findElement(SUBMIT).click();
}
async errorText() {
const el = await this.driver.wait(until.elementLocated(ERROR), waitMs);
await this.driver.wait(until.elementIsVisible(el), waitMs);
return el.getText();
}
}
module.exports = { LoginPage };
// pages/AccountPage.js: same pattern (imports as in LoginPage.js)
const ACCOUNT_EMAIL = By.css('[data-testid=account-email]');
const ADDRESS = By.css('[data-testid=address]');
const REMOVE_ADDRESS = By.css('[data-testid=address-remove]');
class AccountPage {
constructor(driver) { this.driver = driver; }
async open() {
await this.driver.get(`${baseUrl}/account`);
await this.driver.wait(until.elementLocated(ACCOUNT_EMAIL), waitMs);
return this;
}
async email() { return this.driver.findElement(ACCOUNT_EMAIL).getText(); }
async addressCount() { return (await this.driver.findElements(ADDRESS)).length; }
// only clicks: the confirm() dialog that opens belongs to the test
async removeFirstAddress() { await this.driver.findElement(REMOVE_ADDRESS).click(); }
}
module.exports = { AccountPage };
// test/account.spec.js
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { until } = require('selenium-webdriver');
const { buildDriver } = require('../support/driver');
const { captureSession, restoreSession } = require('../support/session');
const { LoginPage } = require('../pages/LoginPage');
const { AccountPage } = require('../pages/AccountPage');
const { waitMs, user } = require('../support/config');
describe('Account', function () {
let driver;
let cookies;
before(async function () { cookies = await captureSession(); });
beforeEach(async function () { driver = await buildDriver(); });
afterEach(async function () {
if (this.currentTest.state === 'failed') {
fs.mkdirSync('screenshots', { recursive: true });
const png = await driver.takeScreenshot();
fs.writeFileSync(`screenshots/${this.currentTest.title}.png`, png, 'base64');
}
await driver.quit();
});
it('signs in with valid credentials', async function () {
const login = await new LoginPage(driver).open();
await login.signIn(user.email, user.password);
await driver.wait(until.urlContains('/account'), waitMs); // the wait is the check
});
it('rejects a wrong password and stays on the login page', async function () {
const login = await new LoginPage(driver).open();
await login.signIn(user.email, 'not-the-password');
assert.equal(await login.errorText(), 'Wrong e-mail or password');
assert.ok((await driver.getCurrentUrl()).includes('/login'));
});
it('opens the account of a restored session', async function () {
await restoreSession(driver, cookies);
const account = await new AccountPage(driver).open();
assert.equal(await account.email(), user.email);
});
it('keeps the address when the removal is cancelled', async function () {
await restoreSession(driver, cookies);
const account = await new AccountPage(driver).open();
const count = await account.addressCount();
await account.removeFirstAddress();
const dialog = await driver.wait(until.alertIsPresent(), waitMs);
assert.equal(await dialog.getText(), 'Remove this address?');
await dialog.dismiss();
assert.equal(await account.addressCount(), count);
});
});أخطاء شائعة
- استخدام
driver.sleep(3000)لانتظار الصفحة، أو ضبط انتظار ضمني ثم إضافة انتظار صريح فوقه. انتظر شرطاً بـdriver.wait()وأبقِ الانتظار الضمني عند 0. - استدعاء
driver.quit()في نهاية الاختبار الناجح فقط. عندها يترك الاختبار الفاشل متصفحاً وعملية مشغّل تعمل، ويُبقي على Grid مكاناً مشغولاً؛ أغلق المتصفح فيafterEachأوfinally. - الاحتفاظ بعنصر وُجد قبل تنقّل أو إعادة رسم واستخدامه بعدها، فيفشل بالخطأ
stale element reference. ابحث عن العناصر داخل دالة كائن الصفحة التي تستخدمها. - إبقاء chromedriver قديم في
PATHأو في المستودع. لا يتدخل Selenium Manager إلا حين لا يجد مشغّلاً، فيُستخدم القديم ويفشل بعد التحديث التالي للمتصفح. - توجيه متصفح يعمل في حاوية أو على عقدة Grid إلى
localhost، الذي يعني هناك الحاوية نفسها، لا جهازك ولا جهاز CI. - تفعيل إعادة المحاولة ليصبح البناء أخضر دون تسجيل الاختبارات التي احتاجت إليها. فإعادة المحاولة تحدّ من أثر الاختبار المتذبذب ولا تصلحه.
من منظور ضمان الجودة
حين تنضم إلى فريق لديه حزمة Selenium، شغّل أولاً ملف اختبار واحداً على بيئة الاختبار من سطر الأوامر، ثم اقرأ دالة بناء المتصفح ووحدة الإعدادات: من أين يأتي عنوان الموقع والمتصفح وبيانات الدخول وعنوان Grid. وحين يفشل اختبار، اقرأ الخطأ قبل تتبّع المكدس (stack trace). no such element يعني أن المحدد خاطئ أو أن الصفحة ليست في الحالة المتوقعة؛ وelement click intercepted يعني غالباً أن شريطاً أو طبقة أو مؤشر تحميل يغطي الهدف؛ وTimeoutError من انتظار يعني أن الشرط لم يتحقق قط، وقد يكون ذلك عيباً أو توقعاً خاطئاً. سجّل لقطة الشاشة والأمر الدقيق وإصدار المتصفح، الذي يعيده (await driver.getCapabilities()).get('browserVersion')، في تقرير الخطأ أو في تذكرة الاختبار المتذبذب.
اتفق مع المطورين على سمات data-testid وعلى طريقة مدعومة لإنشاء الجلسات والبيانات في بيئات الاختبار، مثل تسجيل دخول عبر API أو نقطة نهاية لتهيئة البيانات. واتفق مع المسؤول عن CI على صورة Grid المثبّتة والمتصفحات التي تشغّلونها. وفي المسارات التي تمر بصفحات طرف ثالث، مثل مزوّد دفع أو خدمة دخول موحّد، استخدم بيئة الاختبار (sandbox) لدى المزوّد أو توقّف عند حدود نظامك، بدلاً من أتمتة موقع إنتاج يملكه غيرك.
تمرين عملي
اجعل اختباراً لا يعمل إلا على جهاز كاتبه يعمل في CI
الاختبار أدناه كان ينجح على جهاز كاتبه، حيث لا يُضبط GRID_URL ويعمل Chrome محلياً. ومنذ ذلك الحين تغيّر أمران. صارت الحزمة تعمل في مهمة CI من هذا الدرس، حيث يعمل المتصفح في حاوية selenium/standalone-chrome. وتغيّر التطبيق: صار رابط «Help» يفتح في تبويب جديد، وصار نموذج الصورة الرمزية يُعاد رسمه بعد اختيار الملف فتُستبدل أزراره. والاختبار يفشل الآن.
جِد كل مشكلة، واذكر نوع الفشل الذي قد تسببه كل منها، وصِف الاختبارات بعد التصحيح.
// test/profile.spec.js: passed on the author's laptop, now fails in CI
const assert = require('node:assert/strict');
const { By } = require('selenium-webdriver');
const { buildDriver } = require('../support/driver');
describe('Profile', function () {
it('uploads an avatar and opens help', async function () {
const driver = await buildDriver();
await driver.get('http://localhost:3000/login');
await driver.findElement(By.id('email')).sendKeys('sara@example.com');
await driver.findElement(By.id('password')).sendKeys('Sara-Pass-2026');
await driver.findElement(By.id('submit')).click();
await driver.sleep(3000); // login redirects to /profile
const save = await driver.findElement(
By.xpath('/html/body/div[2]/main/div[3]/form/button[2]'));
await driver.findElement(By.css('input[type=file]')).sendKeys('avatar.png');
await save.click();
await driver.findElement(By.linkText('Help')).click(); // opens in a new tab
assert.equal(await driver.getTitle(), 'Help centre');
await driver.quit();
});
});- حدّد كل قيمة مكتوبة مباشرةً في الشيفرة وقرّر من أين يجب أن تأتي.
- جِد كل موضع يتصرف فيه الاختبار قبل أن تجهز الصفحة، أو يحتفظ فيه بعنصر أطول مما ينبغي.
- افحص سلوك رفع الملف ورابط «Help» حين يكون المتصفح بعيداً ويُفتح تبويب جديد.
- قسّم الاختبار، وانقل المحددات إلى كائن صفحة، واكتب النسخة المصحّحة.
إظهار الإجابة النموذجية
الإعدادات: http://localhost:3000 هو الحاوية نفسها حين يعمل المتصفح على Grid، فلا تُحمَّل الصفحة أبداً في CI. استخدم baseUrl من وحدة الإعدادات. ومكان البريد وكلمة المرور متغيرات البيئة أو أسرار CI، لا الشيفرة.
التوقيت والمحددات: driver.sleep(3000) تخمين، قصير جداً على جهاز CI بطيء ووقت ضائع على جهاز سريع؛ انتظر until.urlContains('/profile') أو دع دالة open() في كائن الصفحة تنتظر. ومسار XPath المطلق ينكسر مع أي تغيير في التخطيط؛ اطلب معرّف اختبار مثل [data-testid=avatar-save]. والمتغير save يُوجَد قبل رفع الملف، وإعادة الرسم تستبدل الزر، فيفشل save.click() بالخطأ stale element reference، أو ينجح فقط حين يسبق النقرُ إعادةَ الرسم مصادفةً؛ ابحث عن الزر قبيل النقر عليه مباشرةً، داخل كائن الصفحة.
رفع الملف: يحتاج sendKeys('avatar.png') إلى مسار مطلق مثل path.resolve('fixtures/avatar.png')، ويحتاج المتصفح البعيد أيضاً إلى driver.setFileDetector(new remote.FileDetector())، وإلا بحثت العقدة عن ملف لا يوجد إلا على جهاز CI. ثم تحقّق من النتيجة، مثل رسالة نجاح، بدلاً من افتراض أن غياب الخطأ يعني النجاح.
التبويب الجديد: يبقى driver على تبويب الملف الشخصي، فيقرأ getTitle() عنوان تلك الصفحة ويفشل التأكيد. احفظ المقبض الأصلي، وانتظر وجود مقبضين، وانتقل إلى الجديد، وتحقّق من العنوان «Help centre»، ثم أغلقه وعُد إلى الأول.
البنية: اختبار واحد يفحص سلوكين لا علاقة بينهما ويسجّل الدخول عبر النموذج، وهذا ما تغطيه اختبارات تسجيل الدخول أصلاً. قسّمه إلى «uploads an avatar» و«Help opens in a new tab»، واستعِد الجلسة من الكوكيز في كليهما، وانقل driver.quit() إلى afterEach الذي يعمل حتى بعد الفشل، مع لقطة شاشة عند الفشل.
أسئلة شائعة
هل ما زلت بحاجة إلى تنزيل chromedriver بنفسي؟
غالباً لا. منذ Selenium 4.6 تجد أداة Selenium Manager مشغّلاً مطابقاً أو تنزّله حين لا يوجد مشغّل في PATH. ثبّت المشغّلات بنفسك فقط حيث لا يستطيع الجهاز التنزيل، وعندها ثبّت إصداري المتصفح والمشغّل معاً، مثلاً عبر صورة Docker مثبّتة الإصدار.
هل يختار مشروع جديد Selenium أم Playwright؟
قرّر حسب لغة فريقك، والمتصفحات والأجهزة التي يجب تغطيتها، والبنية التحتية التي تشغّلها أصلاً. يتبع Selenium معياراً من W3C ينفّذه مصنّعو المتصفحات وله روابط بلغات أكثر؛ ويأتي Playwright بمشغّل اختبارات خاص به مع انتظار تلقائي واعتراض للشبكة وعارض للآثار (trace viewer). ويمكن لكليهما حمل حزمة قابلة للصيانة؛ وتصميم الحزمة وانضباط بيانات الاختبار أهم من الأداة.
هل Selenium IDE هو نفسه Selenium WebDriver؟
لا. Selenium IDE أداة تسجّل النقرات وتعيد تشغيلها، أما WebDriver فهو المكتبة والبروتوكول اللذان تكتب بهما الاختبارات شيفرةً. والتسجيل مفيد لاستكشاف الصفحة، لكنه يحتاج إلى كائنات صفحات وانتظار صريح ومحددات أفضل قبل أن يدخل الحزمة.
المراجع
- WebDriver (W3C)بروتوكول W3C الذي تنفّذه المتصفحات لتتحكم فيها أدوات الأتمتة مثل Selenium عن بُعد (الإصدار Level 2 مسودة عمل).
- Selenium Documentationالتوثيق الرسمي لأتمتة المتصفح عبر WebDriver، بما فيه الانتظار والمحدِّدات وGrid.
- Waiting Strategies — Seleniumلماذا تتسابق اختبارات المتصفح مع الصفحة، وكيف تزامن الانتظارات الضمنية والصريحة سكربت WebDriver مع التطبيق.
- GitHub Actions documentationكيف تُعرَّف مسارات العمل والمهام والمشغّلات والمخرجات في GitHub Actions — حيث تعمل الاختبارات الآلية عند كل دفع أو طلب دمج.
تدرّب بأدوات متقن
تجميل HTML بمسافات بادئة صحيحة للعناصر المتداخلة، مع الحفاظ على كتل pre وscript وstyle.
تحليل ترويسات Set-Cookie والتحقق من أفضل الممارسات في Secure وHttpOnly وSameSite وDomain وPath وانتهاء الصلاحية والحجم.
تنسيق YAML والتحقق من صحته بمسافات بادئة وعلامات اقتباس متسقة.
تجميل كود JavaScript وTypeScript بمسافات بادئة وتباعد متسقين.
للتعمّق أكثر
تقدّمك في هذا الموضوع
سجّل الدخول لتتابع تقدّمك
علّم الموضوعات مكتملة، وتابع من حيث توقفت، وافتح امتحانات التخصص.