Как писать качественные unit-тесты: процент покрытия, мутанты и работа с моками
- четверг, 31 октября 2024 г. в 00:00:07
Привет, Хабр! Меня зовут Марина, я Backend-инженер в компании Авито. Сегодня я хочу поделиться собственными рекомендациями, к которым удалось прийти при работе над качеством тестового покрытия сервисов нашей команды.
Итак, дело было давным-давно, у нас было пять сервисов, около 70% покрытия, интеграционные тесты... и всё равно баги оставались неуловимыми. Шутка, конечно, все куда проще. Процент покрытия и правда выглядел неплохо, но почему тогда мы решили что-то менять?
Когда разработчики пишут unit-тесты, часто возникает соблазн сосредоточиться на достижении высокого процента покрытия кода. Многие CI/CD системы выставляют определенные требования к проценту покрытия, чтобы принять изменения.
И вот, сидишь перед монитором и хочется просто отстреляться, написать хоть что-то, чтобы увидеть заветную зелёную галочку. И даже есть такая возможность…
Процент покрытия показывает, сколько строк вашего кода выполнено в ходе тестирования. То есть простой вызов функции в тесте уже подойдет, чтобы “покрыть” эту функцию. Всё, готово! Можно не напрягаться, галочка получена.
Подобные истории приводят к тому, что тесты не проверяют поведение приложения должным образом. На своей практике мы столкнулись с двумя самыми главными проблемами:
Тесты не проверяют выходные данные
Например, написана проверка двух кейсов: ok и error (“обработка прошла успешно” и “метод вернул ошибку”). Иногда проверка выходных данных полностью отсутствует. В результате процент покрытия высокий, а качество проверки низкое.
Отсутствие проверки граничных случаев
Хорошо, если тест проверяет пару значений из так называемых классов эквивалентности. Но как насчёт граничных? Очень часто они просто игнорируются. Например, проверка обработки нулевого значения или пустого массива может быть критичной для качества продукта, но не сильно повлияет на процент покрытия.
Можно сказать, процент покрытия полезен для того, чтобы убедиться, что код был выполнен, но это не показатель, что ваш код надёжен и протестирован на все 100%. Для этого существуют другие подходы, например, мутационное тестирование.
Мутационное тестирование — это подход, при котором в ваш код намеренно вносятся небольшие изменения, называемые "мутациями". Цель таких изменений — проверить, смогут ли ваши тесты заметить их и провалиться, если поведение программы изменится. Например, мутация может заключаться в замене оператора >
на <
или изменении значения возвращаемой константы.
Идея в том, что если ваши тесты не замечают эти изменения, значит, они слишком поверхностные и не проверяют логику вашего кода достаточно глубоко.
Одной из ключевых метрик в мутационном тестировании является "процент обнаруженных мутантов" (Mutation Score). Это процент мутаций, которые тесты смогли отловить. Чем выше этот показатель, тем выше качество тестов.
Рассмотрим простой пример. Допустим, у нас есть функция isPositive, которая проверяет, является ли переданное число положительным:
func isPositive(n int) bool {
if n > 0 {
return true
}
return false
}
Напишем несколько тестов для проверки работы функции:
func Test_IsPositive(t *testing.T) {
assert.True(t, isPositive(5)) // Тест с положительным числом
assert.False(t, isPositive(-3)) // Тест с отрицательным числом
}
Эти тесты проверяют базовые сценарии: когда число положительное и когда отрицательное. Здесь никаких корнер-кейсов, все просто.
Теперь представим, что в процессе мутационного тестирования условие n > 0
было изменено на n >= 0
:
func isPositive(n int) bool {
if n >= 0 { // Мутация: изменено условие с > на >=
return true
}
return false
}
Теперь функция будет возвращать true
не только для положительных чисел, но и для нуля, а наши тесты пройдут успешно и не заметят этого.
Чтобы отловить такого мутанта достаточно просто добавить проверку на граничный случай:
assert.False(t, isPositive(0))
Представим, что в похожей ситуации в процессе написания нового функционала уставший разработчик Вася нечаянно изменит код. На первый взгляд — мелочь, но иногда такие детали могут нарушить работу важных бизнес-сценариев. Тесты не заметят изменений, и вместо того, чтобы спокойно провести выходные, Вася срочно чинит баги, которые успели попасть в продакшн.
Таким образом, регулярное проведение мутационного тестирования и контроль Mutation Score поможет отслеживать потенциальные уязвимые места и проактивно закрывать их тестами.
А что дальше?
А теперь представим, что мы написали тесты, добавили наборы данных, тщательно проверяем все выходные значения, а отчет по мутационному тестированию все ещё намекает нам, что мы что-то делаем не так.
Один из частых подводных камней — это значения, которые передаются в качестве аргументов в вызов внешнего сервиса. В тестах такие вызовы закрываются стабами и моками. Проблема в том, что стабы вообще не проверяют аргументы, а моки дают соблазнительную возможность этого не делать. В результате, ваш тест может легко проглядеть ситуацию, когда в продакшне вместо корректных данных отправляется что-то неожиданное.
И то и другое - объекты, которые имитируют поведение реальных зависимостей вашего кода. Они позволяют тестировать компоненты системы в изоляции, что особенно полезно при работе с внешними сервисами или базами данных.
Стабы являются простыми заглушками, которые возвращают заранее определенные данные. Моки, в свою очередь, контролируют передачу аргументов, вызов метода и проверяют ожидаемое поведение тестируемого объекта.
Стабы рассматривать в данном случае нет смысла, их задача - просто заменить ненужный для проверки вызов фиктивным ответом. Но, зачастую, вызов проверять очень важно, поэтому перейдем к особенностям работы с моками.
Использование моков, таких как gomock.Any
в Go, может снизить строгость проверок аргументов. Когда метод принимает любой аргумент, это может скрыть потенциальные проблемы, связанные с изменением структуры данных.
mockService.EXPECT().SendRequest(gomock.Any()).Return(nil)
Этот код позволяет методу SendRequest
принимать любые аргументы, что удобно, если конкретное значение неважно для теста. Однако это также уменьшает точность тестов.
Вернемся к Васе.
Допустим, метод SendRequest принимает на вход структуру с полями ID, Name и Content. Вася работает над новой задачей, где ему нужно добавить новую информацию в Content в зависимости от условий. При написании условия он допускает ошибку.
Тест, использующий gomock.Any проходит успешно, так как для него важно только то, что метод был вызван. В результате проблема ушла в продакшн, и Вася снова провёл свой выходной, устраняя баги, чего можно было бы избежать с более строгим тестом.
Исправляем тест:
Вместо использования gomock.Any можно добавить более строгую проверку с конкретными значениями:
// Определяем структуру ожидаемого запроса
type Request struct {
ID int `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}
// Создаем ожидаемый объект
expectedRequest := Request{
ID: 1,
Name: "Test",
Content: "This is a test request",
}
// Указываем, что метод должен вызываться с ожидаемым объектом
mockService.EXPECT().SendRequest(expectedRequest).Return(nil)
В этом примере мы определяем структуру Request, которая содержит поля ID, Name и Content. Затем мы создаем экземпляр expectedRequest с конкретными значениями. Это позволяет тесту проверять входящие аргументы и обеспечивает выявление проблем, связанных с изменением структуры данных.
Если у нас есть условия, в зависимости от которых меняются значения полей - нужно добавить тесты для каждого из них.
Итак, теперь мы стали работать с моками более внимательно, проверяем все аргументы и вызовы, а gomock.Any используем только при повторных проверках. Что осталось?
Иногда проверка может быть не так очевидна, если метод должен изменять состояние объекта или делать дополнительные действия. Например, метод мог бы обновлять кэш или инициировать асинхронный процесс, но если тесты игнорируют этот аспект, то ошибка в настройке асинхронного процесса не будет обнаружена.
Допустим, у нас есть система, которая управляет пользователями. Метод, который мы тестируем, не только возвращает информацию о пользователе, но и обновляет состояние системы, записывая идентификатор последнего активного пользователя.
// Структура для хранения данных о пользователе
type User struct {
ID int
Name string
}
// Структура для хранения состояния системы
type SystemState struct {
LastActiveUserID int
}
// Интерфейс для работы с пользователями
type UserDatabase interface {
GetUser(id int) (User, error)
}
// Метод, который возвращает пользователя и обновляет стейт
func GetUserAndUpdateState(db UserDatabase, state *SystemState, userID int) (User, error) {
user, err := db.GetUser(userID)
if err != nil {
return User{}, err
}
state.LastActiveUserID = user.ID
return user, nil
}
Теперь рассмотрим, как мог бы выглядеть тест на этот метод:
func TestGetUserAndUpdateState(t *testing.T) {
mockDatabase := NewMockUserDatabase(t)
state := &SystemState{}
// Создаем тестового пользователя
expectedUser := User{ID: 1, Name: "Test User"}
// Настраиваем мок так, чтобы он возвращал ожидаемого пользователя
mockDatabase.EXPECT().GetUser(1).Return(expectedUser, nil)
// Вызываем тестируемый метод
user, err := GetUserAndUpdateState(mockDatabase, state, 1)
// Проверяем, что не возникло ошибки
assert.NoError(t, err, "expected no error")
// Проверяем, что возвращаемый пользователь совпадает с ожидаемым
assert.Equal(t, expectedUser, user, "expected user to match")
}
Все выглядит неплохо, есть проверка ошибки и значения на выходе, но он не учитывает важную деталь — обновление состояния. Такой тест не отследит, если мы перестанем обновлять state совсем. Чтобы это исправить нам потребуется добавить проверку:
// Проверяем, что состояние изменилось правильно
assert.Equal(t, expectedUser.ID, state.LastActiveUserID, "Expected LastActiveUserID to match the retrieved user ID")
Готово! Теперь у нас хватит приемов, чтобы отловить мутантов и сделать наши тесты действительно полезными и качественными:)
В этой статье я постаралась выделить ключевые моменты, с которыми мне приходилось сталкиваться на практике при написании unit-тестов. Использование этих рекомендаций уже позволит заметить, как качество ваших тестов вырастет, а вместе с ним и уверенность в том, что ваш код действительно работает как задумывалось.
Делитесь своими мыслями и опытом в комментариях! Какие подходы к тестированию вы используете?