Тестове на компоненти
Накратко: Тестовете на компоненти с Testing Library и Vitest проверяват какво вижда човекът: намират елементи по роля, етикет и текст и кликат като потребител.
Проблем
Тестът е зелен, бутонът не работи
Някой е написал тест за картата на продукта. Пусни го.
Конзола
Отчетът казва „Тестове: 1 минаха, 0 паднаха“. Сега отвори ProductCard.vue: бутонът няма @click. Клиентът натиска „Добави в кошницата“ и нищо не става, а тестът е зелен.
Тестът търси бутона по клас (.add) и проверява текста му. Никой не го натиска. Обратното също се случва: смениш ли класа на btn-add, тестът пада, а човекът пред екрана не вижда никаква разлика. Тестът следи как е написан кодът, а не какво прави.
Идеята
Тествай каквото вижда човекът
Ако си писал тестове в курса по JavaScript, идеята е същата: код, който вика твоя код и сверява резултата. Разликата е, че резултатът на компонент е страница. Затова тестът прави каквото прави човек пред нея:
- Нарисувай компонента:
render. - Намери елемента, както го намира човек: по роля и име, по етикет, по текст.
- Действай като човек:
user.click,user.type. - Провери какво се вижда:
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();});Стъпка по стъпка, с думи
- test() дава име на една проверка. Vitest пуска всички тестове във файла и казва кои минаха.
- render рисува ProductCard в празна страница, както в браузъра. Без App и без останалия магазин: само този компонент.
- Тестът търси бутона като човек: роля „бутон“ и име „Добави в кошницата“. Класът и мястото в кода не го интересуват.
- user.click натиска бутона като човек: събития на мишката, после click. Компонентът се обновява и показва „В кошницата: 1“.
- expect проверява какво вижда човекът: текстът е на страницата. Тестът минава.
- Сега същият тест срещу повредена версия: бутонът не е вързан към нищо. Кликът стига до бутона, но нищо не се сменя.
- expect не намира „В кошницата: 1“ и тестът пада. Зелен срещу верния код и червен срещу повредения: на такъв тест можеш да вярваш.
Тестът не знае дали в кода има className, колко <div> има около бутона и как се казва функцията. Знае какво пише на екрана. Затова оцелява при преработка и пада при повреда.
Пробвай
Първият истински тест
Два теста: картата показва името и цената, а бутонът добавя в кошницата. Пусни ги. После махни @click="add" в ProductCard.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“, а компонентът е верният. Кой ред е виновен?
Избери реда с грешката.
Ред 11: user.click(…) без await. Кликът е обещан, но още не се е случил, когато expect търси текста. С await user.click(…) тестът чака клика и обновяването на компонента.
Идеята
Какво картата казва нагоре: 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);
});toHaveBeenCalledWith(bread): функцията е викана поне веднъж с тези аргументи. Сравнява стойностите, затова обект със същите полета също минава.
Пробвай
Изчерпаният продукт
Баницата е изчерпана: бутонът трябва да е изключен, да пише „Няма наличност“ и кликът да не вика onAdd. Допиши теста: натисни бутона и провери и трите неща. „Провери“ пуска теста ти срещу верната карта и после срещу две повредени версии. Тестът трябва да мине на верната и да падне на всяка повредена.
Конзола
Решение
ProductCard.test.jsimport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import ProductCard from './ProductCard.vue';
const banitsa = { id: 4, name: 'Баница със сирене', price: 2.5, stock: 0 };
test('изчерпаният продукт не се добавя', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(ProductCard, { props: { product: banitsa, onAdd } });
await user.click(screen.getByRole('button', { name: 'Добави в кошницата' }));
expect(onAdd).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Добави в кошницата' })).toBeDisabled();
expect(screen.getByText('Няма наличност')).toBeInTheDocument();
});
ProductCard.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['product']);
const emit = defineEmits(['add']);
const added = ref(0);
const soldOut = computed(() => props.product.stock === 0);
function add() {
added.value++;
emit('add', props.product);
}
</script>
<template>
<article class="card">
<h3>{{ product.name }}</h3>
<p>{{ money(product.price) }}</p>
<p v-if="soldOut">Няма наличност</p>
<button type="button" class="add" :disabled="soldOut" @click="add">Добави в кошницата</button>
<p v-if="added > 0">В кошницата: {{ added }}</p>
</article>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
await user.click(screen.getByRole('button', { name: 'Добави в кошницата' }));expect(onAdd).not.toHaveBeenCalled();expect(screen.getByRole('button', { name: 'Добави в кошницата' })).toBeDisabled();expect(screen.getByText('Няма наличност')).toBeInTheDocument();Кликът върху изключен бутон не стига до компонента, затова onAdd не е викана. Тестът хваща и двете повреди: версията, в която бутонът не е изключен (кликът вика onAdd), и версията без надписа. Тест, който проверява само надписа, минава срещу първата. Затова проверката го отказва.
Идеята
Данни, които идват по-късно: findBy
Списъкът зарежда продуктите от API-то. Първо пише „Зарежда…“, а продуктите идват след малко. Тестът трябва да ги изчака. Пусни двата теста: първият пада нарочно.
Конзола
Заявките са три вида:
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 €“) и „Кошницата е празна“, когато е празна. При „Махни“ казва на родителя кой ред да махне. Напиши тестове, които хващат три повредени версии: сумата не брои количеството, „Махни“ праща първия ред вместо своя и празната кошница не казва нищо. Проверката иска всичките ти тестове да минават на верния код и трите повреди да са хванати.
Конзола
Решение
CartSummary.test.jsimport { test, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import CartSummary from './CartSummary.vue';
const bread = { id: 1, name: 'Бял хляб', price: 1.2, qty: 2 };
const milk = { id: 5, name: 'Прясно мляко', price: 1.6, qty: 1 };
test('показва редовете', () => {
render(CartSummary, { props: { items: [bread, milk], onRemove: vi.fn() } });
expect(screen.getByText('Бял хляб × 2')).toBeInTheDocument();
expect(screen.getByText('Прясно мляко × 1')).toBeInTheDocument();
});
test('сумата брои количеството', () => {
render(CartSummary, { props: { items: [bread, milk], onRemove: vi.fn() } });
expect(screen.getByText('Общо: 4,00 €')).toBeInTheDocument();
});
test('„Махни“ праща своя ред', async () => {
const user = userEvent.setup();
const onRemove = vi.fn();
render(CartSummary, { props: { items: [bread, milk], onRemove: onRemove } });
await user.click(screen.getByRole('button', { name: 'Махни Прясно мляко' }));
expect(onRemove).toHaveBeenCalledWith(milk);
});
test('празната кошница го казва', () => {
render(CartSummary, { props: { items: [], onRemove: vi.fn() } });
expect(screen.getByText('Кошницата е празна')).toBeInTheDocument();
});
CartSummary.vue<script setup>
import { computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['items']);
const emit = defineEmits(['remove']);
const total = computed(() => props.items.reduce((sum, i) => sum + i.price * i.qty, 0));
</script>
<template>
<p v-if="items.length === 0">Кошницата е празна</p>
<section v-else aria-label="Кошницата">
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Махни ${item.name}`" @click="emit('remove', item)">
Махни
</button>
</li>
</ul>
<p>Общо: {{ money(total) }}</p>
</section>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
Три теста, по един за всяко поведение:
- Сумата: ред с количество 2 (
bread) и ред с 1 (milk) дават „Общо: 4,00 €“. Ако всички редове са с количество 1, цена и цена × количество са едно и също и повредата не се вижда. - „Махни“: натисни бутона на втория ред („Махни Прясно мляко“) и провери
toHaveBeenCalledWith(milk). Ако натиснеш първия ред, повредената версия също праща първия ред и тестът минава. - Празната кошница:
items: []и „Кошницата е празна“ на страницата.
Добрият тест избира данни, при които грешката се вижда. Това е същото умение като при тестовете на функции: случаите на границата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Състояние · Вдигане на състоянието · Ефекти · Данни от API в компонент
- Продължава в: От пясъчника към проект · Витрината на магазина
- Задачи по темата: Цяло приложение