Если освоить небольшой список типичных ошибок, возникающих при написании юнит-тестов, то можно даже полюбить писать их. Сегодня руководитель группы разработки Яндекс.Браузера для Android Константин kzaikin Заикин поделится с читателями Хабра своим опытом.


— У меня доклад практический. Надеюсь, он вам всем принесет пользу — и тем, кто юнит-тесты уже пишет, и тем, кто только думает писать, и тем, кто пробует, и у кого не получилось.

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

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

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



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

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



Зачем нужны тесты?

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

Мое мнение — прошу не ассоциировать его со мнением моей команды, — что тесты нам помогают. Они позволяют ваш код прогнать без выкладывания в продакшен, без установки на девайсы, вы его очень быстро запускаете и прогоняете. Вы можете прогнать все corner cases, которые на девайсе и в продакшене вы в жизни не получите, и ваш тестировщик их не придумает. Но вы их как разработчик пытливым умом придумаете, проверите и баги на самой ранней стадии устраните.

Очень важно: тесты рассказывают, как, по мнению разработчика, должен работать код и что, по мнению разработчика, ваши методы должны делать. Это не комментарии, которые отгнивают и через некоторое время из полезных становятся вредными. Бывает, что в комментариях написано одно, а в коде совсем другое. Юнит-тесты в этом смысле не могут врать. Если тест зеленый — он документирует то, что там происходит. Тест поломался — первичный замысел разработчика вы нарушили.

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



Это общая идея всего моего доклада. Можно показать первую строчку и уходить.

Многие считают, что тестовый код — это так себе код, он не для продакшена, поэтому можно его писать так себе. Я с этим категорически не согласен и считаю, что к тестам нужно подходить в первую очередь ответственно, так же как к продакшен коду. Если вы будете подходить к ним так же, то тесты вам будут приносить пользу. В противном случае это будет один головняк.

Если конкретно, две строчки ниже относятся, кажется, к любому коду.

KISS — keep it simple, stupid. Не надо усложнять. Тесты должны быть простые. И продакшен код должен быть простой, но тесты особенно. Если у вас будут тесты, которые просто читать, то это будут тесты, которые, скорее всего, написаны хорошо, они хорошо выразили мысль, их будет легко проверить. Даже во время пул-реквеста человек, который смотрит на ваши новенькие тесты, поймет, что вы хотели сказать. И если что-то сломается, вы легко сможете понять, что случилось.

DRY — don’t repeat yourself. В тестах разработчик часто склонны к тому, чтобы использовать тот запрещенный прием, который в продакшене, кажется, никто не использует — copy paste. В продакшене разработчика, который будет активно копипастить, просто не поймут. В тестах это нормальная практика, к сожалению. Не нужно так делать, потому что — первая строчка. Если вы будете писать тесты по-честному, как настоящий хороший код, тесты будут вам полезны.

Пока мы разрабатывали наши сотни тысяч строк кода, писали тысячи тестов, собирали грабли, у меня накопились типичные замечания к тестам. Я достаточно ленив, и когда я приходил в пул-реквесты и наблюдал одни и те же ошибки, исходя из принципа DRY решил записать эти типичные проблемы, и сделал сначала на внутренней Вики, а потом выложил на GitHub практические test smells, которыми вы можете руководствоваться, когда пишете тесты.



Буду перечислять по пунктам. Инкрементируете у себя в уме счетчик, если вспомните такой test smell. Если вы досчитаете до пяти, то можете поднять руку и закричать «Бинго!» А в конце интересно, кто до скольки досчитал. У меня счетчик будет равен количеству пунктов, я их все сам собирал.


Ссылка на GitHub

Самую сложную вещь в программировании вы знаете. И в тестах это действительно важно. Если вы плохо назовете тест, то скорее всего, не сможете формулировать, что тест проверяет.

Люди — довольно простые существа, они легко ловятся в ловушку имен. Поэтому прошу вас хорошо называть тесты. Формулируйте, что тест проверяет, и следуйте простым правилам.

no_action_or_assertion


Если в названии теста нет описания того, что тест проверяет, например, у вас есть класс Controller, и вы пишите тест testController, что вы проверяете? Что этот тест должен сделать? Скорее всего, либо ничего, либо слишком много вещей проверять. Ни то, ни другое нас не устраивает. Поэтому в имени теста надо написать, что мы проверяем.

long_name


Нельзя вдаваться и в другую крайность. Имя теста должно быть достаточно коротким, чтобы человек легко мог его распарсить. В этом смысле Kotlin прекрасен, потому что он позволяет писать имена тестов в кавычках с пробелами нормальным английским языком. Их читать проще. Но все равно длинные имена — это smell.

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

older_prefix


Это атавизм. Раньше в Java все тестировали при помощи JUnit, где до четвертой версии было соглашение, что тестовые методы должны начинаться со слова test. Так сложилось, до сих пор все так называют. Но тут есть проблема, в английском языке слово test — это глагол «проверить». Люди легко ловятся на эту ловушку, и больше не пишут никаких других глаголов. Пишут testController. Себя легко проверить: если вы не написал глагол, что должен делать ваш тестовый класс, скорее всего, что-то вы не проверили, не написали в названии достаточно хорошо. Поэтому я всегда прошу из названий тестовых методов убирать слово test.

Я рассказываю очень простые вещи, но как ни странно, они помогают. Если тесты называются хорошо, скорее всего под капотом они будут неплохо выглядеть. Это очень просто.



Я фактически зачитываю идентификаторы test smells как на GitHub. Ссылка внизу, можете ходить и пользоваться.

multiple_asserts


В тестовом методе встречается много ассертов. Так может быть или нет? Может быть. А хорошо это или плохо? Я считаю, что это очень плохо. Если вы написали в тестовом методе несколько ассертов, то вы проверяете несколько утверждений. Если вы проверяете ваш тест, и упал первый ассерт, дойдет ли тест до второго ассерта? Не дойдет. Вы уже после падения вашей сборки где-то на CI получите, что тест упал, пойдете что-то исправить, зальете заново, он упадет на следующем ассерте. Это вполне может быть.

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

Еще несколько ассертов могут маскировать за собой разные действия, которые производятся с тестовым классом. Я рекомендую писать один тест — один ассерт. При этом ассерты могут быть довольно сложные. Мой коллега в самом первом докладе демонстрировал кусочек кода, где использовал великолепную конструкцию assertThat и матчер. Я очень люблю матчеры в JUnit, так тоже можно использовать. Для читателя тестов он получается просто одним коротким оператором. На GitHub есть примеры всех этих smells и как их исправить. Там есть пример плохого кода и рядом хорошего. Это все сделано в виде проекта, который вы можете загрузить, открыть, скомпилировать и прогнать все тесты.

many_tests_in_one


Следующий smell тесно связан с предыдущим. Вы делаете что-то с системой — делаете ассерт. Делаете еще что-то с системой, длинные какие-то операции — делаете ассерт — делаете еще что-то. Фактически вы просто распиливаете на несколько методов, и у вас получаются цельные хорошие тестовые методы.

repeating_setup


Это относится к многословию. Если у вас есть тестовый класс, и в каждом тестовом методе в начале выполняются одни и те же методы.

Тестовый класс, в котором у вас в начале выполняются одни и те же методы. Кажется, это немного, но в каждом тестовом методе этот мусор присутствует. А если он общий для всех тестовых методов, то почему бы не унести в конструктор или в блок Before или блок BeforeEach в JUnit 5. Если вы это сделаете, то читаемость каждого метода улучшится, плюс вы избавитесь от греха DRY. Такие тесты легче поддерживать и легче читать.



Надежность тестов — это очень важно. Есть признаки, по которым можно определить, что тест будет флакать, быть то зеленым, то красным. Когда разработчик его пишет, он точно уверен, что он зеленый, а потом почему-то тесты становятся то зелеными, то красными, доставляют нам боль и неуверенность в целом, что тесты полезны. Мы не уверены в тестах, значит, не уверены, что они приносят пользу.

random


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

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

tread_sleep


Классическая проблема, с которой сталкиваются разработчики, когда пытаются протестировать какой-то асинхронный код. Это то, что я в тесте что-то сделал, а потом нужно дождаться, когда оно выполнится. Как сделать? Thread.sleep(), конечно.

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

Thread.sleep() доставляет много боли. Кроме того, что он ухудшает надежность тестов, поскольку позволяет им флакать из-за разных таймингов на девайсах, еще и замедляет выполнение ваших тестов. Кому понравится, что его юнит-тесты, которые должны выполняться миллисекунды, будут выполняться пять секунд, потому что я поставил tread sleep?

