Към съдържанието
wannacode

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

Накратко: Тестовете на компоненти с Testing Library и Vitest проверяват какво вижда човекът: намират елементи по роля, етикет и текст и кликат като потребител.

Проблем

Тестът е зелен, бутонът не работи

Някой е написал тест за картата на продукта. Пусни го.

Vue

Отчетът казва „Тестове: 1 минаха, 0 паднаха“. Сега отвори ProductCard.vue: бутонът няма @click. Клиентът натиска „Добави в кошницата“ и нищо не става, а тестът е зелен.

Тестът търси бутона по клас (.add) и проверява текста му. Никой не го натиска. Обратното също се случва: смениш ли класа на btn-add, тестът пада, а човекът пред екрана не вижда никаква разлика. Тестът следи как е написан кодът, а не какво прави.

Идеята

Тествай каквото вижда човекът

Ако си писал тестове в курса по JavaScript, идеята е същата: код, който вика твоя код и сверява резултата. Разликата е, че резултатът на компонент е страница. Затова тестът прави каквото прави човек пред нея:

  1. Нарисувай компонента: render.
  2. Намери елемента, както го намира човек: по роля и име, по етикет, по текст.
  3. Действай като човек: user.click, user.type.
  4. Провери какво се вижда: expect(…).toBeInTheDocument().

Инструментите са два. Vitest пуска тестовете (test, expect), а Testing Library рисува компонента и търси в страницата. Това са тестове на компоненти.

import { render, screen } from '@testing-library/vue';
render(ProductCard, { props: { product: bread } });
screen.getByRole('button', { name: 'Добави в кошницата' });

Редът, в който Testing Library съветва да търсиш:

  • getByRole с име: бутон, връзка, заглавие, поле. Така търси екранният четец, затова тестът проверява и достъпността. Бутон без име не се намира.
  • getByLabelText: полето по етикета му.
  • getByText: текст, който не е в бутон или заглавие.
  • getByTestId чак накрая, когато нищо друго не става. Атрибутът data-testid не се вижда от човека.

Виж го

Виж как върви тестът

Четирите стъпки върху една карта. После същият тест срещу повредена версия.

test('бутонът добавя в кошницата', async () => {
const user = userEvent.setup();
render(ProductCard, { props: { product: bread } });
const button = screen.getByRole('button', {
name: 'Добави в кошницата',
});
await user.click(button);
expect(screen.getByText('В кошницата: 1'))
.toBeInTheDocument();
});
Стъпка по стъпка, с думи
  1. test() дава име на една проверка. Vitest пуска всички тестове във файла и казва кои минаха.
  2. render рисува ProductCard в празна страница, както в браузъра. Без App и без останалия магазин: само този компонент.
  3. Тестът търси бутона като човек: роля „бутон“ и име „Добави в кошницата“. Класът и мястото в кода не го интересуват.
  4. user.click натиска бутона като човек: събития на мишката, после click. Компонентът се обновява и показва „В кошницата: 1“.
  5. expect проверява какво вижда човекът: текстът е на страницата. Тестът минава.
  6. Сега същият тест срещу повредена версия: бутонът не е вързан към нищо. Кликът стига до бутона, но нищо не се сменя.
  7. expect не намира „В кошницата: 1“ и тестът пада. Зелен срещу верния код и червен срещу повредения: на такъв тест можеш да вярваш.

Тестът не знае дали в кода има className, колко <div> има около бутона и как се казва функцията. Знае какво пише на екрана. Затова оцелява при преработка и пада при повреда.

Пробвай

Първият истински тест

Два теста: картата показва името и цената, а бутонът добавя в кошницата. Пусни ги. После махни @click="add" в ProductCard.vue и пусни пак: вторият тест пада.

Vue

userEvent.setup() прави „потребител“, а await user.click(…) натиска като човек: мишката влиза над бутона, натиска, пуска и чак тогава идва click. Всяко действие на user връща Promise, затова тестът е async и чака с await.

toBeInTheDocument() идва от jest-dom: проверки за страницата като toBeDisabled(), toHaveTextContent(), toHaveValue(). В пясъчника те са вградени, а в проекта се включват с един ред (виж „По-точно“).

Пробвай

Намери грешката

Тестът пада с „Unable to find an element with the text: В кошницата: 1“, а компонентът е верният. Кой ред е виновен?

Избери реда с грешката.

Идеята

Какво картата казва нагоре: vi.fn()

Картата не притежава кошницата. Тя казва на родителя „добави този продукт“ (урокът за общото състояние). Тест на картата не трябва да рисува целия магазин, за да види това. Вместо родител подаваш vi.fn(): празна функция, която помни всяко свое извикване. Такава заместваща функция се казва mock.

const onAdd = vi.fn();
render(ProductCard, { props: { product: bread, onAdd } });
await user.click(screen.getByRole('button', { name: 'Добави в кошницата' }));
expect(onAdd).toHaveBeenCalledTimes(1);
expect(onAdd).toHaveBeenCalledWith(bread);

Картата прави emit('add', product). Във Vue слушателят на събитие е prop с on отпред: @add="…" в шаблона е същото като prop onAdd. Затова onAdd в props на render слуша събитието add.

