Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created June 6, 2025 16:43
Show Gist options
  • Save sunmeat/e3d0166a510d61606617a6df72a29f99 to your computer and use it in GitHub Desktop.
Save sunmeat/e3d0166a510d61606617a6df72a29f99 to your computer and use it in GitHub Desktop.
RTL + vitest example
App.jsx:
import {useState, useEffect, useCallback, useMemo} from 'react';
import {QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient} from '@tanstack/react-query';
import Dexie from 'dexie'; // npm install dexie
import './App.css';
const db = new Dexie('CartDatabase');
db.version(1).stores({
cart: 'id, title, price, image, quantity',
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 60 * 1000,
},
},
});
const truncateText = (text, maxLength) => {
if (!text) return '';
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text;
};
const formatPrice = (price) => {
return typeof price === 'number' ? price.toFixed(2) : '0.00';
};
const cartService = {
async getCart() {
try {
return await db.cart.toArray();
} catch (error) {
console.error('Ошибка загрузки корзины:', error);
return [];
}
},
async addItem(product) {
try {
const existingItem = await db.cart.get(product.id);
if (existingItem) {
const updatedItem = {...existingItem, quantity: existingItem.quantity + 1};
await db.cart.put(updatedItem);
return updatedItem;
} else {
const newItem = {
id: product.id,
title: product.title,
price: product.price,
image: product.image,
quantity: 1,
};
await db.cart.put(newItem);
return newItem;
}
} catch (error) {
console.error('Ошибка добавления в корзину:', error);
throw error;
}
},
async updateQuantity(productId, quantity) {
try {
if (quantity <= 0) {
return this.removeItem(productId);
}
const item = await db.cart.get(productId);
if (item) {
const updatedItem = {...item, quantity};
await db.cart.put(updatedItem);
return updatedItem;
}
} catch (error) {
console.error('Ошибка обновления количества:', error);
throw error;
}
},
async removeItem(productId) {
try {
await db.cart.delete(productId);
return productId;
} catch (error) {
console.error('Ошибка удаления из корзины:', error);
throw error;
}
},
async clearCart() {
try {
await db.cart.clear();
} catch (error) {
console.error('Ошибка очистки корзины:', error);
throw error;
}
}
};
function Notification({message, type, onClose}) {
useEffect(() => {
const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className={`notification notification-${type}`}>
{message}
<button onClick={onClose} className="notification-close">×</button>
</div>
);
}
function LoadingSpinner({size = 'medium'}) {
return <div className={`loading-spinner loading-${size}`}>Загрузка...</div>;
}
function ProductList() {
const [searchTerm, setSearchTerm] = useState('');
const [notification, setNotification] = useState(null);
const queryClient = useQueryClient();
const fetchProducts = async () => {
const response = await fetch('https://fakestoreapi.com/products?limit=20');
if (!response.ok) throw new Error('Ошибка загрузки продуктов');
const data = await response.json();
return data.filter(product =>
product &&
product.id &&
product.title &&
typeof product.price === 'number'
);
};
const {data: products = [], error, isLoading} = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
const addToCartMutation = useMutation({
mutationFn: cartService.addItem,
onSuccess: (data) => {
queryClient.invalidateQueries({queryKey: ['cart']});
setNotification({
message: `${data.title} добавлен в корзину`,
type: 'success'
});
},
onError: (error) => {
setNotification({
message: 'Ошибка при добавлении товара' + error,
type: 'error'
});
}
});
const filteredProducts = useMemo(() => {
if (!searchTerm) return products;
return products.filter(product =>
product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.category?.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [products, searchTerm]);
const handleAddToCart = useCallback((product) => {
addToCartMutation.mutate(product);
}, [addToCartMutation]);
if (isLoading) return <LoadingSpinner/>;
if (error) return <div className="error">Ошибка: {error.message}</div>;
return (
<div className="product-list">
{notification && (
<Notification
message={notification.message}
type={notification.type}
onClose={() => setNotification(null)}
/>
)}
<div className="product-header">
<h2>Наши товары ({filteredProducts.length})</h2>
<div className="search-container">
<input
type="text"
placeholder="Поиск товаров..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
</div>
<div className="products">
{filteredProducts.map((product) => (
<div key={product.id} className="product-card">
<div className="product-image-container">
<img
src={product.image}
alt={product.title}
className="product-image"
loading="lazy"
/>
</div>
<div className="product-info">
<h3 title={product.title}>
{truncateText(product.title, 50)}
</h3>
<p className="product-category">{product.category}</p>
<p className="price">${formatPrice(product.price)}</p>
<button
onClick={() => handleAddToCart(product)}
className="add-to-cart"
disabled={addToCartMutation.isPending}
>
{addToCartMutation.isPending ? 'Добавление...' : 'Добавить в корзину'}
</button>
</div>
</div>
))}
</div>
{filteredProducts.length === 0 && searchTerm && (
<div className="no-results">
<p>По запросу "{searchTerm}" ничего не найдено</p>
<button onClick={() => setSearchTerm('')} className="clear-search">
Очистить поиск
</button>
</div>
)}
</div>
);
}
function Cart() {
const [notification, setNotification] = useState(null);
const queryClient = useQueryClient();
const {data: cart = [], isLoading, error} = useQuery({
queryKey: ['cart'],
queryFn: cartService.getCart,
});
const updateQuantityMutation = useMutation({
mutationFn: ({productId, quantity}) => cartService.updateQuantity(productId, quantity),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['cart']});
},
onError: () => {
setNotification({
message: 'Ошибка при обновлении количества',
type: 'error'
});
}
});
const removeItemMutation = useMutation({
mutationFn: cartService.removeItem,
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['cart']});
setNotification({
message: 'Товар удален из корзины',
type: 'success'
});
},
onError: () => {
setNotification({
message: 'Ошибка при удалении товара',
type: 'error'
});
}
});
const clearCartMutation = useMutation({
mutationFn: cartService.clearCart,
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['cart']});
setNotification({
message: 'Корзина очищена',
type: 'success'
});
},
onError: () => {
setNotification({
message: 'Ошибка при очистке корзины',
type: 'error'
});
}
});
const total = useMemo(() => {
return cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}, [cart]);
const totalItems = useMemo(() => {
return cart.reduce((sum, item) => sum + item.quantity, 0);
}, [cart]);
const handleQuantityChange = useCallback((productId, quantity) => {
updateQuantityMutation.mutate({productId, quantity});
}, [updateQuantityMutation]);
const handleRemoveItem = useCallback((productId) => {
removeItemMutation.mutate(productId);
}, [removeItemMutation]);
const handleClearCart = useCallback(() => {
if (window.confirm('Вы уверены, что хотите очистить корзину?')) {
clearCartMutation.mutate();
}
}, [clearCartMutation]);
if (isLoading) return <LoadingSpinner size="small"/>;
if (error) return <div className="error">Ошибка загрузки корзины</div>;
return (
<div className="cart">
{notification && (
<Notification
message={notification.message}
type={notification.type}
onClose={() => setNotification(null)}
/>
)}
<div className="cart-header">
<h2>Корзина ({totalItems} товаров)</h2>
{cart.length > 0 && (
<button
onClick={handleClearCart}
className="clear-cart-btn"
disabled={clearCartMutation.isPending}
>
{clearCartMutation.isPending ? 'Очистка...' : 'Очистить корзину'}
</button>
)}
</div>
{cart.length === 0 ? (
<div className="empty-cart">
<p>Корзина пуста</p>
<p className="empty-cart-hint">Добавьте товары из каталога</p>
</div>
) : (
<>
<div className="cart-items">
{cart.map(item => (
<div key={item.id} className="cart-item">
<div className="cart-item-image">
<img src={item.image} alt={item.title}/>
</div>
<div className="cart-item-info">
<h4 title={item.title}>
{truncateText(item.title, 40)}
</h4>
<p className="item-price">${formatPrice(item.price)}</p>
</div>
<div className="cart-item-controls">
<div className="quantity-controls">
<button
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
disabled={updateQuantityMutation.isPending}
className="quantity-btn"
>
-
</button>
<span className="quantity">{item.quantity}</span>
<button
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
disabled={updateQuantityMutation.isPending}
className="quantity-btn"
>
+
</button>
</div>
<div className="item-total">
${formatPrice(item.price * item.quantity)}
</div>
<button
className="remove-item-btn"
onClick={() => handleRemoveItem(item.id)}
disabled={removeItemMutation.isPending}
title="Удалить товар"
>
🗑️
</button>
</div>
</div>
))}
</div>
<div className="cart-summary">
<div className="total">
<strong>Итого: ${formatPrice(total)}</strong>
</div>
<button className="checkout-btn">
Оформить заказ
</button>
</div>
</>
)}
</div>
);
}
function AppContent() {
return (
<div className="app">
<header className="app-header">
<h1>🛒 Интернет-магазин ReactExpress</h1>
<p className="app-subtitle">Качественные товары по доступным ценам</p>
</header>
<main className="app-main">
<ProductList/>
<Cart/>
</main>
</div>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent/>
</QueryClientProvider>
);
}
export default App;
=======================================================================================================
App.css:
.app {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app-header {
text-align: center;
margin-bottom: 40px;
padding: 30px 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.app-header h1 {
color: #ffffff;
font-size: 3em;
font-weight: 700;
margin: 0 0 10px 0;
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3);
letter-spacing: -1px;
}
.app-subtitle {
color: rgba(255, 255, 255, 0.9);
font-size: 1.2em;
margin: 0;
font-weight: 300;
}
.app-main {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 30px;
align-items: start;
}
@media (max-width: 1024px) {
.app-main {
grid-template-columns: 1fr;
gap: 20px;
}
}
.product-list {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.product-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.product-header h2 {
color: #2c3e50;
margin: 0;
font-size: 2em;
font-weight: 600;
}
.search-container {
flex: 1;
max-width: 300px;
min-width: 200px;
}
.search-input {
width: 100%;
padding: 12px 10px;
border: 2px solid #e0e0e0;
border-radius: 25px;
font-size: 1em;
transition: all 0.3s ease;
background: #f8f9fa;
}
.search-input:focus {
outline: none;
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.products {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 25px;
}
.product-card {
background: #ffffff;
border-radius: 16px;
padding: 20px;
text-align: center;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
height: 420px;
}
.product-card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15);
}
.product-image-container {
height: 180px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 15px;
background: #f8f9fa;
border-radius: 12px;
overflow: hidden;
}
.product-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-card:hover .product-image {
transform: scale(1.05);
}
.product-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-info h3 {
color: #2c3e50;
margin: 0 0 8px 0;
font-size: 1.1em;
font-weight: 600;
line-height: 1.3;
min-height: 2.6em;
}
.product-category {
color: #7f8c8d;
font-size: 0.9em;
margin: 0 0 10px 0;
text-transform: capitalize;
font-weight: 500;
}
.price {
color: #e74c3c;
font-weight: 700;
font-size: 1.4em;
margin: 10px 0 15px 0;
}
.add-to-cart {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
cursor: pointer;
border-radius: 25px;
font-size: 1em;
font-weight: 600;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.add-to-cart:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.add-to-cart:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.no-results {
text-align: center;
padding: 60px 20px;
color: #7f8c8d;
}
.no-results p {
font-size: 1.2em;
margin-bottom: 20px;
}
.clear-search {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 20px;
cursor: pointer;
transition: background 0.3s ease;
}
.clear-search:hover {
background: #2980b9;
}
.cart {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
flex-wrap: wrap;
gap: 15px;
}
.cart-header h2 {
color: #2c3e50;
margin: 0;
font-size: 1.8em;
font-weight: 600;
}
.clear-cart-btn {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.3s ease;
}
.clear-cart-btn:hover:not(:disabled) {
background: #c0392b;
transform: translateY(-1px);
}
.clear-cart-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.empty-cart {
text-align: center;
padding: 40px 20px;
color: #7f8c8d;
}
.empty-cart p {
margin: 0 0 10px 0;
font-size: 1.1em;
}
.empty-cart-hint {
font-size: 0.9em;
opacity: 0.7;
}
.cart-items {
margin-bottom: 25px;
}
.cart-item {
display: grid;
grid-template-columns: 60px 1fr auto;
gap: 15px;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #ecf0f1;
}
.cart-item:last-child {
border-bottom: none;
}
.cart-item-image {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
.cart-item-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.cart-item-info h4 {
margin: 0 0 5px 0;
color: #2c3e50;
font-size: 0.95em;
font-weight: 600;
line-height: 1.3;
}
.item-price {
margin: 0;
color: #7f8c8d;
font-size: 0.9em;
}
.cart-item-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 8px;
background: #f8f9fa;
border-radius: 20px;
padding: 4px;
}
.quantity-btn {
width: 28px;
height: 28px;
border: none;
background: #ffffff;
border-radius: 50%;
cursor: pointer;
font-weight: bold;
color: #667eea;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.quantity-btn:hover:not(:disabled) {
background: #667eea;
color: white;
transform: scale(1.1);
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity {
min-width: 30px;
text-align: center;
font-weight: 600;
color: #2c3e50;
}
.item-total {
font-weight: 700;
color: #e74c3c;
font-size: 0.9em;
}
.remove-item-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2em;
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
opacity: 0.6;
}
.remove-item-btn:hover:not(:disabled) {
opacity: 1;
transform: scale(1.2);
background: rgba(231, 76, 60, 0.1);
}
.remove-item-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.cart-summary {
border-top: 2px solid #ecf0f1;
padding-top: 20px;
display: flex;
flex-direction: column;
gap: 15px;
}
.total {
font-size: 1.4em;
font-weight: 700;
color: #2c3e50;
text-align: right;
}
.checkout-btn {
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 25px;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 1px;
}
.checkout-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.4);
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 10px;
color: white;
font-weight: 600;
z-index: 1000;
animation: slideIn 0.3s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
min-width: 300px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.notification-success {
background: linear-gradient(135deg, #2ecc71, #27ae60);
}
.notification-error {
background: linear-gradient(135deg, #e74c3c, #c0392b);
}
.notification-close {
background: none;
border: none;
color: white;
font-size: 1.2em;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s ease;
}
.notification-close:hover {
background: rgba(255, 255, 255, 0.2);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.loading-spinner {
text-align: center;
padding: 40px 20px;
color: #7f8c8d;
font-size: 1.1em;
}
.loading-medium {
padding: 60px 20px;
font-size: 1.3em;
}
.loading-small {
padding: 20px;
font-size: 1em;
}
.error {
text-align: center;
padding: 40px 20px;
color: #e74c3c;
font-size: 1.1em;
background: rgba(231, 76, 60, 0.1);
border-radius: 10px;
border: 1px solid rgba(231, 76, 60, 0.2);
}
@media (max-width: 768px) {
.app {
padding: 15px;
}
.app-header h1 {
font-size: 2.2em;
}
.app-subtitle {
font-size: 1em;
}
.product-list,
.cart {
padding: 20px;
}
.product-header {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.products {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.product-card {
height: auto;
min-height: 380px;
}
.cart {
position: static;
max-height: none;
}
.cart-header {
flex-direction: column;
align-items: stretch;
}
.cart-item {
grid-template-columns: 50px 1fr;
gap: 10px;
}
.cart-item-controls {
grid-column: 1 / -1;
flex-direction: row;
justify-content: space-between;
margin-top: 10px;
}
.notification {
left: 15px;
right: 15px;
min-width: auto;
}
}
@media (max-width: 480px) {
.app-header h1 {
font-size: 1.8em;
}
.products {
grid-template-columns: 1fr;
}
.product-header h2 {
font-size: 1.5em;
}
.cart-header h2 {
font-size: 1.5em;
}
}
=======================================================================================================
App.test.jsx:
import {act, render, waitFor, screen, fireEvent} from '@testing-library/react'; // импортируем функции для тестирования компонентов react
import {vi, beforeEach, afterEach, describe, it, expect} from 'vitest'; // импортируем функции vitest для написания тестов и моков
import React from 'react'; // импортируем react для использования jsx
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; // импортируем react-query для управления запросами и кэшем
let cartData = []; // создаем массив для хранения данных корзины
let dbCallbacks = []; // создаем массив для хранения колбэков обновления компонентов
function createMockDb() { // определяем функцию для создания мока базы данных
const notifyCallbacks = () => { // создаем функцию для вызова всех колбэков
dbCallbacks.forEach(callback => { // перебираем массив колбэков
if (typeof callback === 'function') callback(); // вызываем каждый колбэк, если он является функцией
});
};
return { // возвращаем объект мока базы данных
version: vi.fn().mockReturnThis(), // мокаем метод version, возвращающий сам объект
stores: vi.fn().mockReturnThis(), // мокаем метод stores, возвращающий сам объект
cart: { // создаем объект для работы с корзиной
toArray: vi.fn(() => Promise.resolve([...cartData])), // мокаем метод toArray, возвращающий копию данных корзины
get: vi.fn(id => Promise.resolve(cartData.find(item => item.id === id) || null)), // мокаем метод get, возвращающий элемент по id
put: vi.fn(item => { // мокаем метод put для добавления или обновления элемента
const idx = cartData.findIndex(i => i.id === item.id); // ищем индекс элемента в корзине
if (idx !== -1) cartData[idx] = {...item}; // обновляем существующий элемент
else cartData.push({...item}); // добавляем новый элемент
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой
return Promise.resolve(); // возвращаем промис
}),
delete: vi.fn(id => { // мокаем метод delete для удаления элемента
cartData = cartData.filter(i => i.id !== id); // удаляем элемент из корзины
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой
return Promise.resolve(); // возвращаем промис
}),
clear: vi.fn(() => { // мокаем метод clear для очистки корзины
cartData = []; // очищаем массив корзины
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой
return Promise.resolve(); // возвращаем промис
}),
},
subscribe: vi.fn(cb => { // мокаем метод subscribe для подписки на изменения
dbCallbacks.push(cb); // добавляем колбэк в массив
return () => { // возвращаем функцию для отписки
dbCallbacks = dbCallbacks.filter(x => x !== cb); // удаляем колбэк из массива
};
}),
};
}
let mockDb = vi.hoisted(() => createMockDb()); // создаем мок базы данных с использованием hoisted для корректной инициализации
vi.mock('dexie', () => ({ // мокаем библиотеку dexie
default: vi.fn().mockImplementation(() => mockDb) // возвращаем замоканную базу данных
}));
const renderWithProviders = () => { // определяем функцию для рендеринга компонента с провайдерами
const queryClient = new QueryClient({ // создаем новый клиент react-query
defaultOptions: { // задаем стандартные настройки
queries: { // настройки для запросов
retry: 0, // отключаем повторные попытки запросов
staleTime: 0, // отключаем время устаревания данных
gcTime: 0, // отключаем время жизни кэша
refetchOnWindowFocus: false, // отключаем повторный запрос при фокусе окна
refetchOnMount: true, // включаем повторный запрос при монтировании
},
mutations: { // настройки для мутаций
retry: 0, // отключаем повторные попытки мутаций
},
},
});
return render( // рендерим компонент
<QueryClientProvider client={queryClient}> // оборачиваем в провайдер react-query
<App/> // рендерим компонент App
</QueryClientProvider>
);
};
beforeEach(() => { // задаем действия перед каждым тестом
vi.resetAllMocks(); // сбрасываем все моки
cartData = []; // очищаем массив корзины
dbCallbacks = []; // очищаем массив колбэков
mockDb = createMockDb(); // создаем новый мок базы данных
globalThis.indexedDB = { // мокаем indexedDB
open: vi.fn(), // мокаем метод open
deleteDatabase: vi.fn(), // мокаем метод deleteDatabase
cmp: vi.fn() // мокаем метод cmp
};
globalThis.fetch = vi.fn().mockResolvedValue({ // мокаем fetch для получения продуктов
ok: true, // задаем успешный статус ответа
json: () => Promise.resolve([ // возвращаем тестовые данные
{
id: 1, // id первого продукта
title: 'тестовая шаурма', // название первого продукта
price: 10.99, // цена первого продукта
image: 'https://example.com/image1.jpg', // изображение первого продукта
category: 'electronics' // категория первого продукта
},
{
id: 2, // id второго продукта
title: 'тестовый хотдог', // название второго продукта
price: 15.50, // цена второго продукта
image: 'https://example.com/image2.jpg', // изображение второго продукта
category: 'clothing' // категория второго продукта
},
]),
});
});
afterEach(() => { // задаем действия после каждого теста
vi.clearAllMocks(); // очищаем все моки
vi.restoreAllMocks(); // восстанавливаем все моки
cartData = []; // очищаем массив корзины
dbCallbacks = []; // очищаем массив колбэков
});
import App from './App'; // импортируем компонент App после настройки моков
describe('Компонент App', () => { // описываем группу тестов для компонента App
it('рендерится без ошибок', async () => { // тестируем рендеринг компонента
await act(async () => { // оборачиваем рендеринг в act для корректного обновления
renderWithProviders(); // рендерим компонент с провайдерами
});
await waitFor(() => { // ждем, пока элемент появится
expect(screen.getByText(/Интернет-магазин ReactExpress/)).toBeInTheDocument(); // проверяем наличие заголовка
}, {timeout: 5000}); // задаем таймаут 5 секунд
});
it('отображает заголовок приложения', async () => { // тестируем отображение заголовка
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
expect(screen.getByText('🛒 Интернет-магазин ReactExpress')).toBeInTheDocument(); // проверяем наличие заголовка с эмодзи
expect(screen.getByText('Качественные товары по доступным ценам')).toBeInTheDocument(); // проверяем наличие подзаголовка
});
it('не отображает кнопку оформления заказа когда корзина пуста', async () => { // тестируем отсутствие кнопки при пустой корзине
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
expect(screen.queryByText('Оформить заказ')).not.toBeInTheDocument(); // проверяем отсутствие кнопки оформления
});
it('отображает список товаров после загрузки', async () => { // тестируем отображение списка товаров
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем отображения количества товаров
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем наличие заголовка с количеством
});
expect(await screen.findByText('тестовая шаурма')).toBeInTheDocument(); // проверяем наличие первого товара
expect(await screen.findByText('тестовый хотдог')).toBeInTheDocument(); // проверяем наличие второго товара
});
it('отображает цены товаров', async () => { // тестируем отображение цен
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
expect(await screen.findByText('$10.99')).toBeInTheDocument(); // проверяем отображение цены первого товара
expect(await screen.findByText('$15.50')).toBeInTheDocument(); // проверяем отображение цены второго товара
});
it('отображает категории товаров', async () => { // тестируем отображение категорий
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
expect(await screen.findByText('electronics')).toBeInTheDocument(); // проверяем отображение категории первого товара
expect(await screen.findByText('clothing')).toBeInTheDocument(); // проверяем отображение категории второго товара
});
it('отображает кнопки добавления в корзину', async () => { // тестируем наличие кнопок добавления
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
const addButtons = await screen.findAllByText('Добавить в корзину'); // находим все кнопки добавления
expect(addButtons).toHaveLength(2); // проверяем, что кнопок две
});
it('работает поиск товаров', async () => { // тестируем функциональность поиска
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
const searchInput = await screen.findByPlaceholderText('Поиск товаров...'); // находим поле ввода поиска
await act(async () => { // оборачиваем изменение поля в act
fireEvent.change(searchInput, {target: {value: 'тестовая шаурма'}}); // вводим запрос в поле поиска
});
await waitFor(() => { // ждем обновления списка товаров
expect(screen.getByText(/Наши товары \(1\)/)).toBeInTheDocument(); // проверяем, что отображается один товар
});
expect(screen.getByText('тестовая шаурма')).toBeInTheDocument(); // проверяем наличие искомого товара
expect(screen.queryByText('тестовый хотдог')).not.toBeInTheDocument(); // проверяем отсутствие другого товара
});
it('показывает сообщение когда поиск не дал результатов', async () => { // тестируем сообщение при пустом поиске
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
const searchInput = await screen.findByPlaceholderText('Поиск товаров...'); // находим поле ввода поиска
await act(async () => { // оборачиваем изменение поля в act
fireEvent.change(searchInput, {target: {value: 'Non-existent Product'}}); // вводим несуществующий запрос
});
await waitFor(() => { // ждем отображения сообщения
expect(screen.getByText(/По запросу "Non-existent Product" ничего не найдено/)).toBeInTheDocument(); // проверяем наличие сообщения
});
const clearButton = screen.getByText('Очистить поиск'); // находим кнопку очистки поиска
await act(async () => { // оборачиваем клик в act
fireEvent.click(clearButton); // кликаем по кнопке очистки
});
await waitFor(() => { // ждем восстановления списка товаров
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение всех товаров
});
});
it('отображает пустую корзину', async () => { // тестируем отображение пустой корзины
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем заголовок пустой корзины
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине
expect(screen.getByText('Добавьте товары из каталога')).toBeInTheDocument(); // проверяем подсказку
});
it('добавляет товар в корзину', async () => { // тестируем добавление товара в корзину
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем загрузки товаров
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение товаров
});
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления
name: /Добавить в корзину/i,
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[0]); // кликаем по первой кнопке
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем обновление корзины
}, {timeout: 2000});
expect(screen.getByRole('heading', {level: 4, name: 'тестовая шаурма'})).toBeInTheDocument(); // проверяем наличие товара в корзине
expect(screen.queryByText('Корзина пуста')).not.toBeInTheDocument(); // проверяем отсутствие сообщения о пустой корзине
});
it('удаляет товар из корзины', async () => { // тестируем удаление товара из корзины
console.log('cartData при входе в тест', cartData); // логируем начальное состояние корзины
cartData = [{ // заполняем корзину тестовым товаром
id: 1,
title: 'тестовая шаурма',
price: 10.99,
image: 'https://example.com/image1.jpg',
quantity: 1
}];
console.log('cartData перед рендером', cartData); // логируем состояние корзины перед рендерингом
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
console.log('cartData после рендера', cartData); // логируем состояние корзины после рендеринга
await waitFor(() => { // ждем отображения корзины
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара
}, {timeout: 2000});
const removeButton = screen.getByTitle('Удалить товар'); // находим кнопку удаления
await act(async () => { // оборачиваем клик в act
fireEvent.click(removeButton); // кликаем по кнопке удаления
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста
}, {timeout: 2000});
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине
});
it('очищает всю корзину', async () => { // тестируем очистку корзины
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем отображения корзины
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста
});
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления
name: /Добавить в корзину/i,
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[0]); // кликаем по первой кнопке
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[1]); // кликаем по второй кнопке
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(2 товаров\)/)).toBeInTheDocument(); // проверяем наличие двух товаров
}, {timeout: 2000});
const clearButton = screen.getByText('Очистить корзину'); // находим кнопку очистки корзины
vi.spyOn(window, 'confirm').mockImplementation(() => true); // мокаем confirm, чтобы возвращал true
await act(async () => { // оборачиваем клик в act
fireEvent.click(clearButton); // кликаем по кнопке очистки
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста
}, {timeout: 2000});
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине
});
it('правильно рассчитывает общую стоимость', async () => { // тестируем расчет общей стоимости
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем отображения корзины
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста
});
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления
name: /Добавить в корзину/i,
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[0]); // кликаем по первой кнопке
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[1]); // кликаем по второй кнопке
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[1]); // кликаем по второй кнопке еще раз
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(3 товаров\)/)).toBeInTheDocument(); // проверяем наличие трех товаров
}, {timeout: 2000});
await waitFor(() => { // ждем отображения итоговой суммы
expect(screen.getByText('Итого: $41.99')).toBeInTheDocument(); // проверяем правильность суммы
});
});
it('показывает уведомление при добавлении товара', async () => { // тестируем уведомление при добавлении
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем загрузки товаров
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение товаров
});
const addButtons = await screen.findAllByText('Добавить в корзину'); // находим кнопки добавления
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[0]); // кликаем по первой кнопке
});
await waitFor(() => { // ждем появления уведомления
expect(screen.getByText(/тестовая шаурма добавлен в корзину/)).toBeInTheDocument(); // проверяем уведомление
});
});
it('отображает кнопку оформления заказа когда корзина не пуста', async () => { // тестируем кнопку оформления при непустой корзине
cartData = [{ // заполняем корзину тестовым товаром
id: 1,
title: 'тестовая шаурма',
price: 10.99,
image: 'https://example.com/image1.jpg',
quantity: 1
}];
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем появления кнопки
expect(screen.getByText('Оформить заказ')).toBeInTheDocument(); // проверяем наличие кнопки оформления
});
});
it('удаляет товар при уменьшении количества до 0', async () => { // тестируем удаление товара при уменьшении количества
cartData = [{ // заполняем корзину тестовым товаром
id: 1,
title: 'тестовая шаурма',
price: 10.99,
image: 'https://example.com/image1.jpg',
quantity: 1
}];
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
await waitFor(() => { // ждем отображения корзины
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара
});
const minusButton = screen.getByRole('button', {name: '-'}); // находим кнопку уменьшения количества
await act(async () => { // оборачиваем клик в act
fireEvent.click(minusButton); // кликаем по кнопке уменьшения
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста
}, {timeout: 2000});
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине
});
it('обновляет количество товара в корзине', async () => { // тестируем обновление количества товара
await act(async () => { // оборачиваем рендеринг в act
renderWithProviders(); // рендерим компонент
});
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления
name: /Добавить в корзину/i,
});
await act(async () => { // оборачиваем клик в act
fireEvent.click(addButtons[0]); // кликаем по первой кнопке
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара
}, {timeout: 2000});
const plusButton = screen.getByRole('button', {name: '+'}); // находим кнопку увеличения количества
await act(async () => { // оборачиваем клик в act
fireEvent.click(plusButton); // кликаем по кнопке увеличения
});
await waitFor(() => { // ждем обновления корзины
expect(screen.getByText(/Корзина \(2 товаров\)/)).toBeInTheDocument(); // проверяем наличие двух товаров
}, {timeout: 2000});
expect(screen.getByText('2')).toBeInTheDocument(); // проверяем отображение количества
});
});
=============================================================================================================================
setupTests.js:
import '@testing-library/jest-dom'
=============================================================================================================================
vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js'
},
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment