تطوير الاختبارات البصرية على أساس الجوزاء و Storybook

مرحبا يا هبر! في هذه المقالة ، أود أن أشارككم تجربة تطوير الاختبارات البصرية في فريقنا.

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

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

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

بالطبع ، "فحص" المكونات البصرية الفردية ليست فعالة للغاية. لا تعيش المكونات في فراغ وقد يعتمد عرضها على مكونات المستوى الأعلى أو على المكونات المجاورة. بغض النظر عن كيفية اختبار المكونات الفردية ، قد يكون للصورة ككل عيوب. من ناحية أخرى ، إذا قمت بالتقاط صور لإطار التطبيق بالكامل ، فستحتوي العديد من الصور على نفس المكونات ، مما يعني أنه إذا قمت بتغيير أحد المكونات ، فسنضطر إلى تحديث جميع الصور التي يوجد بها هذا المكون.

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

اختيار الأداة


لتسريع تنفيذ الاختبارات ، يمكن إجراء عرض الصفحات في بعض المستعرضات مقطوعة الرأس التي تعمل جميعها في الذاكرة دون عرضها على الشاشة وتضمن أقصى درجات الأداء. ولكن في حالتنا ، كان من الضروري التأكد من أن التطبيق يعمل في Internet Explorer (IE) ، الذي لا يحتوي على وضع مقطوع الرأس ، ونحن بحاجة إلى أداة لإدارة المتصفحات برمجياً. لحسن الحظ ، تم بالفعل اختراع كل شيء أمامنا وهناك أداة كهذه - يطلق عليها السيلينيوم . كجزء من مشروع Selenium ، يتم تطوير برامج التشغيل لإدارة المتصفحات المختلفة ، بما في ذلك برنامج تشغيل لـ IE. يمكن لخادم السيلينيوم إدارة المتصفحات ليس فقط محليًا ، ولكن أيضًا عن بُعد ، مكونًا مجموعة من خوادم السيلينيوم ، ما يسمى بشبكة السيلينيوم.

السيلينيوم هو أداة قوية ، لكن الحد الأدنى لدخوله مرتفع للغاية. قررنا البحث عن أدوات جاهزة للاختبار البصري استنادًا إلى السيلينيوم وشاهدنا منتجًا رائعًا من Yandex يسمى Gemini . يمكن لـ Gemini التقاط صور ، بما في ذلك صور لمنطقة معينة من الصفحة ، ومقارنة الصور مع الصور المرجعية ، وتصور الفرق ومراعاة لحظات مثل مكافحة التعرجات أو المؤشر الوامض. بالإضافة إلى ذلك ، يمكن أن يقوم Gemini بإعادة إجراء الاختبارات المتساقطة ، وموازنة الاختبارات ، والعديد من الأشياء الجيدة الأخرى. بشكل عام ، قررنا أن نحاول.

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

//.gemini.js module.exports = { rootUrl: 'http://my-app.ru', gridUrl: 'http://127.0.0.1:4444/wd/hub', browsers: { chrome: { windowSize: '1920x1080', screenshotsDir:'gemini/screens/1920x1080' desiredCapabilities: { browserName: 'chrome' } } }, system: { projectRoot: '', plugins: { 'html-reporter/gemini': { enabled: true, path: './report' }, 'gemini-optipng': true }, exclude: [ '**/report/*' ], diffColor: '#EC041E' } }; 

الاختبارات نفسها عبارة عن مجموعة من الأجنحة ، يتم فيها التقاط صورة واحدة أو أكثر (حالات). قبل التقاط لقطة (طريقة (التقاط))) ، يمكنك تعيين مساحة الصفحة المراد التقاطها باستخدام طريقة setCaptureElements () ، وكذلك تنفيذ بعض الإجراءات التحضيرية إذا لزم الأمر في سياق المستعرض باستخدام إما أساليب كائن الإجراءات أو استخدام شفرة JavaScript التعسفية - من أجل هذا في الإجراءات يحتوي على طريقة executeJS ().

مثال:

 gemini.suite('login-dialog', suite => { suite.setUrl('/') .setCaptureElements('.login__form') .capture('default'); .capture('focused', actions => actions.focus('.login__editor')); }); 

اختبار البيانات


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

بالطبع ، يمكنك ترك التطبيق نفسه لتلقي البيانات بحيث أثناء الاختبار سوف ينفذ الطلبات إلى الواجهة الخلفية ، والتي بدورها ستتلقى البيانات من نوع من قاعدة البيانات المرجعية ، ولكن ماذا عن الإصدار؟ لا يمكنك وضع قاعدة بيانات في مستودع git. لا ، بالطبع يمكنك ذلك ، ولكن هناك بعض الآداب.

