Глобальное состояние и тестирование через внешнее API
February 4, 2026
Введение
У меня недавно в Проекте Э случилась поучительная и многогранная история. Эта история одновременно:
- Иллюстрирует как именно глобальное изменяемое состояние усложняет поддержку кода. И соответственно, почему его стоит избегать.
- Показывает, что зелёные тесты на внешнее API не гарантируют отсутствие тупых багов внутри.
История
Началась эта история с того, что в админке одного из вспомогательных сервисов Проекта Э мне потребовалось научить PATCH-метод сбрасывать значение пары свойств (выставлять их в null). А эта задача в языках с типами является известной болью (1, 2, 3), потому как по значению null поля входящей DTO невозможно понять — «клиент хочет выставить это поле в null» или «клиент не хочет менять это поле».
Для Java/Kotlin/Jackson в целом есть целый набор устоявшихся решений этой задачи:
- C jackson-modules-java8 (или Jackson3) в DTO можно использовать
Optional<…>?(нуллабельный Optional); - Можно присылать JSON Merge Patch и делать патчинг с помощью Jackson-овго
JsonMergePatch; - Можно присылать JSON Patch и делать патчинг с помощью Jackson-овго
JsonPatch(пример по той же ссылке, что и выше); - Можно использовать jackson-databind-nullable (осторожно: модуль с сентября 2025 года ищет маинтейнеров).
Но я решил не использовать ни одно из этих решений.
Optional — вообще не идиоматично для Котлина, а нуллабельные Optional-поля — ещё и для Java.
А тащить варианты с Jackson/Json в слой приложения/домена, где у меня выполняется патчинг сущностей, я счёл неправославными.
Кроме того, в моём случае была сложность в том, что мне патч надо было собрать из двух частей — json с полями (что и как патчим) и контент для поля с картинкой (как патчим поле с картинкой, если загружаем новую).
Поэтому я решил запилить небольшой велосипедик, с кастомным типом для патча поля (PatchField), превращением любого типа с PatchField-полями в мапу и патчингом любого data-класса этой мапой с помощью рефлексии:
interface Patch
sealed interface PatchField<out T> {
val value: T?
val isChange: Boolean
get() = this is Set
@Suppress("UNCHECKED_CAST")
fun <R> map(fn: (T) -> R): PatchField<R> =
when (this) {
is Set if value != null -> Set(fn(value))
else -> this as PatchField<R>
}
data object Unchanged : PatchField<Nothing> {
override val value = null
}
data class Set<T>(override val value: T?) : PatchField<T>
}
@Suppress("UNCHECKED_CAST")
fun <T : Any> patch(
target: T,
patch: Map<String, Any?>
): T {
val targetClass = target::class
require(targetClass.isData) {
"Target must be a data class: ${targetClass.simpleName}"
}
val constructor = targetClass.primaryConstructor
?: error("Data class must have a primary constructor")
val targetProps = targetClass.memberProperties
.associateBy { it.name }
val args = constructor.parameters.associateWith { param ->
val name = param.name
?: error("Unnamed constructor parameter")
val targetProp = targetProps[name]
?: error("No property '$name' in target")
targetProp.isAccessible = true
val currentValue = targetProp.getter.call(target)
val newValue = if (name in patch) {
patch[name]
} else {
currentValue
}
newValue
}
return constructor.callBy(args)
}
fun Patch.toPatchMap(): Map<String, Any?> =
this::class.memberProperties
.asSequence()
.filter { it.returnType.isSubtypeOf(PatchField::class.starProjectedType) }
.map { prop -> prop.name to (prop.getter.call(this) as PatchField<*>) }
.filter { (_, field) -> field.isChange }
.associate { (name, field) -> name to field.value }Запилил, перевёл логику на работу через него, дописал тесты на новое поведение, прогнал их, все прошли, закоммитил, запушил, задеплоил и пошёл пилить следующую фичу.
А спустя какое-то время на дейликах юниор начал жаловаться, что чёт нифига не работает: при попытке обновления сущности бэк начинает валится из-за того, что что-то там приватное недоступно.
Но я был занят уже другой фичей, и ни юниор, ни продакт подключиться меня не просили, поэтому я сам не лез.
Спустя неделю юниор заводит МР с фиксом:

