restaurant.md 16 KB

Тестирование WEB приложений (на примере vue.js)

Про типы тестирования можно почитать тут

На примере простенького web-приложения разберёмся как далать тестирование web-приложений.

  • для реализации модульного тестирования вынесем расчёт стоимости корзины в отдельную функцию
  • для карточки блюда сделаем компонент и реализуем тестирование компонента
  • протестируем АПИ (и узнаем что такое mock)
  • Проверим основной функционал приложения (добавление блюда в корзину и проверка итога корзины) с помощью end-to-end тестирования

При создании проекта выбрал фичи vitest и end-to-end, это библиотеки для тестирования:

Для end-to-end тестирования выбрал библиотеку Playwright

Что будет делать приложение:

  1. Получать список блюд из АПИ
  2. Отображать карточки блюд
  3. При клике на кнопку с ценой в карточке блюда добавлять его в корзину, при этом карточка блюда поменяет вёрстку - цена передет в описание, а вместо одной кнопки появится блок с кнопками "-", "+" и количеством блюда в корзине

unit-тестирование

Unit тестирование предназначено для тестирования функций без учёта их взаимодействия с остальным проектом. То есть классический черный ящик - знаем что подавать на вход и что должно получиться на выходе

В файле site/src/helpers/common.js реализована функция расчёта суммы корзины:

export function calcItog (cartList) {
  if (Array.isArray(cartList) && cartList.length) {
    return cartList.reduce((total, item) => {
      return total + item.price * item.quantity
    }, 0)
  }
  return 0
}

В ./site/src/App.vue используем эту функцию для расчёта итога корзины:

const itog = computed(() => {
  return calcItog(cartList.value)
})

Добавим тесты

Названия файлов unit-тестов должны заканчиваться на .test.js (для обычного JavaScript) и могут храниться в любом месте проекта, vitest находит их автоматически.

Vitest для тестов создаёт каталоги __tests__ в каталогах с тестируемыми файлами, сделаем так же, создадим каталог ./site/src/helpers/__tests__/ и в нём файл ./site/src/helpers/__tests__/common.test.js

import { describe, expect, test } from 'vitest'
// из файла common.js импортируем функцию calcItog
import { calcItog } from '../common.js'

// создаём тестовые блюда в корзине
const item100_1 = {title:'test', price:100, quantity:1}
const item50_2 = {title:'test', price:50, quantity:2}

// для группировки нескольких связанных тестов используется метод describe
// (использовать его не обязательно)
// В первом параметре пишем что тестируем, во втором лямбда функция с телом теста
describe('Расчет итога корзины', () => {
  // каждый тест завернут в метод test, в котором опять же пишем что делаем
  // и реализуем тест
  test('Вообще не корзина', () => {
    // метод expect получает в параметрах реальное значение
    // т.е. результат тестируемой функции
    // а метод toBe - ожидаемое
    expect(calcItog(null)).toBe(0)
  })

  test('Пустая корзина', () => {
    expect(calcItog([])).toBe(0)
  })

  test('В корзине одно блюдо в одном экземпляре', () => {
    const cart = [item100_1]
    expect(calcItog(cart)).toBe(100)
  })

  test('В корзине одно блюдо в двух экземплярах', () => {
    const cart = [item50_2]
    expect(calcItog(cart)).toBe(100)
  })

  test('В корзине два блюда', () => {
    const cart = [item100_1, item50_2]
    expect(calcItog(cart)).toBe(200)
  })
})

Тестирование компонента

Более сложный вид тестирования - тестирование компонента, когда тестируемый компонент взаимодействует с окружением. Т.е. мы должны симулировать это окружение и взаимодействие с ним

Рассмотрим компонент ./site/src/components/MenuItem.vue

Примерно так выглядит карточка блюда и корзина

"Салат" у нас уже добавлен в корзину и его вёрстка изменена в соответствии с логикой приложения. "Суп" в исходном варианте вёрстки.

В интеграционных тестах для файлов тестов используется суффикс .spec.js, т.к. в них тестируется не результат действия, а поведение.

Cоздадим каталог ./site/src/components/__tests__/ и в нём файл ./site/src/components/__tests__/MenuItem.spec.js

 
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import MenuItemVue from '../MenuItem.vue'
import { MenuItem } from '@/helpers/common'
import { createPinia, setActivePinia } from 'pinia'