modify_global


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

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

@VisibleForTesting


Test smell для продвинутых. Необходимость такую штуку использовать возникает не в первый день, как правило. Вы уже что-то тестировали, и тут вам потребовалось класс перевести в какое-то специфическое состояние. И вы себе делаете бэкдор. У вас есть продакшен-класс, и вы делаете специфический метод, который никогда в продакшен не будет вызван, и через него что-то инжектируете в класс или меняете его состояние. Тем самым злостно нарушая инкапсуляцию. В продакшене у вас класс работает как-то, а в тестах, по сути, это другой класс, вы через другие входы-выходы с ним общаетесь. И тут вы можете получить ситуацию, когда продакшен вы поменяете, а тесты этого не заметят. Тесты продолжают ходить через бэкдор и не заметили, что, например, в конструкторе начали стрелять исключения, поскольку они ходят через другой конструктор.

В целом вы должны тестировать ваши классы через те же входы и выходы, что и в продакшене. Не должно быть доступа к каким угодно методам только для тестов.



Сколько выполняются наши 15 тысяч тестов? Около 20 минут, на каждом пул-реквесте, на Team City разработчики вынуждены ждать. Просто потому что 15 тысяч — это много тестов. И в этом разделе я собрал smells, которые замедляют тесты. Хотя thread_sleep уже был.

unnecessary_android_test


В Android есть instrumentation tests, они прекрасны, они запускаются на девайсе или эмуляторе. Это поднимет ваш проект полностью, по-настоящему, но они очень медленные. И для них нужно даже поднять целый эмулятор. Даже если представить, что у вас есть поднятый эмулятор на CI — так совпало, что он у вас есть, — то выполнение теста на эмуляторе займет гораздо больше времени, чем на хост-машине, например, при помощи Robolectric. Хотя есть и другие методы. Это такой фреймворк, который позволяет вам на хост-машине, на чистой Java работать с классами из Android-фреймворка. Мы используем его достаточно активно. Раньше к нему Google относился несколько с прохладцей, но сейчас про него рассказывают и сами гуглеры на разных докладах, он рекомендуется к использованию.

unnecessary_robolectric


Android-фреймворк в Robolectric эмулируется. Он там не полный, хотя реализация чем дальше, тем полнее. Это почти настоящий Android, только выполняется на вашем декстопе, ноутбуке или CI. Но его тоже не везде нужно использовать. Robolectric не бесплатный. Если у вас есть тест, который вы героически перенесли с Android instrumentation на Robolectric, надо подумать — может, пойти еще дальше, избавиться от Robolectric, превратить его в самый простой JUnit-тест? Robolectric-тесты требуют времени на инициализацию, пытаются загружать ресурсы, инициализируют вашу activity, application и все остальное. Это занимает определенное время. Это уже не секунды, это миллисекунды, иногда десятки и сотни. Но когда тестов много, даже это имеет значение.

Существуют техники, которые позволяют избавиться от Robolectric. Вы можете изолировать свой код через интерфейсы, обернув всю платформенную часть интерфейсами. Тогда будет просто JUnit-хост-тест. JUnit на хост-машине работает очень быстро, там минимальное количество overhead, такие тесты можно запускать тысячами и десятками тысяч, они будут выполняться минуту, единицы минут. Наши тесты, к сожалению, выполняются долго, потому что у нас много Android instrumentation-тестов, потому что у нас есть нативная часть в браузере и мы вынуждены выполнять их на настоящем эмуляторе или девайсе. Поэтому так долго.