بدلاً من ذلك ، لإجراء الاختبارات ، يمكنك استبدال خادم الواجهة الخلفية الحقيقي بخادم مزيف ، مما يمنح تطبيق الويب لا بيانات من قاعدة البيانات ، ولكن البيانات الثابتة المخزنة ، على سبيل المثال ، بتنسيق json ، بالفعل بالمصادر. ومع ذلك ، فإن إعداد مثل هذه البيانات ليست تافهة للغاية. قررنا أن نسير بطريقة أسهل - ليس لسحب البيانات من الخادم ، ولكن ببساطة لاستعادة حالة التطبيق (في حالتنا ، حالة التخزين المتكرر ) ، والتي كانت في التطبيق وقت التقاط الصورة المرجعية ، قبل تنفيذ الاختبار.

لتسلسل الحالة الحالية لمتجر الإرجاع ، تمت إضافة طريقة اللقطة () إلى كائن النافذة:

 export const snapshotStore = (store: Object, fileName: string): string => { let state = store.getState(); const file = new Blob( [ JSON.stringify(state, null, 2) ], { type: 'application/json' } ); let a = document.createElement('a'); a.href = URL.createObjectURL(file); a.download = `${fileName}.testdata.json`; a.click(); return `State downloaded to ${a.download}`; }; const store = createStore(reducer); if (process.env.NODE_ENV !== 'production') { window.snapshot = fileName => snapshotStore(store, fileName); }; 

باستخدام هذه الطريقة ، باستخدام سطر الأوامر بوحدة التحكم في المتصفح ، يمكنك حفظ الحالة الحالية لوحدة تخزين الإعادة في ملف:

صورة

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

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

 import preloadedState from './incoming-letter.testdata'; const store = createStore(rootReducer, preloadedState); storiesOf('regression/Cards', module) .add('IncomingLetter', () => { return ( <Provider store={store}> <MemoryRouter> <ContextContainer {...dummyProps}/> </MemoryRouter> </Provider> ); }); 

في المثال أعلاه ، يعد ContextContainer مكونًا يتضمن "الهيكل العظمي" للتطبيق - شجرة التنقل والرأس ومنطقة المحتوى. في منطقة المحتوى ، يمكن تقديم العديد من المكونات (القائمة ، البطاقة ، مربع الحوار ، إلخ) وفقًا للحالة الحالية لوحدة تخزين إعادة التخزين. لكي لا يلبي المكون الطلبات غير الضرورية إلى الواجهة الخلفية للإدخال ، يتم تمرير خصائص كعب الروتين المقابلة إليه.

في سياق Storybook ، يبدو مثل هذا:

صورة

الجوزاء + القصص القصيرة


لذلك ، اكتشفنا البيانات للاختبارات. المهمة التالية هي تكوين صداقات مع Gemini و Storybook. للوهلة الأولى ، كل شيء بسيط - في تكوين الجوزاء نحدد عنوان التطبيق قيد الاختبار. في حالتنا ، هذا هو عنوان خادم Storybook. ما عليك سوى رفع خادم القصص القصيرة قبل بدء اختبارات الجوزاء. يمكنك القيام بذلك مباشرةً من الشفرة باستخدام اشتراك حدث Gemini START_RUNNER و END_RUNNER:

 const port = 6006; const cofiguration = { rootUrl:`localhost:${port}`, gridUrl: seleniumGridHubUrl, browsers: { 'chrome': { screenshotsDir:'gemini/screens', desiredCapabilities: chromeCapabilities } } }; const Gemini = require('gemini'); const HttpServer = require('http-server'); const runner = new Gemini(cofiguration); const server = HttpServer.createServer({ root: './storybook-static'}); runner.on(runner.events.START_RUNNER, () => { console.log(`storybook server is listening on ${port}...`); server.listen(port); }); runner.on(runner.events.END_RUNNER, () => { server.close(); console.log('storybook server is closed'); }); runner .readTests(path) .done(tests => runner.test(tests)); 

كخادم للاختبارات ، استخدمنا http-server ، والذي يقوم بإرجاع محتويات المجلد مع كتاب القصة الذي تم تجميعه بشكل ثابت (لبناء كتاب القصة ، استخدم أمر build-storybook ).

حتى الآن ، كان كل شيء يسير بسلاسة ، لكن المشاكل لم تبق في انتظارها. والحقيقة هي أن القصص القصيرة تعرض القصة داخل الإطار. في البداية ، أردنا أن نكون قادرين على ضبط المنطقة الانتقائية للصورة باستخدام setCaptureElements () ، ولكن لا يمكن القيام بذلك إلا إذا حددت عنوان الإطار كعنوان للجناح ، شيء مثل هذا:

 gemini.suite('VisualRegression', suite => suite.setUrl('http://localhost:6006/iframe.html?selectedKind=regression%2Fcards&selectedStory=IncomingLetter') .setCaptureElements('.some-component') .capture('IncomingLetter') ); 

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

 gemini.suite('VisualRegression', suite => suite.setUrl('/') .setCaptureElements('#storybook-preview-iframe') .capture('IncomingLetter', actions => openStory(actions, 'IncomingLetter')) .capture('ProjectDocument', actions => openStory(actions, 'ProjectDocumentAccess')) .capture('RelatedDocuments', actions => { openStory(actions, 'RelatedDocuments'); hover(actions, '.related-documents-tree-item__title', 4); }) ); 

