Никак не могу оставить в прошлом, одну историю, произошедшую со мной больше 7 лет назад.

На тот момент я, еще студент последнего курса универа, только получил свою первую работу в IT... Как сейчас помню свои эмоции. Наконец‑то, спустя годы подготовок и отказов, получаешь свой первый «настоящий» проект. Осмотревшись по сторонам, понимаю, что кругом меня не то что других джунов нет, но даже мидлов. Сплошные синьоры и лиды, как тогда казалось — грозные дядьки, с большим опытом... Ну ничего, сейчас я им покажу, что такое «молодая гвардия» ?.

Получаю компьютер, креды для доступа, мне подробнее рассказывают про проект, присылают ссылки на минимальный набор сервисов, что нужно будет локально поднять для работы и отправляют настраивать окружение. В первый же день я сломал заботливо предустановленную мне убунту ? (удалил «не ту» версию питона, которая, как выяснилась, очень нужна), ну да ладно, мелочи, с кем не бывает?

Установил минт, начал настраивать иде, окружение, забрал себе нужные сервисы, вроде все хорошо, НО в одном из сервисов стабильно падает один и тот же тест. Запускаю отдельно — все хорошо и стабильно. Запускаю через сборщик (mvn test) — падение. Пытаюсь разобраться, что происходит — ничего не понятно. Тест падает из‑за мока, которого вообще нет в этом тестовом сценарии. Больше того, смущает ситуация, что ни на ci, ни у кого из коллег такого не происходит. Тест стабилен, да и в нем не меняли ничего уже довольно давно. Вывод: проблема на моей стороне и разбираться мне с ней самому.

Пытаюсь откопать, от куда этот мок мог вообще попасть, и сталкиваюсь с суровой реальностью: количество тестов измеряется тысячами, этот мок копипастом использовался во многих из них... Да и не должны, в конце концов моки из одних тестовых сценариев влиять на другие... Ситуация, на тот момент, мне казалось безнадежной. Уже хотел @Ignore повесить над тестом и запомнить, что вот этот тест нужно каждый раз в ручную запускать (не помогло. Начал падать следующий тест, а за ним еще один, все явно по той причине). Ладно, оставил как есть. Просто будем локально игнорировать ошибку, пока не разберусь в чем дело.

Примерно 2 недели... Тот срок, за который я, по мимо своих полученных задач пытался разобраться, что же происходит. А происходило следующее:

  1. примерно пол года назад проводился небольшой рефакторинг

  2. в рамках этого рефакторинга, один из тестируемых классов перестал напрямую использовать эту утилитную функцию

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

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

  5. при настройке нового окружения, я использовал новую версию мавена (естественно, уже не вспомню, о какой версии речь, но не суть). Отличалась PATCH версия, но этого оказалось достаточно, чтоб запустить тесты в другом порядке и ошибка, СПУСТЯ ПОЛ ГОДА, начала проявляться.

Наблюдая дальше, как на этом проекте, так и на других проектах, в других компаниях, которые писали ��овсем другие люди, я вижу одну и ту же закономерность: моки, из «друзей», с которыми так легко тестировать код, с ростом проекта и сложности стабильно превращаются во «врагов». То что в моменте позволило нам легко проверить, что какой‑то внешний код вызвался с нужными параметрами, что на определенный ответ наша функция повела себя правильно, стабильно приводит к настолько сложным конфигурациям теста, что по ним не то что сложно понять «а что вообще происходит?», но их порой проще полностью переписать, после очередных изменений, чем заставить работать. Минимальные изменения кода приводят к необходимости обновлять кучи тестов. И ведь это синдром.

Тесты, по своей сути, должны быть простым подтверждением, что наш код работает та��, как мы от него ожидаем. Но вместо этого, он становится сложнее, запутаннее, связаннее, чем код, который он должен тестировать. Это не нормально, что вместо того, чтоб посмотреть на тест и понять, как работает код, мы чаще смотрим на код, чтоб понять, как работает тест. Сложность их написания и поддержки на столько большая, что разработчики стараются саботировать тестовое покрытие, оставляя тесты только «на действительно важных» компонентах, а рассказ, что у нас 95+% тестовое покрытие вызывает либо недоверие, либо сочувствие у других разработчиков.

