Привет, Хабр! Меня зовут Марина, я 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-тестов. Использование этих рекомендаций уже позволит заметить, как качество ваших тестов вырастет, а вместе с ним и уверенность в том, что ваш код действительно работает как задумывалось.

Делитесь своими мыслями и опытом в комментариях! Какие подходы к тестированию вы используете?

Комментарии (21)


  1. PrinceKorwin
    30.10.2024 05:44

    То есть простой вызов функции в тесте уже подойдет, чтобы “покрыть” эту функцию.

    Это странно. Обычно считается вызов строк внутри самой функции. А вызов функции, это просто учет строки где функция вызывается и не говорит о том, что функцию "покрыли".

    У себя в команде при описании задачи в разаработку там же прописываю сценарии проверки имплементации. Разработчики уже знают и пишут по ним сразу тесты.

    На коммит-ревью видишь и изменения и как эти изменения тестируются - сильно проще понимать всё ли нормально.

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

    По результатам подсчета метрик эти команды самые эффективные с точки зрения сроков исполнения, количества закрытых тикетов на количество багов и reopen.


    1. mkarulina Автор
      30.10.2024 05:44

      Про вызов функции - спасибо за замечание, и правда неудачная формулировка.

      У вас очень хороший подход, так и разработчик сразу видит более точные требования и оценка задаче будет более честная:) А ресурсов QA хватает на все задачки?


      1. PrinceKorwin
        30.10.2024 05:44

        Кроме QA есть ещё SVT ребята которые проверят предрелиз на performance regression. Поэтому да, хватает :)


  1. RodionGork
    30.10.2024 05:44

    Хорошо бы ещё научиться понимать какой функционал годится для проверки юнит-тестами, а какой нет :)

    Метод, который мы тестируем, не только возвращает информацию о пользователе, но и обновляет состояние системы

    Иногда тесты даже подсказывают, даже кричат "что за хрень ты написал???" В данном случае метод заметно нарушает модные принципы дизайна, о которых так любят спрашивать на собеседованиях, но напрочь забывают непосредственно при разработке - он и в базу лезет дублируя функционал db.GetUser(...) - это более низкий уровень и мутирует переданный параметр по ходу. Да, по логике разработчика "так задумано". Но тест выглядит подозрительно: он тупо повторяет код. Он не проверяет логику, он проверяет что код написан тестировщиком и программистом одинаково.

    Затрудняюсь сказать "как должно быть" т.к. не знаю деталей задачи - возможно это абсолютно вымышленный тестовый пример и такой вопрос вообще не стоит. Но в общем хочется подчеркнуть - к тестам надо прислушиваться - они порой подсказывают что надо код порефакторить :)


    1. mkarulina Автор
      30.10.2024 05:44

      Спасибо за комментарий!
      Пример тут и правда сильно упрощен, чтобы просто визуализировать смысл содержания.
      Основное, что хотелось показать - что результатов вызова метода может быть несколько и привычный нам тест может покрыть не всю логику:)


  1. qeeveex
    30.10.2024 05:44

    А как мутационные тесты в Go делать?


    1. mkarulina Автор
      30.10.2024 05:44

      У нас используется доработка этой библиотеки go-mutesting
      Тут форк, который можно использовать - https://github.com/avito-tech/go-mutesting


      1. zloddey
        30.10.2024 05:44

        Что у неё по скорости работы?

        Я в паре своих питонячьих проектов использую mutmut, и у него со скоростью всё достаточно грустно. Если в норме все тесты пробегают за несколько секунд, то при запуске мутатора можно идти пить кофе. Оно и неудивительно: для проверки условных 500 мутантов (проект небольшой) требуется запустить все тесты до 500 раз (до первого падения). Метод реально хорош, но к скорости тестов весьма чувствителен.

        Как у вас с длительностью дело обстоит, если не секрет? Не слишком ли удлинняется пайплайн?


        1. mkarulina Автор
          30.10.2024 05:44

          Тут все правда, увы, количество мутаций очень большое)
          Если говорить о рабочих проектах и внедрении регулярных проверок - наверное, лучше не блокировать изменения, а гонять с какой-то периодичностью на мастере


      1. evgeniy_kudinov
        30.10.2024 05:44

        Пожалуйста, выкатите статью о применении go-mutesting в практике на реальных кейсах и как внедрили в процессы конвейерной разработки


        1. mkarulina Автор
          30.10.2024 05:44

          Есть такая статья на эту тему, надеюсь, будет полезна - Мутационное тестирование: опыт внедрения на 1500 сервисов


  1. ruomserg
    30.10.2024 05:44

    Первое - блокер в пайплайне требующий покрытия тестами без возможности override - есть чистое зло. Ибо девелоперы будут писать тесты которые ставят своей целью "потрогать" лежащий код - и вы потеряете ценный диагностический критерий. В общем - это опять классическая ошибка менеджмента: диагностический критерий сделать целевым. Низкий процент покрытия должен трансформироваться в задачу в бэклоге - проанализировать компонент, и определить сценарии которые сейчас не покрыты тестами. А потом в задачи - покрыть эти сценарии. Вместо этого, менеджмент срезает угол и ставит задачу: увеличить покрытие тестами - а еще со времен советской армии известно: как задача поставлена, так она и выполняется...

    Второе - спорное ИМХО, но я пришел к выводу что если в вашем юнит-тесте есть моки, то или у нас проблема с архитектурой, или это не юнит-тест. Юнит-тест должен тестировать конкретный нетривиальный алгоритм. Вот например если у нас в коде есть метод расчета контрольной цифры EAN13 штрих-кода - его можно тестировать юнит-тестом. А когда мы начинаем мокать базу данных - ну такое себе... Юнит тесты в этом случае начинают вырождаться в психиатрический тест: что наши предположения по поводу кода в момент его написания соответствуют нашим же предположениям в момент тестирования. Нет, я не спорю - иметь подтверждение что разработчики не страдают раздвоением личности - это неплохо для бизнеса. Но не то, чего бы нам хотелось на самом деле...

    Третье - сама идея юнит-тестов рождалась во времена когда мы писали 90% кода, и 10% использовали библиотек. Сейчас, наши приложения - это небольшие плагины, которые кастомизируют поведение гигантских фреймворков. Если ваш тест не запускает эти скрытые слои кода и не проверяет что вы их правильно кастомизировали - он имеет мало отношения к реальной жизни. Я встречал приложение с идеально зелеными тестами, которое в реальности даже не запускалось (забыли миграции БД положить). И толку в таком тестировании ?! Если мы хотим работающее приложение - надо заводить тест-контейнеры и заменять тучу юнит-тестов с моками - нормальными интеграционными и компонентными тестами.

    Четвертое - компонентное и интеграционное тестирование нами в теоретическом и практическом смысле поняты намного хуже юнит-тестирования. Особенно - как его сделать разумным с точки зрения стоимости написания и поддержки. Но, по моему опыту - за этим скорее всего будущее...


    1. evgeniy_kudinov
      30.10.2024 05:44

      если в вашем юнит-тесте есть моки, то или у нас проблема с архитектурой, или это не юнит-тест.

      спорное утверждение, имхо


      1. ruomserg
        30.10.2024 05:44

        Я не настаиваю, и даже в моем ИМХО может быть много исключений. Но в реальности - когда люди мокают БД или репозиторий - оно потом берет, и не работает! Потому что аннотации Transactional расставили неправильно. Потому что Hibernate словил на границе транзакционного блока эксепшн, и пометил всю транзакцию как roll-back only. Потому что Lazy loading proxy не может материализоваться из-за рано закрытой сессии, и так далее. И даже H2 в качестве БД для тестов - ведет себя не так как работает реальный Постгрес или Оракл. Соответственно - если мы тестим БД, а не делаем вид - значит добро пожаловать в testcontainers...

        А если мы не тестируем БД в юнит-тесте - тогда объясните, зачем ее мокать ?! Если вам нужен для юнит-теста какой-то объект, не надо изображать что вы его получили из БД - создайте руками через билдер или конструктор то что вам нужно для юнит-теста, и протестируйте. А вот если у вас приложение написано так, что вы не можете добраться до тестируемого алгоритма потому что там в коде подход винтика-шпунтика "нам нужен пылесос - так давайте же наделим его еще и функциональностью холодильника!" - то есть один и тот же метод и извлекает объект, и проводит над ним нетривиальные преобразования - то это именно то, что я называю проблемой архитектуры. По науке, нужно бить функционал на извлечение и на преобразование (и юнит-тестом покрывать второе, но не первое). Но если времени нет, архитектуру менять поздно - тогда начинаются танцы с моками...


        1. summerwind
          30.10.2024 05:44

          А если мы не тестируем БД в юнит-тесте - тогда объясните, зачем ее мокать ?!

          Мне кажется, вы обманываете сами себя. Хотите вы этого или не хотите, но каждый тест явно или неявно тестирует все зависимости, которые создаются при его инициализации. Поменялась версия драйвера БД, обновилась версия СУБД, поменялась реализация ORM библиотеки для работы с БД - все это потенциально может привести потом к падению любого теста, в котором есть даже глубоко вложенная зависимость на реальную БД.

          И вот с такой логикой как у вас потом возникают проекты с десятками и сотнями тестов, которые явно или неявно тестируют ВООБЩЕ ВСЕ - реальную БД, реальные HTTP вызовы, реальные вызовы к брокерам сообщений. И авторы таких проектов гордо называют все это юнит-тестами, хотя никакие это не юнит тесты, а интеграционные. Я уже имел печальный шанс переписывать такие проекты - вносить какие-либо изменения с такими тестами это то еще "удовольствие".

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

          Так что если у вас обращение к БД (HTTP/Kafka, подставить нужное) не вынесено в отдельный слой, а пронизывает весь код так, что его невозможно нормально протестировать без использования реальной БД, так это вопросы к качеству кода, а не "моки это вредно". Обратитесь к истокам, вспомните, что такое "D" в SOLID, что такое тестируемый код, и все встанет на свои места.

          И если нужно будет протестировать слой работы с БД, слой работы с HTTP, слой работы с брокером сообщений, можно написать отдельный небольшой набор интеграционных тестов, которые тестируют именно взаимодействие, а не логику. А в юнит тестах все такие внепроцессные зависимости подменять.

          Возвращаясь к первоначальной процитированной фразе, перефразирую ее с точностью до наоборот - если мы явно не тестируем в юнит тесте БД - зачем нам там зависимость на реальную БД?


          1. ruomserg
            30.10.2024 05:44

            Я же не предлагаю заменить юнит-тесты - интеграционными и компонентными! Для меня идеальный юнит-тест - это создали входной объект(ы), скормили в тестируемый метод - получили объект-результат, заассертили его. Еще раз приведу пример про алгоритм вычисления контрольной цифры штрих-кода EAN13 - этой части программы пофиг на то, что на самом деле EAN13 это признак SKU который на самом деле привязан к корпоративному справочнику товаров. Задача тестируемого метода - получить 12 знаков штрих-кода и правильно рассчитать 13-й. И вот это мы тестируем юнит-тестом.

            А моки у нас появляюся когда ? Когда алгоритм вычисления принимает на вход SKU, сам лезет в базу и у вытащенного значения считает контрольный разряд. Но тут я с вами соглашусь - это нарушение SOLID - и поэтому начинается страдание над моками.

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

            Что касается юнит-тестов из серии "давайте ткнем слой логики, и проверим что он сходит в БД и возьмет данные" - я считаю их преимущественно вредными. Любой интеграционный, а еще лучше - компонентный тест вам как побочный эффект прекрасно проверит - ходит ваш слой логики в БД, или не ходит...

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

            А большой ошибкой я считаю обматывать приложение тестами бездумно, по-принципу "больше бумаги - чище задница!". Тогда фактически, тесты перестают покрывать функционал - "что!" должно быть сделано, и вместо этого начинают тестировать структуру и связи внутри приложения - "как!" должно быть сделано: вызывает ли этот метод - вот тот ?; ходит ли оно в базу данных ?;принимает ли этот метод ровно три параметра ? - и потом получается структура приложения которую невозможно изменить с сохранением функционала: где не потрогай, половина тестов краснеет.


            1. summerwind
              30.10.2024 05:44

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

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

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

              С этим соглашусь. Так, конечно, делать не надо. Но это никак не связано с наличием или отсутствием моков. Никто не обязывает при использовании моков проверять, сколько раз был вызван метод и с какими параметрами - моки всего лишь позволяют обеспечить изолированное тестирование кода. И если такой тест упадет, то только по единственной причине - значит, сломался тестируемый код. А не какая-то из 10 зависимостей.


              1. ruomserg
                30.10.2024 05:44

                Я же не на ровном месте это говорю - по итогам создания и долговременной эксплуатации систем (в том числе, внесения в них изменений). Юнит тесты, в классическом понимании - тестирующие корректность поведения нетривиальных алгоритмов - безусловно, полезны. Более того, чем ниже в слоях кода лежат эти алгоритмы - тем более важно для них покрытие юнит-тестами. Потому что этот код прямо или косвенно будет переиспользован приложением многократно! Соответственно, я хочу быть уверен что эти кирпичи в основании - они нормальные.

                А теперь внимание - вопрос: что в вашем понимании есть "бизнес-логика" ? Если бизнес-логика - это "получить входящий REST-call, сходить в базу и извлечь сущность, изменить ее, записать обратно, вернуть response-entity" - то я категорически против того, чтобы это тестировать unit-тестами. Здесь юнит-тест должен покрыть единственное нетривиальное действие "изменить ее". Все остальное - не тестирует бизнес-логику, а тестирует структуру приложения: вызывается ли репозиторий из контроллера, вызывается ли код изменения, и так далее... В моей практике - эти тесты ничего не дают, но их поддержка постоянно стоит денег.

                Более того - сложная "бизнес-логика" в большой системе имеет свойство быть кастомизированной. Пример - в большой системе есть метод resolveBarcode который пытается по входящей строке символов определить, является ли это корректным штрих-кодом, и если да - то чего именно ? Как он реализован ? А разумеется через chain-of-responsibility, которая последовательно вызывает частные распознаватели штрих-кодов начиная от общеупотребительных типа EAN13 или ITF14 до внутренних кодировок предприятий-партнеров. А где задается порядок и состав делегатов-опознавателей ? Правильно - в конфиге. Следовательно, если вы не делаете компонентный тест - спринг-бут вам не поднимет контекст. А нет контекста - нет бинов-делегатов в resolveBarcode. И какую "бизнес-логику" вы хотите тут проверить юнит-тестом ?! Индивидуальные делегаты и алгоритмы в них - понятное дело тестами покрыты. А то, что из индивидуально правильных кирпичей построено правильное здание - проверяется компонентными тестами. Более того, в разных проектах эти тесты еще и разные - потому что разные клиенты хотят одним и тем же кодом реализовать - сюрприз! - разную бизнес-логику!

                Соответственно по мокам - моя позиция такая, что это наличие мока в тесте - это скорее всего (не впадая в истерику и экстремизм) - признак проблемы. Либо у вас код так написан, что вы не можете изолировать execution path для юнит-теста, либо вы тестируете слишком большой кусок кода, либо на самом деле вы не хотите мок, а вы хотите проверить взаимодействие частей системы - и для этого вам лучше подойдет тест где все эти части будут реально присутствовать в конфигурации похожей на реальную. Потому что юнит-тесты по своей природе очень плохо предназначены для отслеживания побочных эффектов stateful кода. А как только execution path уходит в дебри spring-boot, hibernate и товарищей - это оно и есть. И побочные эффекты их состояний влияют на ваш код. Поэтому (если вам нужна гарантия что приложение заработает у клиента) - вы все-равно будете тестировать его компонентными и интеграционными тестами чтобы убедиться что разные случайности и неприятность во взаимодействии с фреймворком его не роняют. А если так - то зачем нужен слой юнит-тестов, которые принципиально тестируют то же самое, только с моками ?

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


    1. mkarulina Автор
      30.10.2024 05:44

      Тут допускаю, что опыт у всех разный
      Насчет юнит и интеграционных тестов - это все же две разные сущности, которые не исключают друг друга. Более того, юнит тесты позволят покрыть, условно, 500 сценариев бизнес-логики без похода в базу, что сильно облегчит разработку и избавит от необходимости поднимать каждый раз весь контур.
      Интеграции - это очень важно и их обязательно нужно проверять, вы правы, но с наличием юнит тестов вы сильно сократите количество интеграционных (в этом примере потребуется добавить только кейсы на запись в бд)


      1. ruomserg
        30.10.2024 05:44

        Я не против юнит-тестов как таковых. Напротив - за. Но без моков. И в последнее время я начинаю быть против того, чтобы каждый разработчик себе создавал тестовые объекты для юнит-теста. Потому что потом начинатся зоопарк, и у каждого человека оказываются свои представления о том, как на самом деле будут выглядеть объекты с которыми мы должны работать. Последние два-три проекта я начал внедрять в практику "справочник тестовых объектов", за которые в конечном счете отвечают не разработчики и QA+BA (ну или да, сам разработчик - если он для этого модуля сам-себе QA+BA). Смысл в том, что даже для юнит-теста, мы берем заготовленный (корректный с точки зрения бизнеса) объект, кастомизируем его чуть-чуть если нужно (чтобы избежать экспоненциального разрастания числа тестовых объектов в справочнике) - и уже то что получилось используем как вход в тесте. Иногда это позволяет выловить нетривиальные ошибки и взаимные влияния (которые бы не проявились если бы разработчик тупо забил поле которое его не интересовало null-ом, или значением "от балды").


  1. scome
    30.10.2024 05:44

    Спасибо за статью!

    В рамках темы мне очень зашла книга Владимира Хорикова «Принципы юнит-тестирования» - зашли идеи главенства подсчета покрытия не строк кода, а ветвлений, плюс по полкам раскладывается про тестируемость кода и способы ее повышения/обеспечения.