describe('MenuItem', () => {
  let wrapper
  
  // перед выполнением каждого теста мы должны создать окружение
  // в нашем случае хранилище и экземпляр компонента
  beforeEach(() => {
    setActivePinia(createPinia())

    // метод mount монтирует компонент с параметрами
    wrapper = mount(MenuItemVue, { 
      props: { item: new MenuItem('test', 100) } 
    })
  })
  
  // в интернетах пишут, что it это просто алиас для test
  it('Элемент меню содержит название', () => {
    // здесь мы получаем текстовое представление компонента (метод text())
    // и ищем в нём название блюда (метод toContain - "содержится")
    // если находит, значит компонент смонтировался нормально
    expect(wrapper.text()).toContain('test')

    // метод test() убирает все теги и показывает только тесктовое содержимое
    // если нужно проанализировать и вёрстку, то можно использовать метод html()
  })

  it('У элемента меню есть кнопка с ценой и при клике на неё цена перезжает в описание', () => {
    // метод find ищет в компоненте элемент или класс (тема css-селекторы)
    // (поэтому полезно тестируемым сущностям задавать отдельные классы)
    // и генерирует на нём событие click
    wrapper.find('.price-button').trigger('click')

    // в название должна переехать цена
    expect(wrapper.text()).toContain('100')
  })

  // модификатор todo создаёт тест, который планируется реализовать в будущем
  it.todo('Тестирование событий', () => {
    // по идее наш компонент реализован не совсем правильно (всё обрабатывает внутри)
    // правильнее было бы события добавления и удаления блюда выбрасывать наружу компонента
    // методами $emit('add-item', item) и $emit('remove-item', item)

    // все события, генерируемые компонентом, собираются в очередь
    // и мы можем просто проверить длину очереди событий
    // expect(wrapper.emitted('add-item')).toHaveLength(1)
  })

  // модификатор skip помечает тест как пропущенный
  it.skip('Элемент меню генерирует событие click', () => {
    // тут я как раз делал более правильный вариант с пробрасыванием 
    // события наружу...

    // компонент обрабатывает клик прямо по внешнему div-у
    // поэтому можем генерировать событие прямо на нём
    wrapper.trigger('click')

    // все события, генерируемые компонентом, собираются в очередь
    // и мы просто проверяем длину очереди событий
    expect(wrapper.emitted('click')).toHaveLength(1)
  })

  // тест, наверное, должен содержать только одну проверку, но мне было лень расписывать несколько отдельных тестов
  it('При клике на кнопку с ценой должен появиться счётчик и кнопки "-" и "+", при клике на кнопку "+" счётчик должен принять значение "2"', async () => {
    // после клика перестраивается DOM, поэтому заворачиваем в await (т.е. дожидаемся, пока не отрисуются все изменения)
    await wrapper.find('.price-button').trigger('click')

    // в компоненте должен появиться счётчик (элемент с классом .qty-value)
    expect(wrapper.find('.qty-value').exists()).toBe(true)

    // кнопка "-"
    expect(wrapper.find('.qty-minus').exists()).toBe(true)

    // и кнопка "+"
    const plusButton = wrapper.find('.qty-plus')
    expect(plusButton.exists()).toBe(true)

    // генерируем клик по кнопке "+" (и тоже ожидаем перестройки DOM)
    await plusButton.trigger('click')

    // после чего в счётчике должно быть ровно "2"
    expect(wrapper.find('.qty-value').text()).toEqual('2')
  })
})

Тестрование АПИ

Из АПИ мы получаем только список блюд, используя метод getItems из файла ./site/src/stores/items.js

async function getItems () {
  if (itemList.value.length == 0) {
    const response = await fetch(`${API_URL}`);
    if (!response.ok) {
      throw new Error('Ошибка при получении списка блюд');
    }
    itemList.value = await response.json()
  }
  return itemList.value
}

Напишем тесты для этого метода API в файле ./site/src/stores/__tests__/items.test.js

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

Mock-тестирование — это испытание программы, при котором реальные её компоненты заменяются «дублёрами» — тестовыми объектами.

import { MenuItem } from '@/helpers/common';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { useItemsStore, API_URL } from '../items';

// vi - это алиас к VitestUtils
// в следующих 3-х строчках мы ставим заглушку на fetch вызовы
vi.mock('node-fetch')
const mockFetch = vi.fn()

// линтер ругается, что мы переопределяем глобальную функцию (fetch)
// eslint-disable-next-line no-global-assign
fetch = mockFetch

describe('Тестирование АПИ', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  test('Успешное получение списка блюд', async () => {
    // подгатавливаем фейковый объект
    const mockItems = [new MenuItem('test', 10)]
    const itemStore = useItemsStore()

    // метод mockResolvedValueOnce сработает один раз при первом вызове fetch
    // вместо реального вызова вернутся фейковые данные
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: () => Promise.resolve(mockItems)
    })

    // делаем штатный запрос к АПИ
    // запрос будет перехвачен и в ответ должны получить фейковые данные
    const items = await itemStore.getItems()

    // сравниваем полученные данные с темы, которые переданы в заглушку
    expect(items).toEqual(mockItems)

    // убеждаемся что запрос был именно на тот endpoint, который ожидался от метода getItems
    expect(mockFetch).toHaveBeenCalledWith(API_URL)
  })

  test('Ошибка при получении списка блюд', async () => {
    // симулируем response.ok == false для следующего запроса
    mockFetch.mockResolvedValueOnce({ ok: false })
    const itemStore = useItemsStore()
  
    // при ошибке получения данных метод должен сгенерировать исключение
    // которое мы и ожидаем получить
    await expect(itemStore.getItems()).rejects.toThrow('Ошибка при получении списка блюд')
  })
})

e2e тестирование

E2e тесты лежат в каталоге ./site/e2e/

Перед запуском тестов нужно настроить используемые браузеры в настройках ./site/playwright.config.js

Я отключил в секции projects браузеры firefox и webkit (нам для учебных целей хватит и одного)

Полезные ссылки