Глобальное состояние и тестирование через внешнее API

February 4, 2026

Введение

У меня недавно в Проекте Э случилась поучительная и многогранная история. Эта история одновременно:

  1. Иллюстрирует как именно глобальное изменяемое состояние усложняет поддержку кода. И соответственно, почему его стоит избегать.
  2. Показывает, что зелёные тесты на внешнее API не гарантируют отсутствие тупых багов внутри.

История

Началась эта история с того, что в админке одного из вспомогательных сервисов Проекта Э мне потребовалось научить PATCH-метод сбрасывать значение пары свойств (выставлять их в null). А эта задача в языках с типами является известной болью (1, 2, 3), потому как по значению null поля входящей DTO невозможно понять — «клиент хочет выставить это поле в null» или «клиент не хочет менять это поле».

Для Java/Kotlin/Jackson в целом есть целый набор устоявшихся решений этой задачи:

  1. C jackson-modules-java8 (или Jackson3) в DTO можно использовать Optional<…​>? (нуллабельный Optional);
  2. Можно присылать JSON Merge Patch и делать патчинг с помощью Jackson-овго JsonMergePatch;
  3. Можно присылать JSON Patch и делать патчинг с помощью Jackson-овго JsonPatch (пример по той же ссылке, что и выше);
  4. Можно использовать 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 }

Запилил, перевёл логику на работу через него, дописал тесты на новое поведение, прогнал их, все прошли, закоммитил, запушил, задеплоил и пошёл пилить следующую фичу.

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

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

Спустя неделю юниор заводит МР с фиксом:

2026 02 04 14 49 23

Когда я это увидел, я прям почувствовал, как мои седины покрываются позором.

Но! Тесты-то зелёные! И их 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 не гарантирует отсутствие тупых багов внутри

Интернет полон мемов, высмеивающих юнит-тесты.

2026 02 03 11 13 53
2026 02 04 09 10 51
2026 02 04 09 11 07

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

Именно поэтому Эргономичный подход (вслед за xUnit Patterns, кстати) фокусируется на тестировании через внешнее API (storytest-driven development с дополнением юнит-тестов для покрытия непокрытых путей).

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

В общем, мемасы высмеивающие интеграционные тесты тоже есть:

2026 02 05 11 06 52

Тем не менее, я считаю, что этот прецедент — не повод смещать фокус на юнит-тесты и уж тем более не повод откзываться от тестирования через внешнее API.

Теоретически, в следствии этого инцидента можно было бы ужесточить стратегию тестирования, добавив правило в духе "каждый метод длиннее двух строк". Но. Сколько времени уйдёт на написание, компиляцию, запуск и поддержку всех этих юнит-тестов? И насколько это повысит надёжность системы?

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

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