基于双子座和故事书的视觉测试开发

哈Ha! 在本文中,我想分享我们团队中开发视觉测试的经验。

碰巧的是,我们没有立即考虑布局测试。 好吧,某些帧会移出几个像素,好了,将其修复。 最后,有测试人员-苍蝇不会飞过他们。 但是人为因素仍然不能被愚弄-即使对于测试人员来说,检测用户界面中的细微变化也并非总是可能的。 当开始认真优化布局并过渡到BEM时出现了问题。 在这里,这肯定不是没有损失的,并且我们迫切需要一种自动化的方法来检测情况,这些情况是由于进行编辑而导致UI中的某些内容开始更改得不符合预期或不符合预期的情况。

任何开发人员都知道单元代码测试。 单元测试使您确信代码中的更改不会破坏任何内容。 好吧,至少他们没有破坏有测试的部分。 相同的原理可以应用于用户界面。 就像单元测试测试类一样,视觉测试也测试构成应用程序用户界面的视觉组件。

对于可视化组件,您可以编写“经典”单元测试,例如,使用不同的输入参数值启动组件的呈现,并使用assert语句检查DOM树的预期状态,将组件的单个元素或DOM树的快照与引用进行比较一般而言。 视觉测试也基于快照,但是已经基于组件的视觉显示快照(屏幕快照)。 视觉测试的本质是将测试期间拍摄的图片与参考图片进行比较,如果发现差异,则可以接受新图片作为参考图片,或者修复导致这些差异的错误。

当然,“筛选”单个视觉组件不是很有效。 这些组件不能生活在真空中,它们的显示可能取决于顶层组件或相邻组件。 无论我们如何测试单个组件,整个图片都可能存在缺陷。 另一方面,如果您在整个应用程序窗口中拍照,那么许多图片将包含相同的组件,这意味着,如果更改一个组件,我们将被迫更新存在该组件的所有图片。

事实是,像往常一样,位于中间位置-您可以绘制应用程序的整个页面,但只能拍摄创建测试的一个区域的图片,在特定情况下,该区域可能与特定组件的区域重合,但是这将不是该组件的一部分。真空,但在非常真实的环境中 这已经类似于单元视觉测试,尽管如果“单元”对环境有所了解,就很难说模块化。 好吧,好吧,测试类别是否包括可视化测试(模块化或集成)并不重要。 俗话说,“你去还是走?”

工具选择


为了加快测试的执行速度,可以在某些无头浏览器中完成页面渲染,该浏览器可以完成内存中的所有工作,而不会在屏幕上显示并提供最佳性能。 但是对于我们而言,确保应用程序在没有无头模式的Internet Explorer(IE)中运行非常重要,并且我们需要一种用于以编程方式管理浏览器的工具。 幸运的是,我们面前已经发明了所有东西,并且有这样一种工具-叫做 。 作为Selenium项目的一部分,正在开发用于管理各种浏览器的驱动程序,包括IE驱动程序。 Selenium服务器不仅可以本地管理浏览器,而且可以远程管理浏览器,从而形成了Selenium服务器集群,即所谓的Selenium网格。

硒是一种强大的工具,但是进入它的门槛很高。 我们决定寻找基于Selenium的现成的用于视觉测试的工具,并发现了Yandex一款名为Gemini的出色产品。 双子座可以拍照,包括页面某个区域的图片,将图片与参考图片进行比较,可视化差异并考虑诸如抗锯齿或光标闪烁之类的时刻。 此外,Gemini可以重新执行失败的测试,并行执行测试以及许多其他好处。 总的来说,我们决定尝试。

双子座测试很容易编写。 首先,您需要准备基础结构-安装selenium-standalone并启动selenium服务器。 然后配置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' } }; 

测试本身是一组套件的集合,其中每个套件均拍摄一张或多张图片(状态)。 在拍摄快照(capture()方法)之前,您可以使用setCaptureElements()方法设置要拍摄的页面区域,并在必要时在浏览器上下文中使用action对象的方法或使用任意JavaScript代码执行一些准备操作-对于这个在动作中有一个executeJS()方法。

一个例子:

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

测试数据


选择了一种测试工具,但距离最终解决方案还有很长的路要走。 有必要了解如何处理图片中显示的数据。 让我提醒您,在测试中,我们决定不绘制单个组件,而是绘制应用程序的整个页面,以便不是在真空中而是在其他组件的真实环境中测试可视组件。 如果您需要将必要的测试数据传输到ee props (我说的是React组件)以呈现单个组件,则需要更多的时间来呈现应用程序的整个页面,并且为此类测试准备环境可能令人头疼。

