Загрузка данных


ПРАКТИЧЕСКАЯ РАБОТА

Тема: Комплексное тестирование и рефакторинг критической бизнес-логики методом «Белого ящика»

---

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, граничные условия, негативные сценарии и проверку отсутствия побочных эффектов. Модуль стал надёжным, безопасным и поддерживаемым.