Тестирование белого ящика — это подход к тестированию, при котором тестировщики исследуют внутреннюю структуру, дизайн и реализацию кода. В отличие от тестирования чёрного ящика, которое фокусируется на входных и выходных данных, тестирование белого ящика проверяет внутреннюю логику, пути кода и алгоритмы. Его также называют структурным тестированием, тестированием прозрачного ящика или тестированием стеклянного ящика.

Что такое Тестирование Белого Ящика?

Тестирование белого ящика включает анализ исходного кода для разработки тестовых случаев, которые выполняют определённые пути кода, условия и логику. Тестировщикам нужны знания программирования и доступ к исходному коду для эффективного выполнения тестирования белого ящика.

Ключевые Характеристики

  • Видимость кода: Полный доступ к исходному коду и внутренней структуре
  • Структурный фокус: Тестирует внутреннюю логику, алгоритмы и пути кода
  • Ориентация на разработчиков: Часто выполняется разработчиками или техническими тестировщиками
  • Управляемость покрытием: Нацелен на высокие метрики покрытия кода
  • Раннее обнаружение дефектов: Находит баги на этапе разработки

Когда Использовать Тестирование Белого Ящика

Тестирование белого ящика идеально для:

  • Модульного тестирования: Тестирование отдельных функций и методов
  • Интеграционного тестирования: Проверка взаимодействий компонентов
  • Тестирования безопасности: Поиск уязвимостей в логике кода
  • Оптимизации кода: Выявление узких мест производительности
  • Валидации алгоритмов: Обеспечение правильности вычислений

Метрики Покрытия Кода

1. Покрытие Операторов

Покрытие операторов измеряет процент исполняемых операторов, выполненных тестами. Каждая строка кода должна выполниться хотя бы один раз.

Формула: Покрытие Операторов = (Выполненные Операторы / Всего Операторов) × 100%

Пример:

def calculate_discount(price, is_member, purchase_count):
    """Рассчитать скидку на основе членства и истории покупок"""
    discount = 0  # Оператор 1

    if is_member:  # Оператор 2
        discount = 10  # Оператор 3

    if purchase_count > 5:  # Оператор 4
        discount += 5  # Оператор 5

    return discount  # Оператор 6

# Тестовый случай 1: Не член, мало покупок
def test_no_discount():
    result = calculate_discount(100, False, 2)
    assert result == 0
    # Покрытие: Операторы 1, 2, 4, 6 = 4/6 = 67%

# Тестовый случай 2: Член с многими покупками
def test_full_discount():
    result = calculate_discount(100, True, 10)
    assert result == 15
    # Покрытие: Все операторы = 6/6 = 100%

Ограничения: 100% покрытие операторов не гарантирует, что все сценарии протестированы—только то, что все строки выполнились.

2. Покрытие Ветвей (Покрытие Решений)

Покрытие ветвей обеспечивает, что каждая точка принятия решения (if/else, switch, циклы) оценивается как истина и ложь. Это сильнее, чем покрытие операторов.

Формула: Покрытие Ветвей = (Выполненные Ветви / Всего Ветвей) × 100%

Пример:

def validate_password(password):
    """Валидировать надёжность пароля"""
    if len(password) < 8:  # Решение 1
        return "Слишком короткий"

    if not any(c.isupper() for c in password):  # Решение 2
        return "Нужны заглавные буквы"

    if not any(c.isdigit() for c in password):  # Решение 3
        return "Нужна цифра"

    return "Валидный"

# Ветви:
# Решение 1: Истина, Ложь
# Решение 2: Истина, Ложь
# Решение 3: Истина, Ложь
# Всего: 6 ветвей

def test_branch_coverage():
    # Тест 1: Покрывает Р1=Истина
    assert validate_password("короткий") == "Слишком короткий"

    # Тест 2: Покрывает Р1=Ложь, Р2=Истина
    assert validate_password("строчные123") == "Нужны заглавные буквы"

    # Тест 3: Покрывает Р1=Ложь, Р2=Ложь, Р3=Истина
    assert validate_password("БезЦифр") == "Нужна цифра"

    # Тест 4: Покрывает Р1=Ложь, Р2=Ложь, Р3=Ложь
    assert validate_password("Валидный123") == "Валидный"

    # Покрытие Ветвей: 6/6 = 100%

3. Покрытие Путей

Покрытие путей тестирует все возможные пути через код. Для кода с множественными решениями количество путей растёт экспоненциально.

