🔥 Важное для QA-специалистов! 🔥
В QaRocks ты найдешь туториалы, задачи и полезные книги, которых нет в открытом доступе. Уже более 16.000 подписчиков – будь среди нас! Заходи к нам в телеграм канал QaRocks
Когда пишешь код, который пойдет в продакшн, нужно быть уверенным, что он будет вести себя так, как задумано. Один из основных способов добиться этого — модульные тесты: небольшие автоматизированные проверки, подтверждающие, что определенные части кода работают правильно.
Естественно, это вызывает несколько вопросов: сколько тестов нужно? И насколько они эффективны? Частый ответ — покрытие тестами, метрика, показывающая, какая часть кода выполняется во время тестирования.
Многие команды используют тестовое покрытие в качестве контроля качества: если показатель опускается ниже установленного порога, код не принимается. Но эти пороговые значения часто являются произвольными — достаточно высокими, чтобы чувствовать себя в безопасности, но достаточно низкими, чтобы их можно было достичь. 75%, 80%, 85% — знакомые цифры, которые выглядят убедительно, но мало что говорят о реальном качестве.
Прежде чем полагаться на покрытие как на показатель качества, стоит себя спросить:
- О чем на самом деле говорит тестовое покрытие?
- Насколько полезен этот показатель?
- Гарантирует ли определенный процент, что код действительно хорошо протестирован?
В этой статье разберем эти вопросы и посмотрим, почему даже высокое покрытие может вводить в заблуждение.
Обманчивое покрытие
Для начала напишем простую функцию, которая рассчитывает коэффициент скидки на основе возраста пользователя и статуса активности:
- для пользователей младше 18 лет — скидка 10% (множитель 0,9);
- для неактивных пользователей — скидка 20% (множитель 0,8);
- для пользователей, которые не достигли 18 лет и не проявляют активности, — применяются обе скидки (множитель 0,72);
- для всех остальных пользователей скидка не применяется (множитель 1,0).
Вот код, реализующий эти требования (на примере Go, но язык здесь не имеет значения):
package main
func CalcDiscount(userAge int, isUserActive bool) float64 {
discountMul := 1.0
if userAge < 18 {
discountMul *= 0.9 // 10% discount for minors
}
if !isUserActive {
discountMul *= 0.8 // 20% discount for inactive users
}
return discountMul
}
Стоит отметить: функция очень простая, но это сделано специально. Позже посмотрим, как ее можно протестировать лучше.
Теперь напишем несколько тестов для этой функции. Поскольку речь идет о покрытии тестами, его и измерим, используя test coverage как метрику:
package main
import (
"fmt"
"math"
"testing"
)
func TestCalcDiscount(t *testing.T) {
type TestCase struct {
UserAge int
IsUserActive bool
Expected float64
}
testCases := []TestCase{
// Test Case 1 - user age < 18 and user is inactive
{UserAge: 17, IsUserActive: false, Expected: 0.72},
// Test Case 2 - user age ≥ 18 and user is active
{UserAge: 30, IsUserActive: true, Expected: 1.0},
// Test Case 3 - user age < 18 and user is active
{UserAge: 17, IsUserActive: true, Expected: 0.9},
// Test Case 4 - user age ≥ 18 and user is inactive
{UserAge: 25, IsUserActive: false, Expected: 0.8},
}
for _, testCase := range testCases {
testCase := testCase
testCaseName := fmt.Sprintf(
"%d-%t",
testCase.UserAge,
testCase.IsUserActive,
)
t.Run(testCaseName, func(t *testing.T) {
actual := CalcDiscount(testCase.UserAge, testCase.IsUserActive)
delta := math.Abs(actual - testCase.Expected)
const epsilon = 0.00001
if delta > epsilon {
t.Errorf("Expected %v, got %v", testCase.Expected, actual)
}
})
}
}
Теперь запустим эти тесты с разными наборами кейсов и посмотрим на покрытие:
$ go test -covermode=count -coverprofile=/dev/null PASS coverage: 100.0% of statements ok github.com/kapitanov/personal-blog/posts/code-examples/code-1 0.211s
Текущая цель — достичь 100% покрытия, не заглядывая в код функции. Да, пример простой, но представим, что он сложный — настолько, что вручную анализировать нереально. Таким образом, придется полагаться на покрытие кода.
Теперь будем добавлять тесты по одному и смотреть, как меняется показатель:
- Без тестов — нулевое покрытие, что вполне логично.
- С одним тестом (Test Case 1) — уже 100% покрытия! На первый взгляд это может показаться правильным, поскольку пройдены обе ветки
if(true и false). Но с точки зрения требований, часть сценариев все еще не покрыта. - Добавление дополнительных тестов не изменяет покрытие — оно остается 100%.
Поэтому, если ориентироваться на тестовое покрытие в качестве метрики и порог покрытия в 100%, то можно получить ложное чувство уверенности: кажется, что функция протестирована идеально — хотя на самом деле это далеко не так.
Глубже в детали
Чтобы разгадать эту загадку, изобразим операторы функции на графике вызовов и посмотрим, какие ветки кода покрываются какими тест-кейсами:

