Тестирование производительности фронтенда в Cypress и Playwright. Быстрый практикум

Производительность сайта — фактор удержания клиентов, что важно для бизнеса в эпоху короткого контента в TikTok и Instagram. Для тестировщика измерение производительности на стороне клиента и предоставление рекомендаций является обязательным навыком. Далее я поделюсь инструментами, метриками и подходами, которые я использовал для своих клиентов.

Что влияет на скорость веб-страниц

Веб-страницы загружаются быстрее или медленнее в зависимости от таких факторов, как

  • Сколько контента должно быть загружено
  • Каков состав содержимого — много ли изображений? GIF? видео? или просто большие скрипты анимации
  • Насколько быстрое соединение
  • Сколько потоков браузера доступно для обработки загруженных данных — это сложный вопрос, который трудно отладить и оптимизировать, поскольку пользователи обычно держат открытыми много вкладок одновременно
  • Есть ли на странице формы с валидацией полей на стороне клиента
  • Используются ли на странице ненужные скрипты стилей? Используется ли Tailwind CSS для простой справки, которая лучше была бы на GitHub
  • Весь ли контент отображается на клиенте или часть его отрисовывается на стороне сервера
  • Сторонние скрипты, например менеджеры тегов, замедляют страницу, добавляя неиспользуемые куски кода при импорте

Причин много, что усложняет жизнь тестировщика, пытающегося выяснить первопричину.

Метрики, которые нужно знать

Core Web Vitals

Набор метрик, предназначенных для количественной оценки реального пользовательского опыта в отношении производительности загрузки, интерактивности страниц и визуальной стабильности.

Набор Core Web Vitals включает в себя такие измерения, как Largest Contentful Paint (LCP, самый крупный видимый объект в области просмотра), Interaction to Next Paint (INP, взаимодействие с соседним контентом) и Cumulative Layout Shift (CLS, кумулятивный сдвиг макета), с разным «весом». Подробнее здесь.

События страницы

Существуют события, которые отслеживаются браузером, и они могут давать представление о производительности веб-страниц. Рассмотрим их подробнее в следующих разделах.

Инструменты измерения производительности на стороне клиента

Chrome Devtools

Доступный в большинстве браузеров, этот инструмент является одним из самых популярных. Пользователю достаточно открыть консоль DevTools и нажать кнопку Record в левом верхнем углу вкладки.

Lighthouse

Google Lighthouse — бесплатный инструмент с открытым кодом, который поможет улучшить скорость, производительность и общее качество работы сайта. Он доступен в Chrome DevTools в виде расширения для браузера. Помимо производительности, Lighthouse оценивает SEO, передовые методы работы с кодом и общую доступность.

Cайты:

PageSpeed https://pagespeed.web.dev/ — сервис, который принимает URL-адрес и отображает статистику производительности для этого адреса.

GTMetrix https://gtmetrix.com/ — решение, которое принимает URL-адрес и дает очень подробный анализ и рекомендации. Одна из самых полезных фич — возможность изменить местоположение тестового сервера. Это позволяет проверить, какое влияние  на сайт оказывает конфигурация CDN. Платная версия дает доступ к дополнительной информации.

Это всего лишь два примера, которые я нашел полезными, но таких сайтов гораздо больше.

Тайминги навигации

Интерфейс PerformanceNavigationTiming предоставляет методы и свойства для хранения и получения метрик, касающихся событий навигации по документу в браузере. Например с помощью этого интерфейса можно определить, сколько времени требуется для загрузки или выгрузки документа.

Преимущество такого интерфейса заключается в том, что легко автоматизировать тестирование производительности, проверяя через ассерт, что различные временнЫе показатели, предоставляемые интерфейсом, находятся в определенных числовых пределах.

Браузерная поддержка Performance Navigation Timing для настольных и мобильных платформ:

Описания свойств в этом интерфейсе (не все):

duration — Это разница между loadEventEnd и startTime. Это число означает высокую производительность, его можно использовать в автоматических ассертах в качестве основного ассерта — если оно «плохое», нет смысла тратить время на следующие тесты.