not обръща проверката: expect(onAdd).not.toHaveBeenCalled() значи „не е викана нито веднъж“.

Тестът проверява, че картата праща точно този продукт. Какво стои на празното място?

test('бутонът добавя в кошницата', async () => {
  const user = userEvent.setup();
  const onAdd = vi.fn();
  render(ProductCard, { props: { product: bread, onAdd } });
  await user.click(screen.getByRole('button', { name: 'Добави в кошницата' }));
  expect(onAdd).(bread);
});

Пробвай

Изчерпаният продукт

Баницата е изчерпана: бутонът трябва да е изключен, да пише „Няма наличност“ и кликът да не вика onAdd. Допиши теста: натисни бутона и провери и трите неща. „Провери“ пуска теста ти срещу верната карта и после срещу две повредени версии. Тестът трябва да мине на верната и да падне на всяка повредена.

Vue

Идеята

Данни, които идват по-късно: findBy

Списъкът зарежда продуктите от API-то. Първо пише „Зарежда…“, а продуктите идват след малко. Тестът трябва да ги изчака. Пусни двата теста: първият пада нарочно.

Vue

Заявките са три вида:

  • getBy… търси веднага. Ако елементът го няма, тестът пада. Първият тест пада точно така: отговорът още не е дошъл.
  • findBy… връща Promise и опитва отново, докато елементът се появи. Ако не се появи до време (в Testing Library по подразбиране 1 секунда), пада. Затова е с await.
  • queryBy… връща null, ако го няма, без да пада. С него проверяваш, че нещо е изчезнало: expect(screen.queryByText('Зарежда…')).toBeNull().

Когато чакаш нещо, което не е елемент (функция, извикана след заявката), ползваш waitFor: await waitFor(() => expect(onLoad).toHaveBeenCalled()). Той пуска проверката, докато мине или изтече времето.

По-точно, за любопитните

В истински проект. Пакетите: npm install -D vitest jsdom @testing-library/user-event @testing-library/jest-dom и @testing-library/vue. Във vite.config.js добавяш test: { environment: 'jsdom' }: тестовете вървят в Node, а jsdom им дава страница. В началото на тестовете (или във файл от setupFiles) пише import '@testing-library/jest-dom/vitest', за да има toBeInTheDocument() и другите. Тестовете се пускат с npx vitest, а файловете им са *.test.jsx или *.test.js до компонента.

Чистенето между тестовете. Testing Library маха нарисуваното след всеки тест сама, ако afterEach е глобален. Във Vitest той е глобален само с test: { globals: true }. Иначе викаш cleanup() в afterEach. В пясъчника всеки тест получава празна страница.

emitted(). render от @testing-library/vue връща и emitted(): всички изпратени събития с аргументите им, например emitted().add. Слушателят onAdd с vi.fn() дава същото, а проверките са същите като в React.

fireEvent и user. fireEvent.click(button) праща едно събитие click. user.click(button) минава през всичко, което прави човек: мишката, фокуса, натискането. Елемент със pointer-events: none, който човек не може да натисне, user отказва да натисне с грешка, а fireEvent праща събитието. Затова user е по-близо до истината.

Тестове от край до край. Тестът на компонент рисува едно парче. Тестът end-to-end (например с Playwright) пуска цялото приложение в истински браузър и минава през него като клиент: каталог, кошница, поръчка. Той хваща грешки между частите, но е по-бавен. Затова тестовете на компоненти са много, а от край до край са малко, за най-важните пътища.

Какво не тестваш. Състоянието отвътре (added, soldOut), имената на функциите и класовете. Те се сменят при преработка. Тестваш входа (props, кликове, отговорите на API-то) и изхода (страницата и извикванията нагоре).

Пясъчникът. Тук vitest и Testing Library са малки версии със същите имена: само частта от API-то, която урокът ползва. findBy и waitFor чакат до 1 секунда, а тест се спира след 4 секунди. Кодът на тестовете се пише както в истински проект.

Упражнение

Тестове за кошницата

CartSummary показва редовете („Бял хляб × 2“ с бутон „Махни“), сумата („Общо: 4,00 €“) и „Кошницата е празна“, когато е празна. При „Махни“ казва на родителя кой ред да махне. Напиши тестове, които хващат три повредени версии: сумата не брои количеството, „Махни“ праща първия ред вместо своя и празната кошница не казва нищо. Проверката иска всичките ти тестове да минават на верния код и трите повреди да са хванати.

Vue

Тест

Провери се

1. Тестът трябва да намери бутона „Добави в кошницата“. Коя заявка да ползваш първо?
2. Компонентът зарежда продуктите от API-то. Тестът вика getByRole('heading', { name: 'Бял хляб' }) веднага след render и пада. Защо?
3. Тестът ти минава и срещу верния код, и срещу версия, в която бутонът не е вързан. Какво значи това?
4. Защо в теста на ProductCard подаваш onAdd = vi.fn()?
5. Искаш да провериш целия път: каталог, кошница, формуляр за поръчка, истински сървър. Какъв тест е това?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·