Пример:

def process_order(item_count, is_premium, in_stock):
    """Обработать заказ с множественными условиями"""
    message = ""

    if item_count > 0:  # Решение A
        if in_stock:  # Решение B
            message = "Обработка"
            if is_premium:  # Решение C
                message += " с приоритетом"
        else:
            message = "Нет в наличии"
    else:
        message = "Неверное количество"

    return message

# Возможные пути:
# Путь 1: A=Ложь → "Неверное количество"
# Путь 2: A=Истина, B=Ложь → "Нет в наличии"
# Путь 3: A=Истина, B=Истина, C=Ложь → "Обработка"
# Путь 4: A=Истина, B=Истина, C=Истина → "Обработка с приоритетом"

def test_all_paths():
    # Путь 1
    assert process_order(0, False, True) == "Неверное количество"

    # Путь 2
    assert process_order(5, False, False) == "Нет в наличии"

    # Путь 3
    assert process_order(5, False, True) == "Обработка"

    # Путь 4
    assert process_order(5, True, True) == "Обработка с приоритетом"

    # Покрытие Путей: 4/4 = 100%

Вызов: Для n независимых решений существует 2^n возможных путей. Покрытие путей становится непрактичным для сложного кода.

4. Покрытие Условий

Покрытие условий обеспечивает, что каждое булево подвыражение оценивается как истина и ложь независимо.

Пример:

def can_access(is_authenticated, has_permission, is_active):
    """Проверить, может ли пользователь получить доступ к ресурсу"""
    # Составное условие с 3 булевыми подвыражениями
    if is_authenticated and has_permission and is_active:
        return "Доступ предоставлен"
    return "Доступ запрещён"

# Для 100% покрытия условий каждая переменная должна быть Истиной и Ложной:
def test_condition_coverage():
    # is_authenticated: Истина
    can_access(True, True, True)

    # is_authenticated: Ложь
    can_access(False, True, True)

    # has_permission: Истина (уже покрыто выше)
    # has_permission: Ложь
    can_access(True, False, True)

    # is_active: Истина (уже покрыто)
    # is_active: Ложь
    can_access(True, True, False)

    # Покрытие Условий: 100%

5. Покрытие Множественных Условий (ПМУ)

Покрытие множественных условий тестирует все комбинации булевых подвыражений.

Пример:

def authorize_action(is_admin, is_owner):
    """Авторизовать действие на основе роли и владения"""
    if is_admin or is_owner:
        return True
    return False

# Таблица истинности для 'is_admin or is_owner':
# is_admin | is_owner | Результат
#    И     |    И     |     И
#    И     |    Л     |     И
#    Л     |    И     |     И
#    Л     |    Л     |     Л

def test_mcc():
    assert authorize_action(True, True) == True    # И, И
    assert authorize_action(True, False) == True   # И, Л
    assert authorize_action(False, True) == True   # Л, И
    assert authorize_action(False, False) == False # Л, Л

    # ПМУ: 4/4 = 100%

Техники Тестирования Белого Ящика

1. Тестирование Потока Управления

Тестирование потока управления визуализирует код как граф, где узлы — это операторы, а рёбра — это пути потока управления.

Пример: Граф потока для валидации входа в систему

def validate_login(username, password):
    # Узел 1: Вход
    if not username:  # Узел 2
        return "Требуется имя пользователя"  # Узел 3

    if len(password) < 8:  # Узел 4
        return "Пароль слишком короткий"  # Узел 5

    user = find_user(username)  # Узел 6

    if user and check_password(user, password):  # Узел 7
        return "Успех"  # Узел 8

    return "Неверные учётные данные"  # Узел 9

# Граф потока:
#     1
#     ↓
#     2 → 3 (возврат)
#     ↓
#     4 → 5 (возврат)
#     ↓
#     6
#     ↓
#     7 → 8 (возврат)
#     ↓
#     9 (возврат)

# Цикломатическая Сложность = Р - У + 2С
# Р=рёбра, У=узлы, С=связанные компоненты
# Сложность = 3 (3 точки принятия решений + 1)

2. Тестирование Потока Данных

Тестирование потока данных отслеживает определения и использования переменных для обеспечения правильной обработки данных.

Пример:

def calculate_total(items):
    """Рассчитать общую цену с логикой скидки"""
    total = 0  # Определение 'total'

    for item in items:
        total += item.price  # Использование 'total', затем переопределение

    discount = 0  # Определение 'discount'

    if total > 100:  # Использование 'total'
        discount = total * 0.1  # Переопределение 'discount'

    final = total - discount  # Использование 'total' и 'discount'
    return final