Но ведь мы — разработчики: мы учились писать хороший, поддерживаемый код, который не должен доставлять столько боли. Почему мы не можем справиться с какими-то тестами? Я утверждаю, что проблема в моках. То как мы вынуждены их писать, с ними работать провоцирует нас делать строгое разделение: вот тут у нас наши исходники, где мы стараемся писать чисто, красиво и аккуратно, а вот тут тесты, к которым стандартные приемы не применимы. Тут мы легко занимаемся копипастом, потому что "ну не переписывать же эту портянку из when...thenReturn", тут мы легко делаем бешенную связанность и завязываем наш код на внутреннее состояние, ведь по другому они не конфигруируются и т.д. Этот отказ от стандартных практик и провоцирует нас еще больше захламлять наши тесты и делать их все меньше пригодными для нас самих.

Меньше слов, больше кода?

Вот классический пример теста для проверки применения скидки при заказе:

class DiscountServiceTest {

    @Mock private lateinit var userRepository: UserRepository
    
    @Mock private lateinit var productRepository: ProductRepository
    
    @Mock private lateinit var promoService: PromoService
  
    @InjectMock private lateinit var discountService: DiscountService

    @Test
    fun `should apply promo code`() {
        // Arrange
        val userId = 1L
        val productId = 2L
        val promoCode = Promo("SALE20"
        
        val mockUser = mock<User> {
            on { id } doReturn userId
            on { status } doReturn UserStatus.ACTIVE
        }
        
        val mockProduct = mock<Product> {
            on { id } doReturn productId
            on { price } doReturn Money(1000.0)
            on { category } doReturn Category.ELECTRONICS
        }
        
        `when`(userRepository.findById(userId)).thenReturn(mockUser)
        `when`(productRepository.findById(productId)).thenReturn(mockProduct)
        `when`(promoService.validatePromo(promoCode, Category.ELECTRONICS)).thenReturn(true)
        `when`(promoService.calculateDiscount(promoCode, Money(1000.0))).thenReturn(Money(200.0))

        // Act
        val result = discountService.calculateFinalPrice(userId, productId, promoCode)

        // Assert
        verify(userRepository).findById(userId)
        verify(productRepository).findById(productId)
        verify(promoService).validatePromo(promoCode, Category.ELECTRONICS)
        verify(promoService).calculateDiscount(promoCode, Money(1000.0))
        assertEquals(Money(800.0), result) // 1000 - 20% promo 
    }
}

Мне одному кажется, что вместо того, что тест проверяет скорее свою собственную конфигурацию, чем поведение системы? Зачем-то знает о внутреннем состоянии, какие методы других сервисов и репозиториев вызываются. А как только хоть что-то изменится - наши тесты резко перестанут работать... А теперь представьте, сколько мусора было бы здесь с усложнением логики и как часто приходилось бы его обновлять по любым причинам?

Можно с этим что-то сделать? ДА. И все эти методы, не какое-то ноу-хау, а то, что все давно знали, но незаслуженно забыли (или отказываются применять к юнит и интеграционным тестам). Старые добрые фикстуры, стабы, фейки... Да-да, требуют гораздо больше времени на старте. На них не получится просто повесить аннотацию, и чтоб все заработало. Но какой результат?

class DiscountServiceTest {

    private lateinit var fakeUserRepository: FakeUserRepository
    private lateinit var fakeProductRepository: FakeProductRepository
    private lateinit var fakePromoService: FakePromoService
    private lateinit var discountService: DiscountService

    @BeforeEach
    fun setUp() {
        fakeUserRepository = FakeUserRepository()
        fakeProductRepository = FakeProductRepository()
        fakePromoService = FakePromoService()
        discountService = DiscountService(
            fakeUserRepository, 
            fakeProductRepository, 
            fakePromoService
        )
    }

    @Test
    fun `should apply promo code to expensive electronics`() {
        // Arrange
        val user = Fixtures.createUser()
        val expensiveProduct = Fixtures.createProduct(
            price = 1000.USD,
            category = Category.ELECTRONICS
        )
        val promoCode = Fixtures.createPromocode()
        
        fakeUserRepository.givenUserExists(user)
        fakeProductRepository.givenProductExists(expensiveProduct)
        fakePromoService.givenPromoValid(
            code = promoCode, 
            config = Fixtures.createPromoConfig(
                value = 20.0.percent,
                category = Category.ELECTRONICS
            )
        )

        // Act
        val result = discountService.calculateFinalPrice(
            userId = user.id,
            productId = expensiveProduct.id,
            promoCode = promoCode
        )

        // Assert
        assertThat(result).isEqualTo(
            ExpectedPrice.finalPrice(
                originalPrice = 1000.USD,
                finalPrice = 800.USD // 1000 - 20%
            )
        )
    }
    