当然,您可以保留应用程序本身来接收数据,以便在测试过程中它将执行对后端的请求,而后端又将从某种类型的参考数据库接收数据,但是版本控制呢? 您无法将数据库放入git存储库中。 不,当然可以,但是有一些礼节。

另外,要运行测试,您可以用伪造的服务器替换真正的后端服务器,这将使Web应用程序不会从数据库中获得数据,而是向源应用程序提供静态数据,例如以json格式存储的静态数据。 但是,这种数据的准备也不是一件容易的事。 我们决定采用一种更简单的方法-不是从服务器中提取数据,而是简单地还原应用程序的状态(在我们的例子中是redux存储的状态),该状态在执行测试之前在应用程序中是在拍摄参考图片时出现的。

为了序列化redux存储的当前状态,已经将window()方法添加了snapshot()方法:

 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); }; 

使用此方法,使用浏览器控制台的命令行,可以将redux存储的当前状态保存到文件中:

图片

作为视觉测试的基础架构,选择了Storybook-一种用于交互式开发视觉组件库的工具。 主要思想是,修复故事树中组件的各种状态,而不是修复应用程序的各种状态,并使用这些状态来截取屏幕截图。 最后,除了在准备环境方面,简单组件和复杂组件之间没有根本区别。

因此,每个视觉测试都是一个故事,在渲染之前,还原了先前保存在文件中的redux存储的状态。 这是使用react-redux库中的Provider组件完成的,将其存储属性传递给从先前保存的文件恢复的反序列化状态:

 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是一个组件,其中包含应用程序的“骨架”-导航树,标题和内容区域。 在内容区域中,可以根据Redux存储的当前状态来呈现各种组件(列表,卡片,对话框等)。 为了使组件不执行对后端的不必要输入请求,会将相应的stub属性传递给它。

在故事书的上下文中,它看起来像这样:

图片

双子座+故事书


因此,我们找出了测试数据。 下一个任务是与Gemini和Storybook交朋友。 乍看之下,一切都很简单-在Gemini配置中,我们指定了被测应用程序的地址。 在我们的例子中,这是Storybook服务器的地址。 您只需要在启动gemini测试之前提高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,但不能为套件中的单个快照设置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()等)的能力。到。 Gemini框架中的元素不“可见”。 但是我们仍然有机会使用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 }; 

重复执行


编写了视觉测试并开始工作之后,事实证明某些测试不是很稳定。 在某个地方,该图标将没有时间绘制,在某个地方,该选择将不会被删除,并且我们与参考图像不匹配。 因此,决定包括对测试执行的重新测试。 但是,在Gemini中,重试适用于整个套件,并且如上所述,我们试图避免为每个镜头制作一个套件的情况-这会大大降低测试的执行速度。 另一方面,在一个套件的框架内拍摄的镜头越多,与前一个套件一样,套件的重复执行的可能性就越大。 因此,有必要实施重试。 在我们的方案中,不对整个套件重复执行,而仅对那些未通过先前失败运行的图片进行重复执行。 为此,在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)); 

总结


今天,我们已经进行了大约五十种视觉测试,涵盖了应用程序的主要视觉状态。 当然,没有必要讨论UI测试的全部内容,但是我们还没有设定这样的目标。 测试可以在开发人员的工作站和构建代理上成功运行。 虽然仅在Chrome和Internet Explorer的环境中执行测试,但是将来有可能连接其他浏览器。 所有这些经济情况为Selemium网格提供了两个部署在虚拟机上的节点。

我们不时面对这样一个事实:由于某些元素的显示开始有所不同(例如滚动条),因此在发布新版本的Chrome之后有必要更新参考图像,但是对此无能为力。 这种情况很少见,但是碰巧,当您更改redux-store的结构时,您必须重新获取测试的已保存状态。 当然,要恢复测试创建时的状态完全相同并不容易。 通常,没有人会记住这些图片是在哪个数据库上拍摄的,因此您必须在其他数据上拍摄一张新图片。 这是一个问题,但不是一个大问题。 为了解决这个问题,您可以在演示的基础上拍照,因为我们拥有用于生成照片的脚本并保持最新状态。

Source: https://habr.com/ru/post/zh-CN454464/


All Articles