# Аномалии потока данных для тестирования:
# - Определена, но никогда не использована (dd)
# - Использована, но никогда не определена (ur)
# - Определена дважды без использования между ними (dd)

def test_data_flow():
    # Тестировать жизненный цикл переменных
    items = [Item(50), Item(60)]  # total=110, discount=11
    assert calculate_total(items) == 99

    items = [Item(30), Item(40)]  # total=70, discount=0
    assert calculate_total(items) == 70

3. Тестирование Циклов

Тестирование циклов проверяет поведение циклов на границах и во время выполнения.

Стратегии тестирования циклов:

def process_batch(items, max_retries=3):
    """Обработать элементы с логикой повторных попыток"""
    processed = []

    for item in items:  # Цикл 1
        retry_count = 0

        while retry_count < max_retries:  # Цикл 2 (вложенный)
            if process_item(item):
                processed.append(item)
                break
            retry_count += 1
        else:
            # Выполняется, если цикл завершается без break
            log_failure(item)

    return processed

def test_loops():
    # Простые тесты циклов:
    # 1. Пропустить цикл (0 итераций)
    assert len(process_batch([])) == 0

    # 2. Одна итерация
    assert len(process_batch([Item(1)])) == 1

    # 3. Две итерации
    assert len(process_batch([Item(1), Item(2)])) == 2

    # 4. Максимум итераций
    items = [Item(i) for i in range(100)]
    assert len(process_batch(items)) <= 100

    # Тесты вложенных циклов:
    # 5. Внутренний цикл выполняется максимальное количество раз
    failing_item = FailingItem()
    result = process_batch([failing_item])
    # Проверить, что retry_count достиг max_retries

4. Мутационное Тестирование

Мутационное тестирование вносит небольшие изменения кода (мутации) для проверки, что тесты могут их обнаружить.

Пример:

# Оригинальный код
def is_eligible(age, has_license):
    return age >= 18 and has_license

# Мутация 1: Изменить >= на >
def is_eligible_mutant1(age, has_license):
    return age > 18 and has_license  # Мутант

# Мутация 2: Изменить 'and' на 'or'
def is_eligible_mutant2(age, has_license):
    return age >= 18 or has_license  # Мутант

# Тесты должны убивать мутации
def test_eligibility():
    # Этот тест убивает Мутацию 1 (age=18 должно пройти, но не проходит в мутанте)
    assert is_eligible(18, True) == True

    # Этот тест убивает Мутацию 2 (должно не пройти без лицензии)
    assert is_eligible(20, False) == False

    # Оценка Мутаций = Убитые Мутации / Всего Мутаций
    # Оценка = 2/2 = 100%

Инструменты Тестирования Белого Ящика

Инструменты Статического Анализа

# Пример: Использование pylint для статического анализа
# Запуск: pylint my_module.py

def calculate_interest(principal, rate, time):
    """Рассчитать простой процент"""
    result = principal * rate * time / 100
    return result

# Pylint проверяет:
# - Неиспользуемые переменные
# - Неопределённые переменные
# - Несоответствия типов
# - Code smells
# - Метрики сложности

Инструменты Покрытия Кода

# Использование pytest-cov для отчёта о покрытии
# Запуск: pytest --cov=myapp --cov-report=html

# пример отчёта о покрытии:
"""
Name                    Stmts   Miss  Cover
-------------------------------------------
myapp/auth.py              45      2    96%
myapp/orders.py            67      8    88%
myapp/payment.py           34      0   100%
-------------------------------------------
TOTAL                     146     10    93%
"""

# Конфигурация .coveragerc
"""
[run]
source = myapp
omit = */tests/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
"""

Инструменты Профилирования

import cProfile
import pstats

def analyze_performance():
    """Профилировать выполнение кода"""
    profiler = cProfile.Profile()
    profiler.enable()

    # Код для профилирования
    result = expensive_operation()

    profiler.disable()
    stats = pstats.Stats(profiler)
    stats.sort_stats('cumulative')
    stats.print_stats()

# Вывод показывает:
# - Вызовы функций
# - Время на функцию
# - Счётчики вызовов
# - Горячие точки для оптимизации

Лучшие Практики Тестирования Белого Ящика

1. Нацеливаться на Высокое Покрытие, Не 100%