Не буду больше вас утомлять. Сколько у вас smells? Пока семь максимум. Подписывайтесь на канал, ставьте звезды.

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


  1. psman
    21.01.2019 12:19

    Мельком пролистывая, подумал: «А Бутрутдинов то что здесь делает»


  1. Xop
    21.01.2019 14:46

    Люто плюсую, правда есть несколько моментов, которые хотелось бы прокомментировать.


    Мое мнение — прошу не ассоциировать его со мнением моей команды, — что тесты нам помогают.

    Т.е. не все в команде согласны, что тесты это хорошо? )


    long_name
    multiple_asserts

    Не совсем согласен. Иногда бывает так, что тестируется какой-то особо хитрый corner case, который возникает после какой-то определенной последовательности действий. Причем добавлять ассерты в промежуточные моменты также бывает полезным, чтобы точно знать, что все действительно идет так, как ожидается. Понятно, что если такая ситуация возникает, то это сигнал к тому, что тестируемый объект вероятно надо как-то распиливать, но далеко не всегда это возможно сделать быстро, особенно если это еще и legacy.


    При этом ассерты могут быть довольно сложные.

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


    random

    Плюс много. Еще могу добавить к перечисленному списку источников рандома — передача get_current_time в тестируемую функцию, которая ожидает время.
    Правда есть отдельный случай, когда рандом имхо оправдан — это property based testing, но там во всех нормальных фреймворках всегда есть возможность воспроизводимости, как правило через seed.


    thread_sleep

    О да. И еще всякие while current_time < last_time + timeout в разных вариациях. Причем часто интерфейс можно очень легко сделать так, чтобы таймстемпы можно было инжектить, но к сожалению все равно находятся товарищи, которые говорят "фуу, только ради инжекта таймстемпов менять интерфейс".


    @VisibleForTesting

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


    1. GreedyIvan
      22.01.2019 10:34

      Не совсем согласен. Иногда бывает так, что тестируется какой-то особо хитрый corner case, который возникает после какой-то определенной последовательности действий.

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

      Иначе мы просто впустую тратим время, инициализируя всё необходимое для всей цепочки вызовов, чтобы нужные состояния создавались самой системой, а не были захардкожены. Это тоже test smell, когда методы тестируются не изолированно друг от друга. И ещё один способ получить флаки тесты.

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

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


      1. Xop
        22.01.2019 13:54

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

        Фикстуры — да, полезны когда какое-то конкретное состояние системы может использоваться в куче разных тестов. Но я говорил про другое — когда есть объект какого-то класса, у него например вызывается два раза метод a(), потом три раза метод b(), а потом снова a() и тут его раскорячивает. Как по мне — вполне нормальный кейс для отдельного изолированного теста, воспроизводящего конкретный баг (при том, что есть также отдельные тесты на более простые ситуации).


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

        Несколько раз перечитал эту фразу, и не понял. Можете пояснить (желательно на примере)?


        Это тоже test smell, когда методы тестируются не изолированно друг от друга.

        Да, отстутвие изоляции между тестами — это фуфуфу. Но я предлагал совсем не это.


        Хороший модульный тест своим фейлом говорит, что «вот этот конкретный метод не работает так, как задуман»

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


        x.setA(10)
        assert x.a() == 10

        это проверка сразу пары методов класса.


  1. wladyspb
    21.01.2019 17:03
    +1

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

    Основные причины — легаси код; свой фреймворк, быстрый но сильно связанный внутри; бизнес логика, на 99% завязанная на работу с базами данных.

    Подозреваю, что я что-то делаю не так, и каждый раз читаю такие статьи в надежде найти в них ответы — и не нахожу. Например, как можно протестировать что при редактировании определённой сущности, в базе данных появляется N связанных сущностей? А если их появление должно зависеть от N переменных и\или переключателей в данных, поступивших с фронта? По идее, для этого нужно полностью переписывать классы работы с базой, чтобы обеспечить возможность мокать их. Или поднимать отдельную тестовую базу, которая будет заполняться тестовыми данными и очищаться после прогона тестов? Ситуация осложняется тем, что местами код работает с ORM, а местами для скорости используются прямые запросы к БД. В проекте используются четыре разные базы данных — MariaDB, ClickHouse, CouchBase и Redis — у всех свои задачи, но проблему тестирования взаимодействия всего этого зоопарка это только усугубляет.

    Если кто-то в меня кинет статьёй о том, как правильно тестировать бизнес логику, завязанную на работу с БД — буду очень признателен…


    1. GreedyIvan
      22.01.2019 10:47

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

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

      Дальнейшее движение по более сложным классам вскроет довольно много интересного в используемой архитектуре, когда вы чуть ли не каждый метод каждого класса будете распознавать как for single use only. Т.е. из-за сильной связанности данный метод применим (полезен) только в данной конкретной ситуации, и развязать весь контекст, чтобы изолированно протестировать этот метод, не так уж и просто.

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

      Практика написания юнит тестов добавляет навык видеть подобный код. Он может быть красивым и стройным, с маленькими понятными классами, но архитектурно не переиспользуемым.

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


      1. andreyverbin
        22.01.2019 11:24

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

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

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


        1. wladyspb
          22.01.2019 11:37

          Да, постоянное мутирование системы тоже вносит свою боль. Места, которые не меняются — уже протестированы руками, и там ничего не ломается, хотя в случае каких либо право и придётся провести регрессионные тесты, и это будет муторно делать руками. Намного чаще баги находятся в местах с экспериментальным функционалом, к которому тз менялось уже несколько раз за полгода, и там постоянно всё переписывается.


      1. wladyspb
        22.01.2019 11:34

        Но проблема же именно в связанности контекстов, которые по большей части лежат в плоскости БД, или вообще в сторонних сервисах. По сути, для того чтобы нормально проверять классы, надо инвертировать зависимости и, например, отказываться от применения орм, поскольку он слабо тестируем. И естественно, выпиливать работу с бд напрямую. Но дополнительные уровни абстракции не только дадут возможность тестирования — они так же принесут свой оверхед, что недопустимо. На данный момент проект держит до 3к рпс, и упирается в коуч по производительности, один из параллельных проектов запущенный на laravel в порядке эксперимента — показал крайне неудовлетворительные результаты — на сравнимой нагрузке падал уже php(даже при полном отключении работы с бд — просто вызов апи). Плюс, по большей части баги находятся на границе бэка и фронта — и нужно по сути проверить что при наличии определённого набора данных в базе(включающих конкретного авторизованного пользователя и набор сущностей привязанных к нему), на фронт будет отдаваться определённый набор данных. Но это, по идее, уже надо интеграционными тестами делать.


        1. GreedyIvan
          22.01.2019 17:09

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

          Вопрос в том, какую конкретно задачу вы хотите решить тестами?

          Если проверять, что методы правильно раскладывают данные в базе данных, то надо делать тестовую бд, in-memory либо ещё как-то.

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

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


    1. andreyverbin
      22.01.2019 11:14

      Начните с интеграционных или e2e тестов для начала. Запустили всю систему, дернули апи и проверили результат, фактически автоматизируете работу тестеров. Таких тестов много не надо, но они ловят очень много багов. Когда есть хоть какая-то уверенность в том, что «тесты прошли, значит система работает», можно уже рефактоить. Если получится сделать хорошую обвязку для интеграционных тестов: in-memory db, фейковые очереди, фейковые платежные системы и email сервисы, то писать юниты может и не нужно совсем, ваши интеграционные тесты будут проверять 90% системы с 10% усилий на написание и поддержку тестов.


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


      1. wladyspb
        22.01.2019 11:21

        Хм. Т.Е. систему, где логика хранится по большей части в бд, имеет смысл обвешивать юнит тестами только на отказоустойчивость и обработку ошибок? Интеграционные тесты да, выглядят более привлекательными и потихоньку уже внедряются — тестеры сами используют сторонние инструменты для проверки цепочек апи вызовов.


        1. Tishka17
          22.01.2019 11:29

          Если у вас логика в БД (например, хранимые процедуры), можно проводить unit-тесты именно реализации логики в БД.


        1. andreyverbin
          22.01.2019 11:45

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

          Мне кажется деление на «юнит» и «не юнит» тесты несколько искусственное. У теста есть характеристики, вроде скорости работы, воспроизводимости, сложности написания, покрытия и т.п. Обычно хочется максимальное покрытие, минимальным количеством, воспроизводимыми и быстрыми тестами и обычно так не выходит. Искусство заключается в выборе удобного компромисса и поддержании его на протяжении жизни системы.


    1. igostv
      22.01.2019 12:25

      Может сложновато будет на русском :) но все же посмотри тут infostart.ru/public/544782, 1С это как раз про БД.


  1. worldmind
    21.01.2019 18:15

    Вот только на днях пролистывал статью и во многом согласен с автором — моульные тесты стали карго-культом, их просто надо делать, а кто его знает зачем?
    Для рефакторинга? Да где вы видели рефакторинг который не затрагивает интерфейсов модулей? Главные проблемы-то с архитектурой, она рефакторится обычно и тогда все эти модельные тесты идут на помойку.


  1. worldmind
    21.01.2019 18:26

    Да и тут пишут:

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

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


    1. kzaikin
      23.01.2019 11:12

      Стоит следить за процентом _не покрытия_ условий тестами, Сонар умеет показывать эту метрику.
      Она не должна быть целью, но хорошо показывает, что происходит с проектом на больших дистанциях
      Например, у нас на проекте процент непокрытых условий в 2016 году был 71%, а сейчас — 42%, и монотонно убывает. Надо заметить, что мы смотрим на общее покрытие от юнит, интеграционных и end-to-end тестов.


      1. worldmind
        23.01.2019 11:36

        Польза конечно от этого есть, но после достижения чего-то близкого к 100% надо будет искать другие метрики.


  1. KaminskyIlya
    22.01.2019 08:38

    Я новичок в тестировании. Тема меня эта интересует: стараюсь изучать соответствующие материалы. Но пока мне не везет, и я не могу получить ответы на свои вопросы.

    Я надеюсь, хабро-сообщество не будет против, если я озвучу здесь эти вопросы?
    Всё-таки Хабр — это соц.сеть, где программисты делятся опытом.

    Ремарка: понимаю, что не все тесты озвученные вопросы ниже относятся к unit-тестированию.
    Ремарка2: некоторые вопросы я озвучивал на Тостере. Но качественных ответов так и не получил.

    1. Нужно протестировать распределение hashCode у своего класса. Вам нужно сделать оценку кэш-промахов.

    2. У вас функция шифрует данные (пусть алгоритмом AES).
    Ваша задача убедиться, что вы правильно шифруете (правильно вызвали библиотечный код — там много подводных камней), и удаленный получатель сможет их расшифровать.
    Проблема: для одной и той же входной последовательности шифр всегда разный.

    3. Ваш класс генерирует параметризируемый mesh 3D-объекта. Пусть это будет куб с закругленными краями. Одна функция — генерирует массив точек, другая — массив индексов точек, связывающих их в полигоны, третья — нормали к вершинам, четвертая — текстурные координаты. Все функции — независимы друг от друга (вообще это чистые функции). Их можно хоть вынести в другие классы. Как протестировать их работу? А именно:
    1) что куб — вообще на куб похож
    2) что все полигоны обращены к наблюдателю, как ожидается
    3) не перепутаны индексы полигонов или текстурных координат

    4. Ваша функция увеличивает среднюю громкость аудиопотока. Как написать тест на это?

    5. Вы реализовали генератор случайных чисел. Как его протестировать?

    6. Функция получает на вход дату рождения человека и возвращает его возраст. Как написать тест на такую функцию?

    7. Есть некий класс reader с двумя функциями: readChar(), isAvailable()
    Первая функция — читает из буфера символ, вторая — проверяет, что буфер не исчерпан.
    (Пусть буфер — это просто массив символов)
    Как написать правильный тест на isAvailable? Функция readChar() влияет на результат работы isAvailable().

    8. Функция получает сложный объект (например, большую json-структуру) и вытягивает нужные ей значения, например так: json.getDeclarant().getPersonalInfo().getIdentityDocument().getIssueDate().
    Как правильно сделать mock такого объекта?

    Благодарю всех, кто даст конструктивные советы.


    1. Xop
      22.01.2019 09:55

      У вас функция шифрует данные (пусть алгоритмом AES).

      assert decrypt(encrypt(data)) == data и проверяете на самых разных data


      Ваш класс генерирует параметризируемый mesh 3D-объекта. Пусть это будет куб с закругленными краями.
      1) что куб — вообще на куб похож.

      Как вариант — реализовать distance_to_cube как функцию расстояния до поверхности обычного куба, и проверять эти расстояния для всех вершин закругленного куба с аналогичными параметрами. Чуть более общий вариант — реализовать distance_to_mesh, генерировать простой куб без скруглений, ну и дальше аналогичная проверка.


      2) что все полигоны обращены к наблюдателю, как ожидается

      Например взять центральную точку куба, и для каждого треугольника assert dot(((a+b+c)/3 - center), cross(b-a, c-a)) >= 0.


      3) не перепутаны индексы полигонов или текстурных координат

      Топологические проверки очень неплохо делать с помощью half-edge структуры данных. Как минимум — если у вас в принципе по набору треугольников получилось построить half-edge граф, то сетка является 2-manifold (а в большинсте подобных случаем именно такая проверка и требуется).


      Ваша функция увеличивает среднюю громкость аудиопотока. Как написать тест на это?

      assert stddev(output) > stddev(input), где stddev — функция рассчета среднеквадратичного отклонения.


      Вы реализовали генератор случайных чисел. Как его протестировать?

      Если не считать очевидных ассертов на вхождение вывода генератора в некий диапазон, если это применимо, то как минимум можно посчитать среднее и СКО, причем лучше делать это примерно так:


      for _ in range(min_iters):
          stats.add(gen_under_test())
      for _ in range(max_iters - min_iters):
          if abs(stats.mean - expected_mean) < tolerance:
              return
          stats.add(gen_under_test())
      assert False, "after maximum iterations mean {} was still far to off from expected {}".format(stats.mean, expected_mean)

      где stats — некий статистический аккумулятор. Смысл — с одной стороны как можно раньше закончить тест, с другой — если прям сильно неудачные значения из генератора выскакивают, то можно было подождать подольше. Хотя в целом имеет право на существование и более простой вариант assert abs(mean(gen_under_test() for _ in range(iter_count)) - expected_mean) < tolerance. Аналогично можно проверять гистограмму на соответствие функции плотности вероятности.


      Функция получает на вход дату рождения человека и возвращает его возраст. Как написать тест на такую функцию?

      Видимо функция каким-то образом получает еще и текущую дату? Если через параметр (или если допустимо вынести текущую дату в параметр) — то ответ наверное очевиден — набором разных примеров-соответствий дата рождения — текущая дата — возраст. Если через какой-то глобальный get_current_date, то мокировать (а если невозможно, то выделять интерфейс и все равно мокировать) get_current_date, и дальше опять наверное очевидно.


      Есть некий класс reader с двумя функциями: readChar(), isAvailable()

      Если длина буфера заранее известна, то первое, что приходит в голову:


      for _ in range(buf_size):
          assert isAvailable()
          readChar()
      assert not isAvailable()

      Но в идеале конечно лучше несколько раздельных тестов.


      1. KaminskyIlya
        22.01.2019 12:38

        Спасибо, за ответы!

        Про генератор случайных чисел: тут я, конечно, лишнее написал. Для них существуют специальные методики.

        Видимо функция каким-то образом получает еще и текущую дату?

        Да, так и есть:
        private function getYearsOld( Date birthDate )
        {
           Date currentDate = new Date(); // 
           ...
        }
        

        Наверное, Вы правы. Стоит передавать текущую дату в качестве второго параметра, превратив функцию getYearsOld в чистую (без явных и скрытых зависимостей). Тогда можно её вынести вообще в отдельный класс (сейчас она private), сделав static public.

        Что касается тестирования reader. В принципе, я так и делал. Но не могу отделаться от ощущения, что это smell-test.

        А кто, что посоветует для 8-го вопроса?


      1. KaminskyIlya
        22.01.2019 13:20

        Deleted


      1. Dima_Sharihin
        22.01.2019 15:36

        assert decrypt(encrypt(data)) == data и проверяете на самых разных data

        Это уже не юнит тест, вы тестируете связку двух методов. К тому же ваш подход даст зеленый свет на таком коде:


        buffer encrypt(buffer unecrypted) {
          return unencrypted;
        }
        
        buffer decrypt (buffer encrypted) {
          return encrypted;
        }

        Что, разумеется, полная чушь.


        Основным главным подходом "тестируемости" кода является возможность сохранять и восстанавливать состояние любого юнита в конкретной точке. Если мы говорим про FSM, то для тестирования перехода нужно сначала встать в нужную точку. Таким образом вы должны подготовить тестируемый ключ и тестируемый вектор инициализации. В ассерте у вас должна быть правильно зашифрованная строка. Тогда можно смело менять реализацию, юнит тест вас заставить соответствовать интерфейсу.


        1. KaminskyIlya
          22.01.2019 23:48

          Это уже не юнит тест

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

          вы должны подготовить тестируемый ключ и тестируемый вектор инициализации

          Есть ещё проблема в том, что библиотека (ГОСТ, например) в шифр вставляет иммитовставки, которые делают на его выходе уникальным. Т.е. для одних и тех же входных данных, вы всегда будете получать разные шифры.
          вы должны подготовить тестируемый ключ и тестируемый вектор инициализации.
          Думаю, это возможно, если вы — автор этой библиотеки и вам нужно её тестировать. Но, имхо, такая методика возможна в рамках шаблона visible_for_testing: вам придется делать бэкдоры для приведения конвейера шифра в нужное вам состояние. Публичных функций для реализации этого быть не должно — ибо вектор атаки.

          Похоже, что методика:
          assert decrypt(encrypt(data)) == data // проверяете на самых разных data

          пока без вариантов


        1. Xop
          23.01.2019 00:59

          Это уже не юнит тест, вы тестируете связку двух методов.

          Я тестирую часть контракта, и у кода нет внешних зависимостей — чем не юнит тест? То, что это связка двух методов — так если например вы будете тестировать даже банальный сеттер, то для проверки вы будете вынуждены либо использовать геттер, что даст опять же связку двух методов, либо лезть до приватного поля, что фуфуфу, поскольку это детали реализации и может сломаться при рефакторинге. Имхо почти всегда в юнит тестах у вас будет связка минимум двух методов.


          К тому же ваш подход даст зеленый свет на таком коде

          Возможно мне следовало уточнить, что предложенный мной тест не является исчерпывающим. Разумеется, необходимо проверять и другие вещи — как минимум неравенство encrypted != decrypted, невозможность расшифровать "чужим" ключом, и т.п. И да, я полностью согласен, что должна быть возможность инжектить nonce, чтобы можно было написать тест-кейсы на конкретные примеры.


    1. GreedyIvan
      22.01.2019 17:50

      2. У вас функция шифрует данные (пусть алгоритмом AES).
      Ваша задача убедиться, что вы правильно шифруете (правильно вызвали библиотечный код — там много подводных камней), и удаленный получатель сможет их расшифровать.
      Проблема: для одной и той же входной последовательности шифр всегда разный.

      Какой контракт мы хотим подтвердить своим тестом? Что клиент расшифрует данные, которые мы ему отправим? Т.е. мы проверяем то, что данные, которым мы отправим, могут быть расшифрованы.
      Тест тут прост и ясен:
      encryptedData = encrypt(expectedData)
      assertDecrypt(expectedData, encryptedData);

      В assertDecrypt мы прячем реализацию decrypt, которую рассматриваем как эталонную: наш контракт таков, что зашифрованные данные будут расшифрованы данной реализацией. Используйте её или совместимую. Мы проверяем, что наша функция encrypt возвращает данные, которые могут быть расшифрованы этой эталонной реализацией.

      Если мы хотим проверить, что мы можем расшифровать некие зашифрованные данные, то подход тот же:
      decryptedData = decrypt(encryptedData);
      assertEqual(expectedData, decryptedData);

      Здесь мы проверяем, что наша реализация получает правильные данные, расшифровывая некие зашифрованные данные.

      Но вот чего точно нельзя делать, так это взаимно использовать decrypt <-> encrypt в рамках одного теста, так как вместо валидации контракта, мы валидируем обратимость функций, о чём тут уже упомянули.


      1. Xop
        23.01.2019 00:55

        В assertDecrypt мы прячем реализацию decrypt, которую рассматриваем как эталонную: наш контракт таков, что зашифрованные данные будут расшифрованы данной реализацией.

        А потом, чтобы протестировать decrypt вы будете писать аналогичный assertEncrypt, в который спрячете "эталонный" encrypt? Но по факту это же будет два одинаковых теста?


        вместо валидации контракта, мы валидируем обратимость функций

        А разве контракт не подразумевает, что функция шифрования должна быть обратимой? В предложенном мной тесте именно эта часть контракта и валидируется, и если я вас правильно понял — в вашем варианте проверяется ровно та же часть контракта.


        1. GreedyIvan
          23.01.2019 09:41

          Вопрос в том, что вы хотите подтвердить тестом, который работает с ответом, полученным от функции encrypt? То, что в вашем проекте есть функция (например, decrypt), которая выдаст ожидаемый результат? Или то, что некая внешняя библиотека может расшифровать эти данные?

          Те же вопросы и для функции decrypt? Вы хотите подтвердить, что эта функция может расшифровать ответ от вашей функции encrypt? Или расшифровать сообщение, зашифрованное какой-то сторонней библиотекой?


      1. kzaikin
        23.01.2019 12:30

        Проверка

        assertThat(decrypt(encrypt(data)), is(data))
        проверяет только, что последовательно вызванные методы вернут исходные данные. Правильно ли происходит шифрование, такой подход не проверит.
        Проверять стоит на референсных тестовых data и encryptedData, которые получены не вашим кодом, а какими-то сторонними утилитами.


  1. Xop
    22.01.2019 09:52

    del, ошибся веткой