    @Test
    fun `should not apply promo for invalid category`() {
        // Arrange
        val user = Fixtures.createUser()
        val foodProduct = Fixtures.createProduct(
            category = Category.FOOD // Промокод не применяется к еде
        )
        val promoCode = Fixtures.createPromocode()
        
        fakeUserRepository.givenUserExists(user)
        fakeProductRepository.givenProductExists(foodProduct)
        fakePromoService.givenPromoValid(
            code = promoCode, 
            config = Fixtures.createPromoConfig(
                category = Category.ELECTRONICS
            )
        )

        // Act
        val result = discountService.calculateFinalPrice(
            userId = user.id,
            productId = foodProduct.id,
            promoCode = promoCode
        )

        // Assert
        assertThat(result).isEqualTo(
            ExpectedPrice.finalPrice(
                originalPrice = foodProduct.price,
                finalPrice = foodProduct.price // цена без изменений
            )
        )
    }
}

Кажется, что почти ничего не изменилось. Но так ли это? Теперь это чистый код на котлине (ничего не мешает сделать то же самое на джаве или любом другом языке с билдерами, если нет поддержки именованных опциональных параметров). Его можно легко продебажить, если что-то вдруг пошло не так. Он не знает, какие именно методы использует calculateFinalPrice. Мы просто декларативно указываем, что хотим получить, и оно работает. А самое прекрасное, что никакие изменения вроде использования других методов сервисов или репозиториев, добавление каких-то полей в любую из сущностей, которая как либо используется, изменение внутренней логики НЕ ВЛИЯЮТ на тест, пока что-то в действительности не сломается. Нам больше не нужно видеть МР-ы, в которых мы добавляем одно поле, и сотни изменений в тестах из-за него. И тесты действительно независимы друг от друга и ни при каких обстоятельствах не будут делать ничего, что мы от них не ожидаем.

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

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


  1. benjik
    25.11.2025 06:46

    Судя по статье, не "Моки - это технический долг", а "Кривые моки и грязный тестовый код - это технический долг".

    В первом примере у вас тесты с моками влезли на один экран. Во втором - на два, и это без определения всех зависимостей FakeЧегоТоТам, которые ещё пару экранов займут.

    Сколько времени займёт "причёсывание" одного экрана с моками и четырёх экранов с самописными фейками при необходимости? Как быстро самописные фейки превратятся в полу-универсальные и кривые самописные моки, когда тестов прибавится?

    Все вот эти

    Его можно легко продебажить

    Мы просто декларативно указываем, что хотим получить, и оно работает

    никакие изменения ... НЕ ВЛИЯЮТ на тест, пока что-то в действительности не сломается

    можно достичь теми же моками, с меньшим количеством строк, просто уделяя какое-то время дизайну тестового кода. Безумный копипаст так же точно угробит FakeЧегоТоТам, только будет ещё больше грязи.


    1. simarel Автор
      25.11.2025 06:46

      В первом примере у вас тесты с моками влезли на один экран. Во втором - на два, и это без определения всех зависимостей

      Да, но там и 2 теста, когда в 1 примере лишь один. Согласен, фейки, фикстуры ещё нужно написать, это тоже занимает место. Да, больше кода. Больше адекватного привычного нам кода который ведёт себя так, как от него это ожидают.

      Кривые моки и грязный тестовый код - это технический долг.

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

      Сколько времени займёт "причёсывание" одного экрана с моками и четырёх экранов с самописными фейками при необходимости?

      Никто ведь и не спорит. Моки на старте, в режиме "один раз написал, забыл и надеешься, что тебе не придётся в этом разбираться" быстрее. А вот с поддержкой... Я бы не сказал. С ростом сложности и моков, поддерживать это куда сложнее, в то время как в фейках большая часть работы легко выносится в довольно простой дженерик, и из собственной реализации только матчеры (при необходимости) или простые и говорящие верифай методы. Там просто нечему становиться сложным


  1. boulder
    25.11.2025 06:46

    Меньше слов, больше кода?
    Да уж лучше наоборот :)
    Если тесты однотипные, то проще один раз хорошенько подумать, создать специализированный формат и задавать тесты, скажем, как: {in: [1, 2, 3], out:[10, 20, 30]}, а не кодом на два экрана (каждый!). Это же совершенно нечитаемо и немодернизируемо (


    1. simarel Автор
      25.11.2025 06:46

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


      1. boulder
        25.11.2025 06:46

        Вы в статье или в описанной работе хотели сразу "проскочить к ... интересному"? :)


        1. simarel Автор
          25.11.2025 06:46

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