responseEnd — это временнАя метка загрузки содержимого в браузер, она описывает сетевую часть интерфейса таймингов (см. Resource Timing в Navigation Timing на диаграмме).

Для статического HTML-файла или страницы, отрисованной на стороне сервера, это время, по сути, является интегральным показателем. Оно включает в себя поиск DNS, «рукопожатия» TCP, и время, необходимое серверу для рендеринга контента страницы через TCP-соединение. Эта метрика намекает на узкие места сервера.

domInteractive — Эта метка (timestamp) сообщает, когда DOM браузера готов к взаимодействию с пользователем. (Хотя обработка некоторых скриптов может быть еще в процессе). Это событие устанавливает состояние готовности браузера в «интерактивное». При просмотре отчета Lighthouse, Time To Interactive (TTI) — это метрика, которая соответствует времени domInteractive.

domComplete — Эта метка соответствует моменту, когда парсер завершил обработку основного документа, то есть когда состояние готовности браузера readyState меняется на «завершено».

loadEventEnd — Эта метка сигнализирует о том, что загрузка веб-страницы завершена и теперь можно с ней работать. При автоматизации тестирования это событие является ключевым, если есть цель дождаться завершения загрузки страницы и избежать нестабильного поведения.

Выше перечислены лишь несколько событий, которые необходимы для автоматизации тестирования веб-страниц и тестирования производительности. Чтобы ознакомиться с полным списком событий, прочитайте официальную документацию на портале Mozilla Documentation.

Тайминги навигации в консоли браузера

Откройте вкладку команд браузера и введите эту команду для получения тайминга:

const [entry] = performance.getEntriesByType("navigation");
console.table(entry.toJSON());

В консоли отображается таблица следующего вида:

Эта таблица показывает временные метки в формате UNIX в виде таблицы и является более удобной для чтения, и объект entry может быть использован в формате JSON для программного доступа к этим значениям.

Автоматизация тестов

Cypress:

Ниже приведен пример кода Cypress, который проверяет производительность страницы (то есть тестирование производительности на стороне клиента).

/* Глобальный ассерт */
describe('Check some performance metrics', () => {
  it('check page load time page from marks', () => {
    cy.visit('https://docs.deno.com/', {
      onBeforeLoad: (win) => {
        win.performance.mark('start-loading')
        win.localStorage.clear()
        win.sessionStorage.clear()
      },
      onLoad: (win) => {
        win.performance.mark('end-loading')
      },
    }).its('performance').then((p) => {
      p.measure('pageLoad', 'start-loading', 'end-loading')
      const measure = p.getEntriesByName('pageLoad')[0]
      cy.log('Time noted by marks - ' + measure.duration)
      cy.wrap(measure.duration).should('be.lessThan', 1000)
    })
  })

  it('check page load time page from PerformanceNavigationTimings', () => {
    cy.visit('https://docs.deno.com/')
      .its('performance').then(p => {
        const navEntry = p.getEntriesByType("navigation")[0];
        
        cy.log('Time noted by duration of PerformanceNavigationTimings - ' + navEntry.duration)
        cy.log('Time noted by PerformanceNavigationTimings - connectStart to connectEnd - ' + 
               (navEntry.connectEnd - navEntry.connectStart))
        cy.log('Time noted by PerformanceNavigationTimings - startTime to responseEnd - ' + 
               (navEntry.responseEnd - navEntry.startTime))
        cy.log('Time noted by PerformanceNavigationTimings - startTime to loadEventEnd - ' + 
               (navEntry.loadEventEnd - navEntry.startTime))
        cy.log('Time noted by PerformanceNavigationTimings - responseEnd to loadEventEnd - ' + 
               (navEntry.loadEventEnd - navEntry.responseEnd))
        
        // Добавляем свой ассерт
      })
  })
  
  it('ensure max load time for images', () => {
    cy.visit('https://docs.deno.com/').its('performance').then((p) => {
      const imgs = p.getEntriesByType('resource').filter((x) => x.initiatorType === 'img')
      const slowestImg = imgs.reduce((p, c) => c.duration > p.duration ? c : p, { duration: 0 })
      
      cy.wrap(slowestImg.duration).should('be.lessThan', 400, 
        `image '${slowestImg.name}' should be loaded in reasonable time`)
    })
  })
})

