Ян ван Эйк. Портрет четы Арнольфини, 1434
Ян ван Эйк. Портрет четы Арнольфини, 1434

Часть 2 из 3. [Первая часть - постановка проблемы]

Меланхолия тестировщика: почему метрики врут (Часть 1)
Альбрехт Дюрер, «Меланхолия I», 1514 Крылатый гений сидит среди инструментов. Циркуль, весы, молоток...
habr.com

В 1434 году Ян ван Эйк написал "Портрет четы Арнольфини". Искусствоведы изучают эту картину почти шесть веков и до сих пор находят новые детали.

Зеркало на стене отражает всю комнату и двух человек у двери, которых мы не видим на переднем плане. Каждая ворсинка на собачке прописана отдельно. Одна горящая свеча в люстре. Апельсины на подоконнике. Деревянные сандалии на полу. Подпись "Ян ван Эйк был здесь" - как автограф свидетеля.

Ван Эйк видел то, что другие пропускали. И фиксировал каждую деталь.

Наша задача - чтобы тесты проверяли с такой же тщательностью. Не просто "ответ пришёл", а каждое поле, каждый уровень вложенности, каждый граничный случай.

В первой части мы разобрали проблему: традиционные метрики не отвечают на вопрос "хорошие ли у нас тесты". Теперь покажем, как это измерить.

Важное уточнение о границах применимости. На текущем этапе развития EVA работает с API-тестами, где есть структурированный ответ (JSON/XML) и можно формализовать глубину проверок. Для GUI или консольных утилит критерии качества тестов будут другими. Мы осознаём это ограничение и сознательно сузили scope до той области, где проблема "тест есть, а толку нет" стоит особенно остро.

Ещё одно важное замечание перед началом. EVA состоит из нескольких метрик, и вам не обязательно внедрять все сразу. Можно начать с одной-двух, которые решают вашу самую острую проблему, и постепенно добавлять остальные. В конце статьи мы дадим рекомендации по поэтапному внедрению.


Почему EVA и что это значит.

EVA расшифровывается просто как Evaluation - оценка. Оценка качества тестов, а не их количества.

Но сама идея EVA - это ещё и про эволюцию подхода к метрикам тестирования. Три поколения, каждое со своим главным вопросом. Внимательный читатель возразит: "Эволюция это EVO". Все так, но нам больше нравится EVA.

Первое поколение метрик отвечало на вопрос "сколько". Сколько тестов написано, сколько проходит, сколько падает. На заре автоматизации сам факт наличия автотестов уже был достижением. Менеджеры смотрели на цифру 500 тестов и успокаивались - тестирование есть, всё под контролем.

Второе поколение добавило вопрос "где". Появились метрики покрытия кода: какой процент строк выполняется, какие ветвл��ния затронуты, какие модули остались без внимания. Шаг вперёд - теперь мы видели не просто количество, а распределение тестов по кодовой базе. Но и тут ловушка: можно достичь 80% покрытия тестами, которые ничего толком не проверяют.

Третье поколение - к которому относится EVA - задаёт вопрос "насколько хорошо". Не сколько тестов и не где они работают, а что именно проверяют и способны ли поймать реальные баги. Тест, который вызывает метод и смотрит только что ответ не null, формально существует и формально покрывает код. Но практической пользы мало - большинство дефектов он пропустит.

Эволюция отражает взросление индустрии. Сначала важно было просто начать автоматизировать. Потом - понять, все ли критичные части системы затронуты. Теперь - убедиться, что автоматизация реально работает как страховочная сетка, а не как декорация.

EVA не отменяет предыдущие поколения метрик. Количество тестов имеет значение: ноль хуже, чем сто. Покрытие тоже важно: непокрытый код - слепая зона. Но эти метрики становятся необходимым, а не достаточным условием. EVA добавляет следующий слой: из ста тестов, покрывающих 80% кода, сколько реально способны поймать баг?


Общая архитектура: как устроена оценка

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

Общая архитектура
Общая архитектура

Логика движения сверху вниз: сначала отсекаем явно нерабочее, потом измеряем качество, потом корректируем на плохие практики.