Когда я это увидел, я прям почувствовал, как мои седины покрываются позором.
Но! Тесты-то зелёные! И их 14 (четырнадцать!) штук на один только метод обновления сущности! Четырнадцать, Карл!
Тогда я в мастере написал юнит-тест чисто на patch — и он сразу же упал с java.lang.IllegalAccessException: class kotlin.reflect.jvm.internal.calls.CallerImpl$Constructor cannot access a member of class xxx.config.domain.screens.model.Screen with modifiers "private".
Притом, все АПИ-тесты на апдейт, которые вызывают тот же самый patch, безусловно вызывающий приватный конструктор Screen-а — проходят успешно.
На этом месте я немного прифигел: как такое вообще возможно, что один и тот же код при вызове из разных мест либо всегда работает, либо всегда падает?
Но меня спасла интуиция жопа, испещрённая шрамами: она мне подсказала, что в интеграционных тестах constructor.isAccessible = true вызывается силами Spring Data Jdbc при создании сущностей в БД на этапе сетапа фикстуры 🤦♂️.
Проверил дебаггером — так и есть.
Добавил юнит-тест в МР, прогнал тесты, все прошли, закомиитал, запушил, вмёржил МР и пошёл пилить следующую фичу.
Финита ля комедия.
Выводы
Глобальное изменяемое состояние усложняет поддержку
Эта душещипательная и полная взлётов и падений история наглядно демонстрирует что такое «локальность рассуждений» (reasoning locality) и её отсутствие, что такое временнАя сцепленность (temporal coupling) и какие именно проблемы возникают из-за глобального изменяемого состояния (поля constructor.isAccessible).
В данном случае из-за отсутствия локальности рассуждений невозможно объяснить, глядя только в код метода patch, почему он работает в одних тестах и не работает в других.
Для того чтобы это объяснить, надо восстановить цепочку, что в случае интеграционных тестов, сначала выполнялась вставка фикстурных данных в БД силами Spring Data Jdbc, который, в свою очередь, при построении мета-модели данных и, в частности, создании PreferredConstructor : InstanceCreatorMetadataSupport делает ctor.setAccessible(true)
ВременнАя связанность выражается в том, что корректность работы метода patch (и метода обновления в целом) зависит от того, был ли ранее вызван метод создания сущности (или любой другой метод, который делает constructor.isAccessible = true).
В общем, глобальное изменяемое состояние может вас сильно удивить и сожрать от несколько часов до дней на расследование, что вообще происходит.
И хотя в реальном мире полностью обойтись без глобального состояния не получится, в прикладном коде количество изменяемых глобальных переменных можно свести буквально к единицам. Упростив тем самым себе жизнь и поддержку кода.
Зелёные тесты на внешнее API не гарантирует отсутствие тупых багов внутри
Интернет полон мемов, высмеивающих юнит-тесты.
![]() | ![]() | ![]() |
И в моей практике наборы тестов, состоящие исключительно из юнит-тестов, действительно наносят больше вреда, чем пользы. Они препятствуют рефакторингу тем, что слишком плотно связаны с реализацией, и при этом не дают вообще никакой уверенности в том, что система работает.
Именно поэтому Эргономичный подход (вслед за xUnit Patterns, кстати) фокусируется на тестировании через внешнее API (storytest-driven development с дополнением юнит-тестов для покрытия непокрытых путей).
И вот в первый раз за почти шесть лет этой практики я столкнулся с ситуацией, когда в код, покрытый тестами вдоль и поперёк, казалось бы, закралась тупая ошибка в хэппи пасе и тесты это не отлоовили.
В общем, мемасы высмеивающие интеграционные тесты тоже есть:

Тем не менее, я считаю, что этот прецедент — не повод смещать фокус на юнит-тесты и уж тем более не повод откзываться от тестирования через внешнее API.
Теоретически, в следствии этого инцидента можно было бы ужесточить стратегию тестирования, добавив правило в духе "каждый метод длиннее двух строк". Но. Сколько времени уйдёт на написание, компиляцию, запуск и поддержку всех этих юнит-тестов? И насколько это повысит надёжность системы?
В общем случае, на мой взгляд, овчинка выделки не стоит и по умолчанию в ЭП останется фокус на тестировании через внешнее API с минимально необходимым (на взгляд и совесть разработчика) количеством юнит-тестов.
А эту историю, пожалуй, надо будет куда-то включить в качестве иллюстрации того, что критически важный код должен быть дополнительно покрыт юнит-тестами.