В качестве примера на графике выделен Test case 1 — и видно, что он охватывает только 25% всего графика вызовов! Теперь ясно: чтобы протестировать функцию полноценно, нужно пройти все ветви графика вызовов, а значит — четыре тест-кейса, по одному на каждую ветвь.
Как можно догадаться, существуют разные типы покрытия тестами:
- Покрытие операторов (statement coverage) — показывает, выполнена ли каждая строка кода. Именно это измерялось в предыдущих примерах.
Даже один тест позволил добиться 100% покрытия операторов. - Покрытие ветвей (branch coverage) — показывает, были ли выполнены все ветви (true/false) управляющих конструкций (
if,switch,for). В примере выше Test Case 1 проходит по двум ветвям —trueпервой проверки иfalseвторой — из четырех возможных, то есть 50% покрытия ветвей.
Забавно, но 100% покрытия ветвей можно достичь всего с двумя тестами: Test Case 1 — пользователь < 18 лет и неактивен и Test Case 2 — пользователь ≥ 18 лет и активен. - Покрытие путей (path coverage) — показывает, были ли выполнены все возможные пути выполнения кода. В нашем примере таких путей четыре, значит Test Case 1 покрывает лишь один из них, то есть 25% покрытия путей. Чтобы достичь 100%, нужны все четыре теста.
Большинство инструментов тестирования обеспечивают только покрытие операторов, лишь некоторые могут обеспечивать покрытие ветвей — но, как мы видели, ни один из этих типов покрытия не гарантирует полноценной проверки логики.
Только покрытие путей может гарантировать, что все возможные сценарии были протестированы. Но реально ли достичь покрытие путей на практике? Посмотрим на другой пример кода:
package main
import "slices"
// MinOf returns the minimum value from a slice of integers.
// It doesn't allow empty slices as input.
func MinOf(xs []int) int {
if len(xs) == 0 {
panic("empty slice")
}
slices.Sort(xs)
return xs[0]
}package main<br><br>import "slices"<br><br>// MinOf returns the minimum value from a slice of integers.<br>// It doesn't allow empty slices as input.<br>func MinOf(xs []int) int {<br> if len(xs) == 0 {<br> panic("empty slice")<br> }<br><br> slices.Sort(xs)<br> return xs[0]<br>}
Эта функция имеет две ветви — для пустого и непустого среза. Итак, достаточно ли будет двух тестовых случаев — одного с пустым срезом и одного с непустым срезом?
Ответ — нет, потому что сама функция slices.Sort имеет ветви и пути, которые мы не контролируем. Более того, это библиотечная функция — мы не знаем подробностей ее реализации, и они могут меняться от версии к версии, даже в минорных апдейтах.
И, наконец, рассмотрим чуть более сложный пример:
package main
func ComplexCondition(a, b, c bool) bool {
if a || (b && c) {
return true
}
return false
}
На первый взгляд — две ветви: условие истинно или ложно. Но на самом деле есть четыре комбинации входных значений (a, b, c).
Если переписать условие явно:
package main
func ComplexConditionExplicit(a, b, c bool) bool {
if a {
return true
}
if b {
if c {
return true
}
return false
}
return false
}
Теперь видно, что нужно четыре тест-кейса, чтобы покрыть все возможные пути. К сожалению, это означает, что даже при 100% покрытии путей может понадобиться больше тестов, чем изначально предполагалось.
Заключение и несколько мыслей
В целом, покрытие тестами — полезная метрика: она помогает выявить непроверенные части кода.
Но необходимо помнить об ограничениях: даже 100% покрытие операторов или ветвей не гарантирует, что код действительно хорошо протестирован. Более того, это даже близко не означает этого.
При этом достижение 100% покрытия путей обычно невозможно. И даже если это удастся, такие тесты не выдержат никаких изменений в коде.
Поэтому можно придерживаться простого правила:
Пока вы не обеспечили 100% покрытия тестируемых частей кода — у вас есть непроверенный код.
Когда вы достигли 100% покрытия тестируемых частей кода — непроверенный код у вас все еще может быть.
Вот несколько шагов, которые помогут придерживаться этого правила:
- Не каждый фрагмент кода необходимо тестировать.
Определите критически важные части кода, которые действительно требуют тестирования. Скорее всего, инфраструктурный код, «склеивающий» модули, и подобные элементы не нуждаются в юнит-тестах.
Действительно ли нужно проводить модульное тестирование настройки вашего логгера или инициализации HTTP-сервера? ВВряд ли — такие вещи проверяются неявно на этапе интеграционного тестирования.
А вот бизнес-логику тестировать необходимо — это критическая часть приложения.
Даже если какие-то другие модули покрываются косвенно, это не должно влиять на общий показатель. - Стремитесь к 100% (полному) покрытию тестируемых частей кода.
Если покрытие составляет менее 100%, это гарантирует, что код не прошел надлежащее тестирование! С другой стороны, 100% покрытие тестами ничего не значит — все равно придется тщательно разрабатывать тесты. Таким образом, стоит стремиться к 100% покрытию, но не полагаться на него. - Будьте прагматичны.
Достичь полного покрытия невозможно. В Go, например, нет смысла тестировать каждую строчку видаif err != nil { return err }— это пустая трата времени на бесполезные тесты.
Тем не менее, если у вас 95% покрытия, убедитесь, что вы знаете, какие 5% остались и почему.
Регулярно проверяйте покрытие кода — например, при каждом pull request. Особое внимание стоит уделять строкам, которые относятся к важной бизнес-логике и не покрыты тестами. - Не полагайтесь на пороговые значения покрытия кода.
Не устанавливайте произвольные пороговые значения, такие как 75%, 80% или 85% — они бессмысленны.
Не устанавливайте пороговое значение 100% — его часто невозможно достичь.
Не устанавливайте разные пороговые значения для разных частей кода — это тоже бессмысленно.
Не устанавливайте правило «покрытие не должно снижаться» — это также не имеет смысла. - Юнит-тестов никогда не бывает достаточно.
Даже при 100% покрытии путей ваш код может не работать как ожидается. Ошибки конфигурации, сетевые сбои, проблемы с железом — всё это не поймают юнит-тесты.
Поэтому нужны также интеграционные и end-to-end тесты. Юнит-тесты — это лишь один инструмент из набора.
Перевод статьи «Test coverage and what it can tell us».