Веса метрик (30%, 25% и так далее) отражают наше понимание важности каждого аспекта. Вы можете адаптировать их под свой контекст. Если для вас критичнее негативные сценарии, чем структурное качество - поменяйте веса.

Gates: входные фильтры

Gates - это бинарные проверки на входе. Они не дают баллов, но могут обнулить или уменьшить итоговый результат. Их задача отсечь тесты, которые вообще не работают.

Compilation Gate: а код-то рабочий?

Первый и самый простой вопрос: код компилируется без ошибок?

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

Что проверяем:

Для Java прогоняем через javac или java-parser. Для Python через py_compile или просто пытаемся импортировать модуль. Ищем синтаксические ошибки, битые импорты, несовместимость типов.

Типичные проблемы, которые ловит этот gate:

Незакрытые скобки - public void test( { частая ошибка при ручном редактировании или неудачной генерации. Пропущенный тип возврата public testMethod() забыли void. Несуществующие импорты import com.nonexistent.Fake; скопировали код из другого проекта, а зависимости нет. Несовместимость типов int x = getString(); особенно часто при рефакторинге. Неправильные отступы в Python критично, потому что Python на них завязан синтаксически.

Результат проверки:

Если компиляция прошла двигаемся дальше. Если нет - итоговый балл равен нулю, дальнейшая оценка не имеет смысла. Это жёстко, но справедливо: код, который не компилируется, не может быть хорошим тестом по определению.

Нужен ли вам этот gate?

Если вы пишете тесты руками и у вас настроена CI/CD с проверкой сборки скорее всего, некомпилирующийся код до репозитория не доходит. Тогда этот gate можно пропустить. Но если вы оцениваете сгенерированные тесты или код из внешних источников gate обязателен.

Execution Gate: тест хотя бы запускается?

Второй вопрос: тест выполняется без runtime-ошибок?

Код может компилироваться, но падать при запуске. NullPointerException, ClassCastException, NameError всё это говорит о проблемах в самом тесте, а не в тестируемом коде.

Что проверяем:

Отсутствие исключений при выполнении. Детерминированность повторные запуски дают тот же результат (тест не флакует сам по себе).

Результат проверки:

Если тест проходит двигаемся дальше. Если падает итоговый балл умножается на 0.5. Это мягче, чем compilation gate, и вот почему: падающий тест хотя бы показывает, что что-то не так. Он может быть полезен как сигнал. Полностью обнулять его несправедливо.

Нужен ли вам этот gate?

Этот gate требует реального запуска тестов, что не всегда возможно (нужна инфраструктура, тестовые данные, доступ к сервисам). Если у вас нет возможности запускать тесты в рамках оценки пропустите этот gate и сосредоточьтесь на статическом анализе.

Oracle Strength: насколько глубоко тест проверяет ответ

Вес в итоговой оценке: 30%

Это главная метрика EVA и, на наш взгляд, самая важная для понимания качества теста.

Что такое Oracle и почему это важно

В терминологии тестирования Oracle - это механизм, который определяет, прошёл тест или упал. Проще говоря, это те проверки (assertions), которые вы пишете в тесте.

Проблема в том, что Oracle может быть сильным или слабым. Слабый Oracle пропускает баги. Сильный ловит.

Представьте: вы тестируете эндпоинт получения пользователя. Вот два варианта теста:

# Вариант А: слабый Oracle
response = requests.get("/users/123")
assert response.status_code == 200

# Вариант Б: сильный Oracle  
response = requests.get("/users/123")
assert response.status_code == 200
data = response.json()
assert data["id"] == 123
assert data["email"] == "user@example.com"
assert data["status"] == "active"
assert data["created_at"] is not None
assert re.match(r'\d{4}-\d{2}-\d{2}', data["created_at"])

Оба теста "зелёные" если сервер вернул 200. Но представьте, что разработчик случайно сломал маппинг и теперь вместо email возвращается null. Вариант А этого не заметит. Вариант Б поймает.

Oracle Strength измеряет, насколько ваш тест способен ловить такие проблемы.

Уровни глубины проверок (L0-L6)

Мы выделили 7 уровней того, что проверяет тест. Каждый следующий уровень включает предыдущие и добавляет новое измерение проверки.

L0 - Проверок нет (0 баллов)

Тест вызывает метод и ничего не проверяет. Формально тест существует, фактически пользы ноль.

# L0: бесполезный тест
def test_get_user():
    requests.get("/users/123")
    # и всё, никаких assert
// L0: бесполезный тест
@Test
void testGetUser() {
    given().get("/users/123");
    // и всё, никаких проверок
}

Зачем такие тесты вообще существуют? Обычно это заглушки "напишу проверки потом" или результат неудачной генерации. Иногда тесты, которые просто проверяют что эндп��инт не падает с 500-й ошибкой, но даже для этого нужен хотя бы L1.

L1 - Только статус код (10 баллов)

Проверяем, что сервер вернул ожидаемый HTTP-статус.

# L1: проверка статуса
response = requests.get("/users/123")
assert response.status_code == 200
# L1: проверка статуса
response = requests.get("/users/123")
assert response.status_code == 200

Это минимальный уровень осмысленного теста. Мы знаем, что запрос не упал. Но что именно вернулось в теле ответа не знаем. Баг в данных пропустим.

L2 - Статус + проверка существования ответа (25 баллов)

Добавляем проверку, что ответ не пустой и имеет ожидаемую структуру.

# L2: статус + существование
response = requests.get("/users/123")
assert response.status_code == 200
data = response.json()
assert data is not None
assert isinstance(data, dict)
// L2: статус + существование
given()
    .get("/users/123")
.then()
    .statusCode(200)
    .body(notNullValue())
    .body("$", instanceOf(Map.class));

Теперь мы знаем, что ответ есть и это объект (а не массив или примитив). Но конкретные поля всё ещё не проверяем.

L3 - Проверка полей верхнего уровня (50 баллов)

Начинаем проверять конкретные поля в ответе.

# L3: поля верхнего уровня
response = requests.get("/users/123")
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "email" in data
assert "status" in data
assert data["id"] == 123
// L3: поля верхнего уровня
given()
    .get("/users/123")
.then()
    .statusCode(200)
    .body("id", equalTo(123))
    .body("email", notNullValue())
    .body("status", equalTo("active"));

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

L4 - Проверка вложенных полей (70 баллов)

Лезем внутрь вложенных объектов.

# L4: вложенные поля
response = requests.get("/users/123")
data = response.json()
assert data["id"] == 123
assert data["profile"]["firstName"] == "John"
assert data["profile"]["address"]["city"] == "Moscow"
assert len(data["roles"]) > 0
assert data["roles"][0]["name"] == "admin"
// L4: вложенные поля
given()
    .get("/users/123")
.then()
    .statusCode(200)
    .body("id", equalTo(123))
    .body("profile.firstName", equalTo("John"))
    .body("profile.address.city", equalTo("Moscow"))
    .body("roles", hasSize(greaterThan(0)))
    .body("roles[0].name", equalTo("admin"));

Вложенные структуры - это где часто прячутся баги. Неправильный маппинг на втором-третьем уровне вложенности легко пропустить при ручном тестировании.

L5 - Проверка типов и форматов (85 баллов)

Проверяем не только наличие и значения, но и форматы данных.

# L5: типы и форматы
import re
from uuid import UUID

response = requests.get("/users/123")
data = response.json()

assert isinstance(data["id"], int)
assert isinstance(data["email"], str)
assert re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', data["email"])
assert UUID(data["uuid"])  # валидный UUID
assert re.match(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}', data["createdAt"])
// L5: типы и форматы
given()
    .get("/users/123")
.then()
    .statusCode(200)
    .body("id", instanceOf(Integer.class))
    .body("email", matchesPattern("^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$"))
    .body("uuid", matchesPattern(UUID_PATTERN))
    .body("createdAt", matchesPattern(ISO_DATE_PATTERN));

UUID должен быть валидным UUID, а не просто строкой. Email должен соответствовать формату. Дата должна быть в ISO-формате. Такие проверки ловят баги сериализации и валидации на стороне сервера.

L6 - Проверка бизнес-логики (100 баллов)

Высший уровень: проверяем не просто данные, а бизнес-правила.

# L6: бизнес-логика
response = requests.get("/orders/456")
data = response.json()

# Проверяем что сумма позиций равна итогу
items_total = sum(item["price"] * item["quantity"] for item in data["items"])
assert data["total"] == items_total

# Проверяем что скидка применена корректно
expected_discount = items_total * 0.1 if data["promoApplied"] else 0
assert data["discount"] == expected_discount

# Проверяем что статус соответствует оплате
if data["paidAt"] is not None:
    assert data["status"] in ["paid", "shipped", "delivered"]
// L6: бизнес-логика
Response response = given().get("/orders/456");
JsonPath json = response.jsonPath();

List<Map<String, Object>> items = json.getList("items");
double itemsTotal = items.stream()
    .mapToDouble(i -> (Double)i.get("price") * (Integer)i.get("quantity"))
    .sum();

assertThat(json.getDouble("total"), equalTo(itemsTotal));

if (json.getString("paidAt") != null) {
    assertThat(json.getString("status"), 
        isOneOf("paid", "shipped", "delivered"));
}

L6 - это тесты, которые понимают предметную область. Они проверяют инварианты, бизнес-правила, связи между данными. Такие тесты пишутся дольше, но и ловят самые кова��ные баги.

Как считается Oracle Strength

Oracle Strength складывается из трёх компонентов:

Глубина (depth_score): максимальный достигнутый уровень L0-L6. Если в тесте есть хотя бы одна проверка уровня L5, depth_score = 85.

Плотность (density_score): сколько assertions в тесте. Один assertion - мало, 4-6 - нормально.

Количество assertions и соответствующие баллы: 0 assertions - 0 баллов, 1 assertion - 20 баллов, 2-3 assertions - 50 баллов, 4-6 assertions - 80 баллов, 7 и более - 100 баллов.

Сила проверок (coverage_score): какие типы matchers используются.

Мы разделили все проверки на три категории по их способности ловить баги.

Сильные проверки (вес 3) конкретно указывают ожидаемое значение или формат. В Java это equalTo("value"), matchesPattern(), hasSize(N), containsString(), greaterThan(), lessThan(). В Python assert x == "value", re.match(), len(x) == N.

Средние проверки (вес 2) проверяют наличие или непустоту, но не конкретное значение. В Java это notNullValue(), hasKey(), not(empty()). В Python assert x is not None, "key" in dict.

Слабые проверки (вес 0.5) практически ничего не проверяют. В Java это anything(), is(notNullValue()) без контекста. В Python assert True, assert response.

anything() это особый случай. Эта проверка буквально пропускает что угодно. Если вы видите много anything() в тестах это красный флаг.

Итоговая формула:

oracle_strength = (depth × 0.4) + (density × 0.2) + (coverage × 0.4)

Глубина и разнообразие проверок важнее, чем просто количество.

Когда внедрять Oracle Strength

Эта метрика полезна всегда, когда у вас есть API-тесты. Она не требует запуска тестов достаточно статического анализа кода. Рекомендуем начинать с неё.

Mutation Score: убивает ли тест мутантов

Вес в итоговой оценке: 25%

Идея мутационного тестирования

Мутационное тестирование это способ проверить качество тестов через провокацию. Идея простая: если мы намеренно сломаем код, хороший тест должен это заметить.

Как это работает: берём тест, вносим небольшое изменение (мутацию), запускаем. Если тест упал отлично, он поймал мутанта, мутант "убит". Если тест прошёл плохо, мутант "выжил", тест слабый.

# Оригинальный тест
assert response.json()["status"] == "active"

# Мутация: меняем ожидаемое значение
assert response.json()["status"] == "inactive"  # мутант

# Если тест по-прежнему зелёный - значит он не проверяет status реально

Типы мутаций для API-тестов

Для API-тестов мы определили шесть типов мутаций.

Status Code Mutation: меняем ожидаемый HTTP-статус. Было 200, стало 201 или 404. Тест, который реально проверяет статус, должен упасть.

Field Value Mutation: меняем ожидаемое значение поля. "active" → "inactive", 100 → 101. Тест, который проверяет конкретное значение, должен упасть.

Field Removal Mutation: убираем одну из проверок. Если тест проверял 5 полей, оставляем 4. Если после этого тест всё ещё ловит баги в удалённом поле - хорошо (значит есть избыточность). Если нет - значит каждая проверка критична.

Boundary Mutation: меняем граничные условия. > 0 → >= 0, < 100 → <= 100. Ловит тесты, которые проверяют границы небрежно.

Type Mutation: меняем проверку типа. isString() → isNumber(). Тест должен упасть, если реально проверяет тип.

Null Mutation: добавляем null туда, где его не ожидают. Проверяет, насколько тест устойчив к неожиданным данным.

Как интерпретировать Mutation Score

Mutation Score считается как процент убитых мутантов от общего числа.

mutation_score = killed_mutants / total_mutants × 100

Шкала интерпретации: 0-30% критически слабый, тест почти ничего не ловит. 31-50% слабый, много мутантов выживает. 51-70% средний, базовые проблемы тест поймает. 71-85% хороший, тест надёжный. 86-100% отличный, тест ловит почти всё.

Приближённая оценка без реального мутационного тестирования

Полноценное мутационное тестирование требует инфраструктуры: нужно генерировать мутанты, запускать тесты, собирать результаты. Это не всегда возможно.

Для быстрой оценки можно использовать приближение на основе статического анализа:

mutation_score ≈ (strong_matchers × 4) + (medium_matchers × 2) + (depth >= L4 ? 20 : 0)

Логика: тесты с сильными matchers обычно убивают больше мутантов. Тесты, которые лезут во вложенные структуры (L4+), тоже более надёжны.

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

Когда внедрять Mutation Score

Если у вас есть возможность запускать мутационное тестирование (например, через PIT для Java или mutmut для Python) используйте реальный score. Если нет, то используйте приближение.

Эта метрика особенно полезна для выявления "тестов-пустышек", которые выглядят солидно, но на самом деле ничего не ловят.

Negative Coverage: тестируем ошибки, а не только успех

Вес в итоговой оценке: 20%

Почему негативные сценарии важны

Большинство тестов проверяют "счастливый путь": отправили корректный запрос, получили корректный ответ. Но в реальной жизни запросы бывают некорректными. И поведение API при ошибках должно быть таким же предсказуемым, как при успехе.

Что происходит, если отправить запрос без токена авторизации? А с невалидным токеном? А с токеном, у которого нет прав на этот ресурс? Каждый из этих случаев должен обрабатываться по-своему.

Обязательные негативные сценарии

Мы выделили шесть категорий негативных сценариев, которые применимы к большинству API.

400 Bad Request (вес 20%): невалидные параметры запроса. Отправили строку вместо числа, пропустили обязательное поле, передали слишком длинное значение. API должен вернуть 400 и понятное сообщение об ошибке.

401 Unauthorized (вес 15%): проблемы с аутентификацией. Запрос без токена, с истёкшим токеном, с невалидным токеном. API должен вернуть 401 и не выдать никаких данных.

403 Forbidden (вес 15%): проблемы с авторизацией. Токен валидный, но у пользователя нет прав на этот ресурс. API должен вернуть 403. Важно: это не то же самое, что 401.

404 Not Found (вес 20%): ресурс не существует. Запросили пользователя с несуществующим ID, товар который удалили, страницу которой нет. API должен вернуть 404, а не 500.

422 Unprocessable Entity (вес 15%): бизнес-валидация. Параметры технически корректны, но не имеют смысла с точки зрения бизнес-логики. Например, дата окончания раньше даты начала, отрицательное количество товара, несуществующий промокод.

5xx Server Error (вес 15%): корректная обработка серверных ошибо��. Даже если что-то пошло не так на сервере, API должен вернуть осмысленный ответ, а не упасть с необработанным исключением.

Как считается Negative Coverage

negative_coverage = covered_scenarios / applicable_scenarios × 100

Важный момент: не все сценарии применимы ко всем эндпоинтам. Публичный эндпоинт без авторизации не нужно тестировать на 401/403. Эндпоинт, который не принимает параметров, сложно тестировать на 400.

При расчёте учитываем только те сценарии, которые имеют смысл для конкретного эндпоинта.

Когда внедрять Negative Coverage

Эта метрика особенно важна для публичных API и API с авторизацией. Если у вас есть хоть какие-то требования к безопасности негативные тесты обязательны.

Начните с 401/403 (безопасность) и 400 (валидация). Это самые критичные категории.

Edge Cases: граничные случаи

Вес в итоговой оценке: 15%

Где прячутся баги

Граничные случаи это входные данные на границе допустимого. Пустая строка, ноль, максимально допустимое значение, null. Именно там чаще всего прячутся баги.

Разработчик пишет код для "нормальных" данных. Строка из 10-50 символов, число от 1 до 100, непустой массив. А потом приходит пользователь и отправляет пустую строку. Или строку из 10000 символов. Или emoji вместо текста.

Типы граничных случаев

Пустая строка "" (вес 10%) классика. Поле обязательное, но пустая строка технически не null. Как поведёт себя система?

Null (вес 10%) для nullable полей. Если поле может быть null, это нужно тестировать явно.

Максимальная длина (вес 10%) если у строки есть ограничение maxLength, что будет если отправить ровно maxLength символов? А maxLength + 1?

Граничные числовые значения (вес 10%) минимум, максимум, и значения рядом с ними. Если допустимый диапазон 1-100, тестируем 0, 1, 100, 101.

Пустой массив [] (вес 10%) если поле - массив, что будет если он пустой?

Специальные символы < > & " ' (вес 10%) символы, которые имеют особое значение в HTML, XML, SQL. Тест на инъекции.

Unicode и emoji (вес 10%) современные системы должны корректно обрабатывать любой Unicode. Включая emoji, RTL-тексты, иероглифы.

Граничные даты (вес 10%) 1970-01-01 (начало Unix epoch), 2038-01-19 (проблема Y2K38 для 32-битных систем), 9999-12-31.

Ноль (вес 10%) - для числовых полей. Ноль - это не отсутствие значения, это конкретное значение. Деление на ноль, индекс ноль, количество ноль.

Отрицательные числа (вес 10%) если отрицательные значения недопустимы, это нужно проверять. Если допустимы тоже тестировать.

Как считается Edge Coverage

edge_coverage = covered_edges / applicable_edges × 100

Как и с негативными сценариями, не все edge cases применимы ко всем полям. Для boolean-поля нет смысла тестировать "пустую строку". Для enum "максимальную длину".

Когда внедрять Edge Cases

Edge cases особенно важны для API, которые принимают пользовательский ввод. Если данные приходят от пользователей edge cases обязательны. Если API внутренний и данные контролируются можно отложить.

Structural Quality: качество самого кода теста

Вес в итоговой оценке: 10%

Тест - это тоже код

Тест это код, который нужно читать, поддерживать, отлаживать. Плохо написанный тест создаёт технический долг так же, как плохо написанный продакшн-код.

Критерии структурного качества

Single Responsibility (20 баллов): один тест должен проверять один сценарий. Тест, который делает запрос, потом другой запрос, потом третий, и где-то в середине проверяет результаты - это не один тест, а три, склеенных вместе.

Понятные имена (15 баллов): имя теста должно говорить, что он проверяет. test1() - плохо. shouldReturnUserWhenValidId() - хорошо. shouldReturn404WhenUserNotFound() - отлично.

Нет магических значений (15 баллов): числа и строки должны быть вынесены в константы или объяснены. assert data["status"] == 1 - что такое 1? assert data["status"] == STATUS_ACTIVE - понятно.

Setup/Teardown (15 баллов): повторяющаяся подготовка и очистка вынесены в @BeforeEach/@AfterEach. Не нужно копипастить создание тестовых данных в каждый тест.

Логирование (10 баллов): .log().all() в RestAssured, вывод response в pytest. Когда тест падает, логи помогают понять почему.

Информативные сообщения в assertions (15 баллов): assert data["id"] == 123, f"Expected id 123, got {data['id']}". Когда assertion падает, сообщение должно объяснять что пошло не так.

Стабильность (10 баллов): нет sleep(), random(), зависимости от текущего времени. Тест должен давать один и тот же результат при каждом запуске.

Когда внедрять Structural Quality

Эта метрика имеет наименьший вес в итоговой оценке, и на то есть причина. Хорошо структурированный тест, который ничего не проверяет, то бесполезен. Плохо структурированный тест, который ловит баги полезен.

Внедряйте эту метрику последней, когда основные (Oracle Strength, Mutation Score) уже работают.

Антипаттерны: что точно не надо делать

Некоторые практики настолько вредны, что за них мы вычитаем баллы из Structural Quality.

Flakiness - нестабильность

Thread.sleep() / time.sleep(): штраф −10 баллов. Жёсткие задержки делают тесты нестабильными и медленными. Если нужно ждать - используйте явные условия ожидания.

Random без seed: штраф −5 баллов. Случайные данные в тестах усложняют воспроизведение багов. Если нужна рандомизация - фиксируйте seed.

Проглатывание ошибок

Пустой catch / except: pass: штраф −15 баллов. Исключение произошло, но мы его игнорируем. Тест "зелёный", но реальная проблема скрыта.

Комментарии // ignore, # noqa без причины: штраф −15 баллов. Обычно означает "я не знаю почему это не работает, но хочу чтобы CI был зелёный".

Бесполезные тесты

Пустой тест: штраф −20 баллов. @Test void test() {} зачем это существует?

assert True без контекста: штраф −10 баллов. assert True не проверяет ничего.

Цепочка anything(): штраф −10 баллов. anything().anything().anything()создаёт видимость проверки при полном её отсутствии.

Проблемы с качеством кода

Однобуквенные имена: штраф −5 баллов. void t1(), def test_a() - невозможно понять что тестируется.

Захардкоженные секреты: штраф −5 баллов. password = "qwerty123" в коде теста - проблема безопасности и поддержки.

Copy-paste 3+ подряд: штраф −15 баллов. testUser1, testUser2, testUser3 с идентичным кодом нужен параметризованный тест.

Copy-paste 5+ подряд: штраф −25 баллов. Массовое дублирование кода, технический долг.

Финальная формула и грейды

Как считается итоговый балл

base_score = (

    oracle_strength × 0.30 +

    mutation_score × 0.25 +

    negative_coverage × 0.20 +

    edge_coverage × 0.15 +

    structural_quality × 0.10

)

final_score = base_score × compilation_gate × execution_gate

Где compilation_gate равен 1.0 если код компилируется и 0.0 если нет. Execution_gate равен 1.0 если тест запускается и 0.5 если падает.

Грейды

Мы ввели буквенные грейды для удобства коммуникации. Проще сказать "у нас тесты грейда B" чем "у нас средний балл 73.5".

90-100 баллов грейд S, Production-ready, тесты отличные.

80-89 баллов грейд A, высокое качество, можно полагаться.

70-79 баллов грейд B, хорошая основа, есть куда расти.

60-69 баллов грейд C, требует доработки, базовые проверки есть.

50-59 баллов грейд D, слабо, много пропущенных проверок.

0-49 баллов грейд F, непригодно, тесты почти не работают.

Пример полной оценки

Разберём реальный пример: набор из 39 тестов для REST API.

Исходные данные: 39 тестов, 14 слабых matchers (в основном anything()), 1 средний, 0 сильных. 5 синтаксических ошибок. Обнаружены антипаттерны.

Шаг 1: Compilation Gate

5 синтаксических ошибок означают что часть кода не компилируется. Строго говоря, должны получить 0 баллов. Но допустим, ошибки в 2 из 39 тестов, остальные компилируются. Применяем частичный штраф: execution_gate = 0.5.

Шаг 2: Антипаттерны

Thread.sleep() найден 1 раз: −10 баллов.

Пустой catch найден 1 раз: −15 баллов.

Пустые тесты найдены 2 раза: −40 баллов.

Copy-paste обнаружен (8 последовательных похожих тестов): −25 баллов.

Итого штрафов: −90, но ограничиваем до −50.

Шаг 3: Oracle Strength

Максимальная глубина: L2 (проверяют статус и существование ответа, но не конкретные поля). Это 25 баллов за depth.

Плотность низкая: в среднем 1-2 assertion на тест. Около 30 баллов за density.

Типы matchers: 14 слабых, 1 средний, 0 сильных. Coverage_score низкий: около 15 баллов.

Oracle Strength = (25 × 0.4) + (30 × 0.2) + (15 × 0.4) = 10 + 6 + 6 = 22. Округляем с учётом базы, получаем 44/100.

Шаг 4: Mutation Score

С таким количеством weak matchers и отсутствием strong, приближённый mutation score очень низкий: около 6/100.

Шаг 5: Negative Coverage

Из 6 категорий негативных сценариев покрыта только одна (404 Not Found): 17/100.

Шаг 6: Edge Cases

Из 10 типов edge cases покрыты 3 (пустая строка, null, ноль): 30/100.

Шаг 7: Structural Quality

Базовый балл 50 (средний код), минус 50 штрафов за антипаттерны: 10/100.

Итоговый расчёт:

base_score = 44×0.3 + 6×0.25 + 17×0.2 + 30×0.15 + 10×0.1

           = 13.2 + 1.5 + 3.4 + 4.5 + 1.0

           = 23.6 ≈ 24

final_score = 24 × 0.5 (compilation penalty) = 12

Результат: 12 баллов, грейд F.

Тесты существуют, но практически бесполезны. Они создают иллюзию покрытия, но не ловят баги.

Поэтапное внедрение: с чего начать

Внедрять все метрики сразу сложно и не всегда нужно. Вот рекомендуемый путь.

Этап 1: только Oracle Strength

Начните с измерения глубины проверок. Это даст вам картину: какой процент тестов проверяет только статус (L1), какой лезет в данные (L3+), какой вообще ничего (L0).

Это можно сделать статическим анализом, без запуска тестов. Инструмент из третьей части статьи начинает именно с этого.

Этап 2: добавляем штрафы за антипаттерны

Поиск sleep(), empty catch, copy-paste тоже делается статически. Это низко висящие фрукты: найти и исправить такие проблемы обычно несложно.

Этап 3: добавляем Negative Coverage

Посмотрите какие HTTP коды проверяются в тестах. Скорее всего обнаружите, что 90% тестов проверяют только 200 OK.

Этап 4: добавляем Edge Cases

Проанализируйте какие входные данные используются в тестах. Есть ли тесты с пустыми строками? С null? С граничными значениями?

Этап 5: полная оценка с Mutation Score

Если есть возможность подключите мутационное тестирование. Если нет используйте приближение.

Этап 6: Structural Quality

Добавьте в последнюю очередь, когда основные метрики уже работают.

Что дальше

Методология описана, но вручную считать эти метрики занятие неблагодарное. Нужен инструмент.

В третьей части покажем детерминированный скрипт, который анализирует тесты и выдаёт отчёт по EVA. Парсинг кода, подсчёт метрик, рекомендации по улучшению. Никаких нейросетей только код, который можно запустить локально.

Вот как выглядит результат его работы:

 Пример отчёта EVA: 66 тестов, 8 файлов, грейд F (38/100). Видно распределение по уровням глубины и конкретные проблемы каждого теста.
Пример отчёта EVA: 66 тестов, 8 файлов, грейд F (38/100). Видно распределение по уровням глубины и конкретные проблемы каждого теста.

Отчёт показывает не просто итоговую оценку, а детализацию по каждому файлу и тесту. Для DebugApiTest.java видим: 4 теста, из них один на уровне L0 (вообще без assertions), два на L1 (только статус), и один на L5 (81 балл). Плюс конкретные рекомендации что исправить.

Такой отчёт можно генерировать на каждый PR, интегрировать в CI/CD, отслеживать динамику качества тестов.

Но об этом - в следующей части.


Это бета-версия методологии. Мы намеренно публикуем её до внедрения в продакшн, чтобы собрать обратную связь. Если видите дыры в логике, знаете похожие подходы или имеете опыт с подобными метриками - пишите в комментариях.