# Фокусироваться на критических путях, а не на полном покрытии
class PaymentProcessor:
    def process_payment(self, amount, method):
        """Критический путь - должен иметь 100% покрытие"""
        if amount <= 0:
            raise ValueError("Недопустимая сумма")

        if method == "credit_card":
            return self._process_credit_card(amount)
        elif method == "paypal":
            return self._process_paypal(amount)
        else:
            raise ValueError("Неизвестный метод")

    def format_receipt(self, transaction):
        """Низкий риск - 80% покрытие приемлемо"""
        # Менее критическая логика форматирования
        pass

# Приоритизировать покрытие для:
# - Обработка платежей: 100%
# - Функции безопасности: 100%
# - Бизнес-логика: 95%+
# - Форматирование UI: 70-80%

2. Тестировать Крайние Случаи и Границы

def get_age_category(age):
    """Категоризировать возрастные группы"""
    if age < 0:
        raise ValueError("Возраст не может быть отрицательным")
    elif age < 13:
        return "ребёнок"
    elif age < 20:
        return "подросток"
    elif age < 65:
        return "взрослый"
    else:
        return "пожилой"

def test_edge_cases():
    # Неверный ввод
    with pytest.raises(ValueError):
        get_age_category(-1)

    # Границы
    assert get_age_category(0) == "ребёнок"
    assert get_age_category(12) == "ребёнок"
    assert get_age_category(13) == "подросток"
    assert get_age_category(19) == "подросток"
    assert get_age_category(20) == "взрослый"
    assert get_age_category(64) == "взрослый"
    assert get_age_category(65) == "пожилой"
    assert get_age_category(100) == "пожилой"

3. Использовать Мокирование для Зависимостей

from unittest.mock import Mock, patch

class UserService:
    def __init__(self, db, email_service):
        self.db = db
        self.email_service = email_service

    def register_user(self, email, password):
        """Зарегистрировать нового пользователя"""
        if self.db.user_exists(email):
            return False

        user = self.db.create_user(email, password)
        self.email_service.send_welcome(user)
        return True

def test_registration_with_mocks():
    # Мокировать зависимости
    mock_db = Mock()
    mock_email = Mock()

    # Настроить поведение мока
    mock_db.user_exists.return_value = False
    mock_db.create_user.return_value = {"id": 1, "email": "test@example.com"}

    # Тест
    service = UserService(mock_db, mock_email)
    result = service.register_user("test@example.com", "password123")

    # Проверить
    assert result == True
    mock_db.user_exists.assert_called_once_with("test@example.com")
    mock_db.create_user.assert_called_once()
    mock_email.send_welcome.assert_called_once()

4. Тестировать Обработку Ошибок

def divide_numbers(a, b):
    """Разделить два числа с обработкой ошибок"""
    try:
        result = a / b
        return {"success": True, "result": result}
    except ZeroDivisionError:
        return {"success": False, "error": "Деление на ноль"}
    except TypeError:
        return {"success": False, "error": "Неверные типы"}

def test_error_handling():
    # Счастливый путь
    result = divide_numbers(10, 2)
    assert result["success"] == True
    assert result["result"] == 5

    # Путь ZeroDivisionError
    result = divide_numbers(10, 0)
    assert result["success"] == False
    assert "ноль" in result["error"]

    # Путь TypeError
    result = divide_numbers("10", "2")
    assert result["success"] == False
    assert "типы" in result["error"]

Реальный Пример Тестирования Белого Ящика

Тестирование Системы Корзины Покупок

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.discount_code = None

    def add_item(self, product, quantity):
        """Добавить товар в корзину"""
        if quantity <= 0:
            raise ValueError("Количество должно быть положительным")

        # Проверить, существует ли товар уже
        for item in self.items:
            if item['product'].id == product.id:
                item['quantity'] += quantity
                return

        self.items.append({
            'product': product,
            'quantity': quantity
        })

    def calculate_total(self):
        """Рассчитать общую сумму корзины со скидками"""
        subtotal = sum(
            item['product'].price * item['quantity']
            for item in self.items
        )

        discount = 0
        if self.discount_code:
            if self.discount_code.is_valid():
                if self.discount_code.type == "percentage":
                    discount = subtotal * (self.discount_code.value / 100)
                elif self.discount_code.type == "fixed":
                    discount = min(self.discount_code.value, subtotal)

        return max(subtotal - discount, 0)

# Всесторонние тесты белого ящика
class TestShoppingCart:
    def test_add_item_new_product(self):
        """Тест добавления нового товара (путь: товара нет в корзине)"""
        cart = ShoppingCart()
        product = Product(id=1, price=10)

        cart.add_item(product, 2)

        assert len(cart.items) == 1
        assert cart.items[0]['quantity'] == 2

    def test_add_item_existing_product(self):
        """Тест добавления существующего товара (путь: товар в корзине)"""
        cart = ShoppingCart()
        product = Product(id=1, price=10)

        cart.add_item(product, 2)
        cart.add_item(product, 3)

        assert len(cart.items) == 1
        assert cart.items[0]['quantity'] == 5

    def test_add_item_invalid_quantity(self):
        """Тест обработки ошибок для неверного количества"""
        cart = ShoppingCart()
        product = Product(id=1, price=10)

        with pytest.raises(ValueError):
            cart.add_item(product, 0)

        with pytest.raises(ValueError):
            cart.add_item(product, -1)

    def test_calculate_total_no_discount(self):
        """Тест расчёта общей суммы без скидки"""
        cart = ShoppingCart()
        cart.add_item(Product(id=1, price=10), 2)
        cart.add_item(Product(id=2, price=15), 1)

        assert cart.calculate_total() == 35

    def test_calculate_total_percentage_discount(self):
        """Тест пути процентной скидки"""
        cart = ShoppingCart()
        cart.add_item(Product(id=1, price=100), 1)
        cart.discount_code = DiscountCode(type="percentage", value=10, valid=True)

        assert cart.calculate_total() == 90

    def test_calculate_total_fixed_discount(self):
        """Тест пути фиксированной скидки"""
        cart = ShoppingCart()
        cart.add_item(Product(id=1, price=100), 1)
        cart.discount_code = DiscountCode(type="fixed", value=20, valid=True)

        assert cart.calculate_total() == 80

    def test_calculate_total_fixed_discount_exceeds_subtotal(self):
        """Тест, что фиксированная скидка не уходит в минус"""
        cart = ShoppingCart()
        cart.add_item(Product(id=1, price=10), 1)
        cart.discount_code = DiscountCode(type="fixed", value=50, valid=True)

        assert cart.calculate_total() == 0

    def test_calculate_total_invalid_discount_code(self):
        """Тест пути невалидного кода скидки"""
        cart = ShoppingCart()
        cart.add_item(Product(id=1, price=100), 1)
        cart.discount_code = DiscountCode(type="percentage", value=10, valid=False)

        assert cart.calculate_total() == 100

Анализ Отчёта о Покрытии

# Запустить покрытие
$ pytest --cov=shopping_cart --cov-report=term-missing

Name                Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------
shopping_cart.py       45      0     18      0   100%
---------------------------------------------------------------
TOTAL                  45      0     18      0   100%

# Детали покрытия ветвей:
# - add_item: 4/4 ветви (100%)
# - calculate_total: 14/14 ветвей (100%)

Преимущества и Ограничения

Преимущества

  • Раннее обнаружение багов: Находит дефекты во время разработки
  • Оптимизация кода: Выявляет неэффективности и мёртвый код
  • Полное покрытие: Может систематически тестировать все пути кода
  • Валидация алгоритмов: Проверяет сложные вычисления
  • Безопасность: Выявляет уязвимости в логике

Ограничения

  • Требуется доступ к коду: Не подходит для сторонних систем
  • Знание программирования: Тестировщикам нужны технические навыки
  • Трудоёмкость: Тщательное тестирование требует значительных усилий
  • Накладные расходы на обслуживание: Тесты ломаются при изменении кода
  • Не валидирует требования: Может пропустить функциональные пробелы

Заключение

Тестирование белого ящика обеспечивает глубокое понимание качества кода путём исследования внутренних структур и логики. Через техники, такие как покрытие операторов, покрытие ветвей, покрытие путей и анализ потока данных, вы можете систематически проверять, что код ведёт себя правильно при всех условиях.

Ключ к эффективному тестированию белого ящика — баланс между целями покрытия и практическими ограничениями. Нацеливайтесь на высокое покрытие критических путей, используйте соответствующие инструменты для измерения и отслеживания покрытия, и комбинируйте техники белого ящика с тестированием чёрного ящика для всестороннего обеспечения качества.

Независимо от того, пишете ли вы модульные тесты для отдельных функций или анализируете сложные алгоритмы, тестирование белого ящика обеспечивает надёжность, эффективность и поддерживаемость вашего кода.