Загрузка данных
ПРАКТИЧЕСКАЯ РАБОТА
Тема: Комплексное тестирование и рефакторинг критической бизнес-логики методом «Белого ящика»
---
1. Титульный блок
Учебное заведение _________________________
Кафедра _________________________
Дисциплина Тестирование программного обеспечения
Тема работы Комплексное тестирование и рефакторинг критической бизнес-логики методом «Белого ящика»
Вариант Вариант 1. Модуль проведения транзакции и применения промокодов в корзине
Студент _________________________ (ФИО, группа)
Преподаватель _________________________
---
2. Исходные данные
2.1. Текст задания (Вариант 1)
Функция processPayment(cart, promo) должна:
1. Принимать объект cart (корзину) и объект promo (промокод).
2. Если в корзине нет товаров или она не валидна — выбрасывать Error("Empty cart").
3. Базовая скидка по промокоду не должна быть больше 50% от суммы заказа.
4. Если пользователь VIP (cart.user.isVip === true) — дополнительная скидка 10% на итоговую сумму после применения промокода.
5. Промокоды одноразовые: если promo.isUsed === true — скидка не применяется. После успешного применения promo.isUsed = true.
6. Возвращать итоговую сумму с точностью до 2 знаков после запятой, не изменяя исходные цены товаров в cart.
2.2. Исходный код с ошибками
```javascript
function processPayment(cart, promo) {
let total = 0;
// 1. Некорректная проверка массива товаров
for (let i = 0; i <= cart.items.length; i++) {
total += cart.items[i].price * cart.items[i].count;
}
// 2. Ошибка работы с объектом promo и состояниями
if (promo && promo.isUsed = false) {
if (promo.discountPercent > 50) {
promo.discountPercent = 50;
}
total = total - (total * (promo.discountPercent / 100));
promo.isUsed = true; // Побочный эффект при незавершенной операции
}
// 3. Логическая ошибка в проверке VIP и мутация входных данных
if (cart.user.isVip = true) {
total = total * 0.9;
cart.user.bonusPoints += total * 0.05; // Мутация объекта
}
// 4. Проблема плавающей точки и отрицательных сумм
if (total < 0) return 0;
return total; // Нет округления до 2 знаков
}
```
---
3. Аналитическая часть (Аудит кода)
3.1. Сводная таблица дефектов
№ Дефект Тип Последствие в реальной системе
D1 for (let i = 0; i <= cart.items.length; i++) Off-by-one На последней итерации cart.items[i] = undefined, обращение к .price → TypeError: Cannot read property 'price' of undefined. Любая непустая корзина падает с ошибкой. Даже пустая корзина падает: при length === 0 условие 0 <= 0 истинно.
D2 promo.isUsed = false в условии if Присваивание вместо сравнения Выражение возвращает false, условие всегда ложно → блок промокода никогда не выполняется. Скидки не применяются вообще. Дополнительно: promo.isUsed принудительно сбрасывается в false, что позволяет повторно использовать уже использованный промокод — прямые финансовые потери.
D3 promo.discountPercent = 50 Мутация входного объекта Изменяется исходный объект промокода. При повторном вызове (например, при повторной попытке оплаты) скидка уже «зашита» как 50% — непредсказуемое поведение. Нарушение принципа чистоты функции.
D4 promo.isUsed = true внутри блока Ранний побочный эффект, нарушение атомарности Промокод помечается использованным до завершения всех вычислений. Если последующий код (VIP-блок) выбросит исключение, промокод уже «сгорел», а транзакция не выполнена — некомпенсированная транзакция, клиент теряет промокод.
D5 if (cart.user.isVip = true) Присваивание вместо сравнения Условие всегда истинно, и cart.user.isVip принудительно становится true. Каждый пользователь становится VIP навсегда после первого вызова — массовые необоснованные скидки 10%. Если cart.user = undefined → TypeError.
D6 cart.user.bonusPoints += total * 0.05 Мутация входного объекта, отсутствует в ТЗ Изменяется объект пользователя. Бонусы начисляются от уже скидочной суммы (неясная семантика). Функция становится непредсказуемой, побочные эффекты не задокументированы. При конкурентных вызовах — состояния гонки (Race Condition) при инкременте.
D7 Отсутствие округления Float Precision Issue Возврат 0.30000000000000004 вместо 0.3. В финансовой системе — расхождение в копейках, ошибки в бухгалтерии, претензии клиентов.
D8 Отсутствие проверки пустой/невалидной корзины Нарушение ТЗ Вместо Error("Empty cart") — TypeError (падение). Невозможно отличить бизнес-ошибку от программной.
D9 Отсутствие валидации cart.items Отсутствие guard clause Если cart = null/undefined → TypeError. Если cart.items не массив → TypeError при .length.
D10 Отсутствие валидации полей товара Отсутствие guard clause price/count = undefined, NaN, "abc", null → NaN в total, дальнейшие вычисления «отравлены», функция вернёт NaN.
D11 Нет проверки отрицательных price/count Отсутствие валидации Отрицательная цена или количество уменьшает total, возможна отрицательная итоговая сумма. Блок if (total < 0) return 0 маскирует порчу данных вместо её выявления.
D12 promo.discountPercent может быть нечислом Отсутствие валидации undefined > 50 → false, затем total - total * (undefined / 100) → NaN. Функция возвращает NaN.
D13 Проверка total < 0 после VIP-скидки Логическая ошибка При корректных положительных данных total не может быть отрицательным. Эта проверка бессмысленна, но создаёт иллюзию защиты и скрывает D11.
D14 Порядок побочных эффектов Нарушение атомарности Сначала мутируется promo.isUsed, затем — cart.user.bonusPoints. Если между ними исключение — состояние частично изменено.
3.2. Детализация критических дефектов
D2 — присваивание в if (промокод). Это классическая ошибка, которую ловит правило ESLint no-cond-assign. В данном случае promo.isUsed = false возвращает false, поэтому блок не выполняется. Последствие: все промокоды игнорируются, клиенты не получают скидку. Бизнес теряет лояльность, а маркетинговые акции не работают.
D5 — присваивание в if (VIP). cart.user.isVip = true возвращает true, блок выполняется всегда. Последствие: все пользователи получают 10% скидку, а флаг isVip необратимо изменяется в объекте пользователя. Финансовые потери 10% от оборота.
D4 — ранняя мутация promo.isUsed. Если после блока промокода возникнет исключение (например, при cart.user = undefined на строке VIP-проверки), промокод уже помечен использованным. Это некомпенсированная транзакция: клиент потерял промокод, но заказ не оплачен.
D6 — мутация bonusPoints. Начисление бонусов не предусмотрено ТЗ. Это скрытый побочный эффект, который невозможно протестировать без анализа кода. В асинхронной среде два параллельных вызова processPayment для одного пользователя вызовут состояние гонки при +=.
---
4. Рефакторинг (Исправленный код)
```javascript
/**
* Рассчитывает итоговую сумму заказа с учётом промокода и VIP-статуса.
*
* @param {Object} cart - корзина { items: [{ price, count }], user?: { isVip } }
* @param {Object} [promo] - промокод { discountPercent, isUsed }
* @returns {number} итоговая сумма, округлённая до 2 знаков
* @throws {Error} "Empty cart" | "Invalid cart" | "Invalid item" | "Invalid price or count"
*/
function processPayment(cart, promo) {
// === GUARD CLAUSES: валидация корзины ===
if (!cart || !Array.isArray(cart.items) || cart.items.length === 0) {
throw new Error("Empty cart");
}
// === ВАЛИДАЦИЯ ТОВАРОВ И РАСЧЁТ ПОДЫТОГА ===
// Работаем с локальной переменной — входные объекты не мутируем.
let subtotal = 0;
for (const item of cart.items) {
if (!item || typeof item !== "object") {
throw new Error("Invalid item");
}
const { price, count } = item;
// Number.isFinite отсекает NaN, Infinity, undefined, null, строки.
if (!Number.isFinite(price) || !Number.isFinite(count)) {
throw new Error("Invalid price or count");
}
if (price < 0 || count < 0) {
throw new Error("Invalid price or count");
}
subtotal += price * count;
}
// === ПРИМЕНЕНИЕ ПРОМОКОДА ===
let total = subtotal;
let promoShouldBeMarkedUsed = false;
// Строгая проверка: промокод существует, это объект и он не использован.
if (promo && typeof promo === "object" && promo.isUsed !== true) {
const rawDiscount = Number(promo.discountPercent);
if (Number.isFinite(rawDiscount) && rawDiscount > 0) {
// Ограничение «не более 50%» — вычисляем локально, не мутируя promo.
const cappedDiscount = Math.min(rawDiscount, 50);
total = total - total * (cappedDiscount / 100);
promoShouldBeMarkedUsed = true;
}
}
// === VIP-СКИДКА (после промокода, согласно ТЗ) ===
const isVip = Boolean(cart.user && cart.user.isVip === true);
if (isVip) {
total = total * 0.9;
}
// === ОКРУГЛЕНИЕ ДО 2 ЗНАКОВ (финансовое) ===
// Number.EPSILON компенсирует ошибку представления float.
total = Math.round((total + Number.EPSILON) * 100) / 100;
// === ФИКСАЦИЯ ПОБОЧНОГО ЭФФЕКТА ТОЛЬКО ПОСЛЕ УСПЕХА ===
// Атомарность: промокод «сгорает» только если все вычисления прошли.
if (promoShouldBeMarkedUsed) {
promo.isUsed = true;
}
return total;
}
```
Ключевые изменения
Было Стало Почему
i <= length for...of Устранён off-by-one, читаемость
promo.isUsed = false promo.isUsed !== true Сравнение вместо присваивания
cart.user.isVip = true cart.user.isVip === true Сравнение вместо присваивания
promo.discountPercent = 50 Math.min(rawDiscount, 50) локально Нет мутации входа
promo.isUsed = true внутри блока Флаг + установка в конце Атомарность
cart.user.bonusPoints += ... Удалено Не предусмотрено ТЗ
if (total < 0) return 0 Валидация price/count >= 0 Явная ошибка вместо маскировки
Нет округления Math.round((total + EPSILON) * 100) / 100 Финансовая точность
Нет проверок Guard clauses Явные ошибки вместо TypeError
---
5. Набор тестовых сценариев
5.1. Положительные сценарии (Happy Path)
ID Сценарий Входные данные Ожидаемый результат
TC-01 Обычная корзина, промокод 10%, не VIP cart = {items:[{price:100,count:2}], user:{isVip:false}}, promo = {discountPercent:10, isUsed:false} 180 (200 − 10%), promo.isUsed === true
TC-02 VIP + промокод 10% cart = {items:[{price:100,count:2}], user:{isVip:true}}, promo = {discountPercent:10, isUsed:false} 162 (200 → 180 → −10%), promo.isUsed === true
TC-03 VIP без промокода cart = {items:[{price:100,count:1}], user:{isVip:true}}, promo = null 90
TC-04 Обычный клиент без промокода cart = {items:[{price:100,count:1}], user:{isVip:false}} 100
TC-05 Несколько товаров cart = {items:[{price:50,count:2},{price:25,count:4}], user:{isVip:false}} 200
5.2. Граничные условия (Boundary Conditions)
ID Сценарий Входные данные Ожидаемый результат
TC-06 Скидка ровно 50% promo = {discountPercent:50, isUsed:false}, total=100 50
TC-07 Скидка 70% (кап на 50%) promo = {discountPercent:70, isUsed:false}, total=100 50, promo.discountPercent === 70 (не мутирован)
TC-08 Float: 0.1 + 0.2 cart = {items:[{price:0.1,count:1},{price:0.2,count:1}]} 0.3 (не 0.30000000000000004)
TC-09 Округление 0.005 total даёт x.xx5 Корректное округление до 2 знаков
TC-10 Один товар, count = 1 cart = {items:[{price:99.99,count:1}]} 99.99
TC-11 Большие значения price:1e6, count:100 Корректный расчёт, нет переполнения
TC-12 Скидка 0% promo = {discountPercent:0, isUsed:false} total без изменений, promo.isUsed === false
5.3. Негативные сценарии
ID Сценарий Входные данные Ожидаемый результат
TC-13 Пустая корзина cart = {items:[]} throw Error("Empty cart")
TC-14 cart = null null throw Error("Empty cart")
TC-15 cart.items не массив cart = {items:"abc"} throw Error("Empty cart")
TC-16 Товар = null items:[null] throw Error("Invalid item")
TC-17 price = NaN items:[{price:NaN,count:1}] throw Error("Invalid price or count")
TC-18 price < 0 items:[{price:-10,count:1}] throw Error("Invalid price or count")
TC-19 count < 0 items:[{price:10,count:-1}] throw Error("Invalid price or count")
TC-20 price = "abc" items:[{price:"abc",count:1}] throw Error("Invalid price or count")
TC-21 Промокод уже использован promo = {discountPercent:50, isUsed:true} total без скидки, promo.isUsed === true
TC-22 promo.discountPercent = NaN promo = {discountPercent:NaN, isUsed:false} Скидка не применяется, promo.isUsed === false
TC-23 cart.user отсутствует cart = {items:[{price:100,count:1}]} 100, без VIP-скидки, без ошибки
5.4. Проверка отсутствия побочных эффектов
ID Сценарий Проверка
TC-24 Мутация цен После вызова cart.items[0].price не изменён
TC-25 Мутация isVip После вызова cart.user.isVip не изменён
TC-26 Мутация discountPercent После вызова promo.discountPercent не изменён
TC-27 bonusPoints не начисляется cart.user.bonusPoints не изменён
TC-28 Атомарность промокода При исключении в середине promo.isUsed остаётся false
5.5. Пример теста (Jest)
```javascript
describe("processPayment", () => {
test("TC-01: обычная корзина + промокод 10%", () => {
const cart = { items: [{ price: 100, count: 2 }], user: { isVip: false } };
const promo = { discountPercent: 10, isUsed: false };
expect(processPayment(cart, promo)).toBe(180);
expect(promo.isUsed).toBe(true);
});
test("TC-07: скидка 70% каппится на 50% и не мутирует promo", () => {
const cart = { items: [{ price: 100, count: 1 }], user: { isVip: false } };
const promo = { discountPercent: 70, isUsed: false };
expect(processPayment(cart, promo)).toBe(50);
expect(promo.discountPercent).toBe(70); // не мутирован
});
test("TC-08: float precision", () => {
const cart = { items: [{ price: 0.1, count: 1 }, { price: 0.2, count: 1 }] };
expect(processPayment(cart, null)).toBe(0.3);
});
test("TC-13: пустая корзина", () => {
expect(() => processPayment({ items: [] }, null)).toThrow("Empty cart");
});
test("TC-17: NaN в цене", () => {
expect(() => processPayment({ items: [{ price: NaN, count: 1 }] }, null))
.toThrow("Invalid price or count");
});
test("TC-24/25: отсутствие мутаций", () => {
const cart = { items: [{ price: 100, count: 1 }], user: { isVip: true } };
const promo = { discountPercent: 10, isUsed: false };
processPayment(cart, promo);
expect(cart.items[0].price).toBe(100);
expect(cart.user.isVip).toBe(true);
expect(promo.discountPercent).toBe(10);
expect(cart.user.bonusPoints).toBeUndefined();
});
});
```
---
6. Вывод по работе
6.1. Влияние рефакторинга на надёжность
· Устранены off-by-one и присваивания в if — критические дефекты, приводившие к падению на любой непустой корзине и к полному игнорированию промокодов.
· Введены guard clauses и валидация всех входных данных (Number.isFinite, проверка массива, проверка отрицательных значений) — функция больше не возвращает NaN и не падает с TypeError.
· Обеспечена атомарность: promo.isUsed устанавливается только после успешного завершения всех вычислений. При исключении состояние не меняется.
· Устранены мутации входных объектов (promo.discountPercent, cart.user.isVip, cart.user.bonusPoints, cart.items[i].price) — функция стала почти чистой, с единственным задокументированным побочным эффектом (promo.isUsed), предусмотренным ТЗ.
6.2. Влияние на безопасность
· Финансовые риски устранены: кап 50% работает, VIP-скидка не применяется ко всем подряд, промокод нельзя переиспользовать.
· Некомпенсированные транзакции исключены: промокод не «сгорает» при ошибке.
· Состояния гонки минимизированы: удалён неатомарный += по bonusPoints.
6.3. Влияние на поддерживаемость
· Код читается сверху вниз: guard clauses → валидация → расчёт → округление → фиксация эффекта.
· Функция стала предсказуемой и легко тестируемой методом «белого ящика»: каждая ветка покрыта тестами (28 сценариев).
· Явные сообщения об ошибках ("Empty cart", "Invalid item", "Invalid price or count") упрощают диагностику.
· Удалён недокументированный побочный эффект (bonusPoints), который невозможно было бы поддерживать без анализа кода.
6.4. Ответы на вопросы для самопроверки
1. Чистая функция — функция, результат которой зависит только от входных аргументов и которая не производит наблюдаемых побочных эффектов (не мутирует входы, не пишет в I/O). Побочные эффекты затрудняют тестирование «белым ящиком», так как требуют моков, изоляции состояния и проверки порядка вызовов; тесты становятся хрупкими и зависимыми от контекста.
2. Ранняя мутация в многопоточной среде приводит к состояниям гонки: другой поток видит частично изменённое состояние (например, isBooked = true, но userId ещё не записан). Это вызывает двойные бронирования, потерянные данные, взаимные блокировки. Решение — сначала валидировать и вычислять, затем атомарно коммитить изменения.
3. Опасность = в if — присваивание возвращает значение, которое неявно приводится к boolean. Это маскирует опечатку под логику. Предотвращение: правило ESLint no-cond-assign, eqeqeq, использование строгих сравнений ===/!==, code review, тесты на граничные условия.
4. Финансовые данные нельзя сравнивать через === для float (0.1 + 0.2 !== 0.3). Правильно: работать в минорных единицах (копейках/центах) как с целыми числами, либо округлять через Math.round((x + Number.EPSILON) * 100) / 100, либо использовать библиотеки точной арифметики (decimal.js, big.js). Тесты — с допуском (toBeCloseTo) или на целых значениях.
5. Guard Clauses — паттерн «раннего выхода»: проверки некорректных условий в начале функции с return/throw. Упрощает вложенность if-else, делает основной сценарий «плоским» и читаемым, ускоряет понимание кода и снижает цикломатическую сложность.
---
Итог: проведён полный аудит кода, выявлено 14 дефектов (включая 2 критических присваивания в if, off-by-one, некомпенсированную транзакцию и мутации входов). Выполнен рефакторинг с guard clauses, валидацией, атомарностью и финансовым округлением. Разработано 28 тестовых сценариев, покрывающих happy path, граничные условия, негативные сценарии и проверку отсутствия побочных эффектов. Модуль стал надёжным, безопасным и поддерживаемым.