TL;DR: Тестирование белого ящика исследует внутреннюю структуру кода через метрики покрытия: операторов, ветвей, путей и условий. Требует знания программирования и доступа к исходникам. Лучше всего подходит для юнит-тестов, тестирования безопасности и валидации алгоритмов. Цель: 80%+ покрытие операторов, 70%+ покрытие ветвей.

Тестирование белого ящика — это техника верификации кода, при которой тестировщики и разработчики изучают внутреннюю структуру, логику и алгоритмы приложения для проектирования полноценных тест-кейсов. В отличие от тестирования чёрного ящика, которое фокусируется на входах и выходах, white box testing — или структурное тестирование — требует доступа к исходному коду и знания программирования. Согласно отчёту NIST 2023 года, 64% уязвимостей, обнаруженных в продакшене, могли бы быть найдены раньше при тщательном структурном тестировании на этапе разработки. По данным Google Site Reliability Engineering, команды с покрытием ветвей более 70% сталкиваются с 40% меньше инцидентов в продакшене, связанных с логическими ошибками. Основные техники: покрытие операторов, покрытие ветвей, покрытие путей и анализ потока данных — каждая выявляет разные категории дефектов.

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

Тестирование белого ящика анализирует исходный код для проектирования тест-кейсов на конкретные пути выполнения. Требует доступа к коду и навыков программирования.

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

“Тестирование белого ящика — это не просто проценты покрытия. Это поиск скрытых путей в логике, которые ни один пользователь не догадается протестировать, но которые способны обрушить продакшн в самый неподходящий момент.” — Юрий Кан, Senior QA Lead

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

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

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

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

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

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

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%)

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

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

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

Ограничения

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

Заключение

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

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

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

Официальные ресурсы

Структурное тестирование ловит дефекты рано: отчёт NIST 2023 по безопасности ПО показывает, что 64% уязвимостей из продакшена можно было найти раньше при тщательном структурном тестировании.

Согласно практикам Google Site Reliability Engineering, команды с покрытием ветвей 70%+ сталкиваются с 40% меньше инцидентов в продакшене из-за логических ошибок.

FAQ

Что такое тестирование белого ящика?

White box testing исследует внутреннюю структуру кода и логику. Иначе — структурное тестирование: требует доступа к коду и знания программирования.

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

В чём разница между белым и чёрным ящиком?

Белый ящик: внутренний код, доступ к исходникам, ориентация на покрытие. Чёрный ящик: входы/выходы, без кода, ориентация на поведение пользователя.

Белый ящик тестирует внутреннюю структуру кода и требует доступа к исходникам. Чёрный ящик рассматривает систему как непрозрачный блок, фокусируясь на входах/выходах. Белый ящик ориентирован на разработчиков и покрытие; чёрный ящик — на поведение с точки зрения пользователя.

Какие метрики покрытия важнее всего?

Покрытие операторов (80%+), покрытие ветвей (70%+), покрытие путей и покрытие условий — четыре ключевые метрики white box тестирования.

Покрытие операторов (цель 80%+) — исполненные строки. Покрытие ветвей (цель 70%+) — все исходы решений. Покрытие путей — все маршруты выполнения. Покрытие условий — каждое булево подвыражение в обоих значениях.

Когда использовать тестирование белого ящика?

Применяй для: юнит-тестирования функций, тестирования безопасности для поиска уязвимостей, валидации критичных алгоритмов, профилирования производительности и верификации бизнес-логики при код-ревью.

See Also