لسوء الحظ ، في هذا الخيار ، بالإضافة إلى القدرة على تحديد مساحة الصورة ، فقدنا أيضًا القدرة على استخدام الإجراءات القياسية لمحرك Gemini للعمل مع عناصر من شجرة DOM (mouseDown () ، mouseMove () ، focus () ، وما إلى ذلك) ، إلخ. أ. العناصر الموجودة في إطار الجوزاء لا "ترى". ولكن لا تزال لدينا الفرصة لاستخدام الدالة executeJS () ، والتي يمكنك من خلالها تنفيذ شفرة JavaScript في سياق المستعرض. استنادًا إلى هذه الوظيفة ، قمنا بتطبيق نظائر الإجراءات القياسية التي نحتاجها ، والتي تعمل بالفعل في سياق إطار Storybook. كان علينا هنا "استحضار" قليلاً من أجل نقل قيم المعلمات من سياق الاختبار إلى سياق المستعرض - للأسف ، لا توفر executeJS () مثل هذه الفرصة. لذلك ، للوهلة الأولى ، يبدو الرمز غريبًا بعض الشيء - تتم ترجمة الوظيفة إلى سلسلة ، ويتم استبدال جزء من التعليمات البرمجية بقيم المعلمة ، وفي ExecuteJs () تتم استعادة الوظيفة من السلسلة باستخدام eval ():

 function openStory(actions, storyName) { const storyNameLowered = storyName.toLowerCase(); const clickTo = function(window) { Array.from(window.document.querySelectorAll('a')).filter( function(el) { return el.textContent.toLowerCase() === 'storyNameLowered'; })[0].click(); }; actions.executeJS(eval(`(${clickTo.toString().replace('storyNameLowered', storyNameLowered)})`)); } function dispatchEvents(actions, targets, index, events) { const dispatch = function(window) { const document = window.document.querySelector('#storybook-preview-iframe').contentWindow.document; const target = document.querySelectorAll('targets')[index || 0]; events.forEach(function(event) { const clickEvent = document.createEvent('MouseEvents'); clickEvent.initEvent(event, true, true); target.dispatchEvent(clickEvent); }); }; actions.executeJS(eval(`(${dispatch.toString() .replace('targets', targets) .replace('index', index) .replace('events', `["${events.join('","')}"]`)})` )); } function hover(actions, selectors, index) { dispatchEvents(actions, selectors, index, [ 'mouseenter', 'mouseover' ]); } module.exports = { openStory: openStory, hover: hover }; 

تكرار التنفيذ


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

 const SuiteCollection = require('gemini/lib/suite-collection'); const Suite = require('gemini/lib/suite'); let retrySuiteCollection; let retryCount = 2; runner.on(runner.events.BEGIN, () => { retrySuiteCollection = new SuiteCollection(); }); runner.on(runner.events.TEST_RESULT, args => { const testId = `${args.state.name}/${args.suite.name}/${args.browserId}`; if (!args.equal) { if (retryCount > 0) console.log(chalk.yellow(`failed ${testId}`)); else console.log(chalk.red(`failed ${testId}`)); let suite = retrySuiteCollection.topLevelSuites().find(s => s.name === args.suite.name); if (!suite) { suite = new Suite(args.suite.name); suite.url = args.suite.url; suite.file = args.suite.file; suite.path = args.suite.path; suite.captureSelectors = [ ...args.suite.captureSelectors ]; suite.browsers = [ ...args.suite.browsers ]; suite.skipped = [ ...args.suite.skipped ]; suite.beforeActions = [ ...args.suite.beforeActions ]; retrySuiteCollection.add(suite); } if (!suite.states.find(s => s.name === args.state.name)) { suite.addState(args.state.clone()); } } else console.log(chalk.green(`passed ${testId}`)); }); 

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

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

 function onComplete(result) { if ((retryCount--) > 0 && result.failed > 0 && retrySuiteCollection.topLevelSuites().length > 0) { runner.test(retrySuiteCollection, {}).done(onComplete); } } runner.readTests(path).done(tests => runner.test(tests).done(onComplete)); 

ملخص


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

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

Source: https://habr.com/ru/post/ar454464/


All Articles