Playwright:

const { test, expect } = require('@playwright/test');

test.describe('Check some performance metrics', () => {
  test('check page load time page from marks', async ({ page }) => {
    // Создаем маркер производительности
    await page.addInitScript(() => {
      window.performance.mark('start-loading');
      window.localStorage.clear();
      window.sessionStorage.clear();
    });
    
    await page.goto('https://playwright.dev/docs/intro');
    
    // Добавляем маркер конца и измеряем время загрузки страницы
    const loadTimeMs = await page.evaluate(() => {
      window.performance.mark('end-loading');
      window.performance.measure('pageLoad', 'start-loading', 'end-loading');
      const measure = window.performance.getEntriesByName('pageLoad')[0];
      return measure.duration;
    });
    
    console.log(`Time noted by marks - ${loadTimeMs}`);
    expect(loadTimeMs).toBeLessThan(1000);
  });

  test('check page load time from PerformanceNavigationTimings', async ({ page }) => {
    await page.goto('https://playwright.dev/docs/intro');
    
    // Получение навигационных показателей производительности
    const perfMetrics = await page.evaluate(() => {
      const navEntry = performance.getEntriesByType('navigation')[0];
      return {
        totalDuration: (navEntry as PerformanceNavigationTiming).duration,
        connectTime: (navEntry as PerformanceNavigationTiming).connectEnd - (navEntry as PerformanceNavigationTiming).connectStart,
        responseTime: (navEntry as PerformanceNavigationTiming).responseEnd - (navEntry as PerformanceNavigationTiming).startTime,
        loadEventTime: (navEntry as PerformanceNavigationTiming).loadEventEnd - (navEntry as PerformanceNavigationTiming).startTime,
        processingTime: (navEntry as PerformanceNavigationTiming).loadEventEnd - (navEntry as PerformanceNavigationTiming).responseEnd
      };
    });
    
    // Записываем метрики производительности в лог
    console.log('Time noted by duration of PerformanceNavigationTimings - ' + perfMetrics.totalDuration);
    console.log('Time noted by PerformanceNavigationTimings - connectStart to connectEnd - ' + perfMetrics.connectTime);
    console.log('Time noted by PerformanceNavigationTimings - startTime to responseEnd - ' + perfMetrics.responseTime);
    console.log('Time noted by PerformanceNavigationTimings - startTime to loadEventEnd - ' + perfMetrics.loadEventTime);
    console.log('Time noted by PerformanceNavigationTimings - responseEnd to loadEventEnd - ' + perfMetrics.processingTime);
    
    expect(perfMetrics.totalDuration).toBeLessThan(3000, 'Total duration time should be reasonable');
    expect(perfMetrics.connectTime).toBeLessThan(500, 'Connection time should be reasonable');
    expect(perfMetrics.responseTime).toBeLessThan(2000, 'Response time should be reasonable');
    expect(perfMetrics.loadEventTime).toBeLessThan(2500, 'Load event time should be reasonable');
    expect(perfMetrics.processingTime).toBeLessThan(1000, 'Processing time should be reasonable');
  });
  
  test('ensure max load time for images', async ({ page }) => {
    await page.goto('https://playwright.dev/docs/intro');
    
    // Получение записей о производительности для изображений
    const imgPerformance = await page.evaluate(() => {
      const imgs = performance.getEntriesByType('resource')
        .filter(entry => (entry as PerformanceResourceTiming).initiatorType === 'img');
      
      // Находим самое медленное изображение
      const slowestImg = imgs.reduce(
        (prev, current) => current.duration > prev.duration ? current : prev,
        { duration: 0, name: 'none' }
      );
      
      return {
        duration: slowestImg.duration,
        name: slowestImg.name
      };
    });
    
    // Записываем в лог и проверяем самое медленное время загрузки изображений
    console.log(`Slowest image '${imgPerformance.name}' loaded in ${imgPerformance.duration}ms`);
    expect(imgPerformance.duration).toBeLessThan(400, 
      `Image '${imgPerformance.name}' should be loaded in reasonable time`);
  });
  
  test('check detailed resource timing', async ({ page }) => {
    // Переходим по URL-адресу
    await page.goto('https://playwright.dev/docs/intro'); 
    
    // Анализ записей о таймингах ресурсов
    const resourceTimings = await page.evaluate(() => {
      // Получить все тайминги синхронизации ресурсов
      const resources = performance.getEntriesByType('resource');
      
      // Рассчитываем общее количество ресурсов и их размеры
      const totalResources = resources.length;
      const totalSize = resources.reduce((sum, resource) => sum + ((resource as PerformanceResourceTiming).transferSize || 0), 0);
      
      // Группируем ресурсы по типу
      const resourcesByType = resources.reduce((acc, resource) => {
        const type = (resource as PerformanceResourceTiming).initiatorType || 'other';
        if (!acc[type]) acc[type] = [];
        acc[type].push(resource);
        return acc;
      }, {});
      
      // Рассчитываем статистику для каждого типа
      const stats = {};
      for (const [type, typeResources] of Object.entries(resourcesByType)) {
        const resources = typeResources as PerformanceResourceTiming[];
        stats[type] = {
          count: resources.length,
          totalSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
          totalDuration: resources.reduce((sum, r) => sum + r.duration, 0),
          avgDuration: resources.reduce((sum, r) => sum + r.duration, 0) / resources.length
        };
      }
      
      return { totalResources, totalSize, stats };
    });
    
    console.log(`Total resources: ${resourceTimings.totalResources}`);
    console.log(`Total size: ${Math.round(resourceTimings.totalSize / 1024)} KB`);
    
    for (const [type, stats] of Object.entries(resourceTimings.stats)) {
      console.log(`
        Type: ${type}
        Count: ${(stats as any).count}
        Total Size: ${Math.round((stats as any).totalSize / 1024)} KB
        Avg Duration: ${Math.round((stats as any).avgDuration)} ms
      `);
    }
    
    expect(resourceTimings.totalResources).toBeGreaterThan(0);
  });
  
  test('check first contentful paint and other web vitals', async ({ page }) => {
    // Для этого нужно включить соответствующие флажки в Chrome
    page.on('console', msg => console.log(`[Browser Console] ${msg.text()}`));
    
    await page.goto('https://playwright.dev/docs/intro');
    
    const webVitals = await page.evaluate(() => {
      return new Promise(resolve => {
        // Проверяем, поддерживает ли браузер API Performance Observer
        if (!('PerformanceObserver' in window)) {
          return resolve({ error: 'PerformanceObserver not supported' });
        }
        
        // Создаем объект для хранения метрик
        const metrics = {};
        
        // Получаем метрику FCP, если она доступна
        const fcpEntry = performance.getEntriesByName('first-contentful-paint')[0];
        if (fcpEntry) {
          (metrics as {[key: string]: number})['FCP'] = fcpEntry.startTime;
        }
        
        // Получение LCP, CLS, FID через PerformanceObserver  
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          entries.forEach(entry => {
            // У PerformanceEntry нет свойства value, используем вместо него duration
            metrics[entry.name] = entry.duration || entry.startTime;
          });
          
          // После тайм-аута решаем ситуацию с помощью собранных метрик
          setTimeout(() => resolve(metrics), 1000);
        });
        
        // Проверяем тайминги отрисовки
        observer.observe({ type: 'paint', buffered: true });
        
        // Если еще не решено, делаем это после таймаута
        setTimeout(() => resolve(metrics), 3000);
      });
    });
    
    console.log('Web Vitals:', webVitals);
    
    // Добавляем ассерты, если метрики доступны
    if (webVitals.FCP) {
      expect(webVitals.FCP).toBeLessThan(2000);
    }
  });
});

Полный код здесь.

Зная детали интерфейса NavigationTiming, вы сможете быстро обнаружить проблемы с производительностью и предложить соответствующие исправления, а также автоматизировать тесты. При каждой сборке фронтенда тест запускается как часть CI/CD-конвейера и гарантирует, что никакие разрушающие изменения не будут развернуты на проде.

Medium

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас