Русский
Русский
English
Статистика
Реклама

Функциональное тестирование

Перевод Основы Cat Concurrency с Ref и Deferred

10.03.2021 18:12:48 | Автор: admin

Параллельный доступ и ссылочная прозрачность

Для будущих учащихся на курсе Scala-разработчик приготовили перевод материала.

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


*Concurrency конкурентность, допускающая одновременное выполнение нескольких вычислительных процессов.

Ref и Deferred являются основными строительными блоками в FP, используемыми параллельно, в манере concurrent. Особенно при использовании c tagless final (неразмеченной конечной) абстракцией, эти два блока, при построении бизнес-логики, могут дать нам и то, и другое: параллельный доступ (concurrent access) и ссылочную прозрачность (referential transparency), и мы можем использовать их для построения более продвинутых структур, таких как counters (счетчики) и state machines (конечные автоматы).

Перед тем, как мы углубимся в Ref и Deferred, нам полезно узнать, что concurrency в Cats строится на Java AtomicReference, и здесь мы и начнем наше путешествие.

Atomic Reference

AtomicReference это один из элементов пакета java.util.concurrent.atomic. В Oracle docs мы можем прочитать, чтоjava.util.concurrent.atomic это:

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

Экземпляры классов AtomicBoolean, AtomicInteger, AtomicLong, и AtomicReference обеспечивают доступ и обновление от одиночных переменных к соответствующему типу (функционального блока).

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

Когда вам приходится совместно использовать некоторые данные между нитями (threads), вы должны защитить доступ к этой части данных. Самым простым примером будет увеличение некоторого количества int: i = i + 1. Наш пример состоит из фактически 3 операций, сначала мы читаем значение i , затем добавляем 1 к этому значению, а в конце снова присваиваем вычисленное значение i . В отношении многопоточных приложений, мы можем столкнуться с ситуацией, когда каждый thread будет выполнять эти 3 шага между шагами другого thread, а конечное значение i предсказать не удастся.

Обычно в вашей голове появляется слово synchronised или механизм класса lock, но с atomic.* вам больше не нужно беспокоиться о явной синхронизации, и вы можете перейти на предоставленные atomic (атомарные) типы утилит, где проверка выполнения операции в один шаг включается автоматически.

Давайте, возьмем для примера AtomicInteger.incrementAndGet:

/**     * Atomically increments by one the current value.     *     * @return the updated value     */    public final int incrementAndGet() {        for (;;) {            int current = get();            int next = current + 1;            if (compareAndSet(current, next))                return next;        }    }

С помощью операции compareAndSet мы либо обновляем наши данные, либо терпим неудачу, но никогда не заставляем thread ждать. Таким образом, если операция compareAndSet в incrementAndGet не удаётся, мы просто пытаемся повторить всю операцию заново, извлекая текущее значение наших данных с помощью функции get() в начале. С другой стороны, при использовании синхронизированных механизмов нет ограничений на количество операторов (statement), которые вы хотите выполнить во время блокировки, но этот блок никогда не выйдет из строя и может заставить вызывающий thread ждать, предоставляя возможность заблокировать или снизить производительность.

Теперь, зная определенные основы, давайте перейдем к нашей первой мега-звезде concurrency.

Ref

Ref в Cats очень похож на упомянутую выше atomic (атомарную) ссылку Java. Основные отличия заключаются в том, что Ref используется с tagless final абстракцией F . Он всегда содержит значение, а значение, содержащееся в Ref типа A, всегда является неизменным (immutable).

abstract class Ref[F[_], A] {  def get: F[A]  def set(a: A): F[Unit]  def modify[B](f: A => (A, B)): F[B]  // ... and more}

Ref[F[_], A] это функциональная изменяемая (mutable) ссылка:

  • Concurrent ( конкурентная)

  • Lock free ( без блоков)

  • Всегда содержит значение

Она создается путем предоставления начального значения, и каждая операция осуществляется в
F, например, cats.effect.IO.

Если мы внимательно посмотрим на сопутствующий объект для Cats Ref, мы увидим, что наша F должна соответствовать некому требованию, а именно быть Sync.

def of[F[_], A](a: A)(implicit F: Sync[F]): F[Ref[F, A]] = F.delay(unsafe(a))

Вышеприведенный метод является лишь примером многих операций, доступных на нашем Ref; он используется для построения Ref с исходным значением.

Sync дает нам возможность приостанавливать любые побочные эффекты с помощью метода
delayдля каждой операции на Ref.

Ref довольно простая конструкция, мы можем сосредоточиться в основном на ее get, set и of чтобы понять, как она работает.

Метод get and set

Допустим, у нас есть объект (для этого блога мы назовем его Shared), который нужно обновить несколькими threads, и мы используем для этого наши методы get и set , создавая утилитный метод, который поможет нам в дальнейшем:

def modifyShared(trace: Ref[IO, Shared], msg: String): IO[Unit] = {for {sh <- trace.get()_ <- trace.set(Shared(sh, msg))} yield ()}

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

Я только что создал Shared(prev: Shared, msg: String) для данной статьи.

В нашем примере выше F был заменён конкретным IO из Cats Effect, но имейте в виду, что Ref является полиморфным в F и может быть использован с другими библиотеками.

С помощьюmonadic (монадический) IO мы применяем функцию flatMap на каждом шаге и устанавливаем значение, сохраненное в нашем Ref на желаемое значение или... подождите, может быть, мы этого не делаем.

При таком подходе, когда modifyShared будет вызываться одновременно, и мы можем потерять обновления! Это происходит потому, что мы можем столкнуться с ситуацией, когда, например, двое threads могут прочитать значение с помощью get и каждый из них будет выполнять set одновременно. Методы get и set не вызываются атомарно (atomically) вместе.

Atomic (атомарный) update

Конечно, мы можем улучшить приведенный выше пример и использовать другие доступные методы из Ref. Для совместной реализации get и set мы можем использовать update.

def update(f: A => A): F[Unit] 

Это решит нашу проблему с обновлением значения, однако update имеет свои недостатки. Если мы захотим обратиться к переменной сразу после обновления, аналогично тому, как мы использовали get и set , мы можем в итоге получить устаревшие данные, допустим, наш Ref будет содержать ссылку на Int:

for {_ <- someRef.update(_ + 1)curr <- someRef.get_ <- IO { println(s"current value is $curr")}} yield ()

Нас спасет modify

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

def modify[B](f: A => (A, B)): F[B] = {      @tailrec      def spin: B = {        val c = ar.get        val (u, b) = f(c)        if (!ar.compareAndSet(c, u)) spin        else b      }      F.delay(spin)    }

Как видите, это практически та же имплементация, что и в примере с AtomicInteger.incrementAndGet, который я показывал в начале, но только в Scala. Нам четко видно, что для выполнения своей работы Ref также работает на основе AtomicReference .

Ref ограничения

Вы, вероятно, уже заметили, что в случае неудачи при обновлении значения функция, переданная update/ modify, должна быть запущена недетерминированно (nondeterministically) и, возможно, должна быть запущена несколько раз. Хорошая новость заключается в том, что это решение в целом оказывается намного быстрее, чем стандартный механизм блокировки и синхронизации, и гораздо безопаснее, так как это решение не может быть заблокировано.

Как только мы узнаем, как работает простой Ref, мы можем перейти к другому классу Cats Concurrent: Deferred (Отложенный вызов).

Deferred

В отличие от Ref, Deferred:

  • создается пустым (отложенный результат выполнения)

  • может быть выполнен один раз

  • и после установки его нельзя изменить или снова сделать пустым.

Эти свойства делают Deferred простым и в то же время довольно интересным.

abstract class Deferred[F[_], A] {  def get: F[A]  def complete(a: A): F[Unit]}

Deferred используется для явной функциональной синхронизации. Когда мы вызываем get в пустой Deferred мы устанавливаем блокировку до того момента, как значение станет вновь доступно. В соответствии с документацией из самого класса:

  • Блокировка указана только семантическая, никакие реальные threads (нити) не блокируются имплементацией

Тот же вызов get непустого Deferred немедленно вернет сохраненное значение.

Другой метод complete заполнит значение, если экземпляр пуст и при вызове непустого Deferred приведет к сбою (неудачная попытка IO).

Здесь важно отметить, что Deferred требует, чтобы F было Concurrent, что означает, что его можно отменить.

Хорошим примером использования Deferred является ситуация, когда одна часть вашего приложения должна ждать другую.

Пример ниже взят из великолепного выступления Фабио Лабеллы на выставке Scala Italy 2019 Composable Concurrency with Ref + Deferred available at Vimeo

def consumer(done: Deferred[IO, Unit]) = for {c <- Consumer.setup_ <- done.complete(())msg <- c.read_ <- IO(println(s"Received $msg"))} yield ()def producer(done: Deferred[IO, Unit]) = for {p <- Producer.setup()_ <- done.getmsg = "Msg A"_ <- p.write(msg)_ <- IO(println(s"Sent $msg"))} yield ()def prog = for {  d <- Deferred[IO, Unit]  _ <- consumer(d).start  _ <- producer(d).start} yield ()

В приведенном выше примере у нас есть producer (производитель) и consumer (потребитель), и мы хотим, чтобы producer ждал, пока consumer setup закончится, прежде чем писать сообщения, в противном случае все, что бы мы ни написали в producer, будет потеряно. Для преодоления этой проблемы мы можем использовать общий экземпляр Deferred и блокировать get до тех пор, пока не будет заполнен экземпляр done Deferred со стороны consumer (значение в данном случае простая Unit () ).

Конечно, вышеуказанное решение не обошлось без проблем, когда consumer setup никогда не прекращался, мы застревали в ожидании, а producer не мог отправлять сообщения. Чтобы преодолеть это, мы можем использовать таймаут с get , а также использовать Either[Throwable, Unit] или какую-либо другую конструкцию вместо простой Unit внутри нашего объекта Deferred.

Deferred довольно прост, но в сочетании с Ref он может быть использован для построения более сложных структур данных, таких как semaphores (семафоры).

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


Узнать подробнее о курсе Scala-разработчик.

Смотреть открытый вебинар по теме Эффекты в Scala.

Подробнее..

Я сомневался в юнит-тестах, но

13.10.2020 14:23:08 | Автор: admin
Когда я пишу тест, то часто не уверен, что мой дизайн будет на 100% удачным. И хочу, чтобы он давал гибкость в рефакторинге кода например, чтобы затем изменить класс, не меняя код теста.



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

Всем привет! Это расшифровка подкаста Между скобок моих интервью с интересными людьми из мира разработки на PHP.


Запись, если вам удобнее слушать. В полной аудиоверсии мы также обсуждаем больше вопросов code coverage.

С Владимиром vyants Янцем мы познакомились на февральском PHP-митапе в Ростове: я рассказывал про свой опыт с асинхронностью, он делал доклад про тесты. С того выступления у меня остались вопросы и в период карантина мы созвонились, чтобы обсудить их.

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

Владимир Янц, Badoo: Это очень хороший вопрос. Давай начнем с того, нужны ли они в принципе. Может, и правда, только функциональные?

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

Например, у нас на данный момент 20 000 приемочных функциональных тестов. Если запустить их в один поток, они будут бежать несколько дней. Соответственно, нам пришлось придумывать, как получить результаты за минуты: отдельные кластеры, куча машин, большая массивная инфраструктура на поддержку всех этих вещей. А еще ваш тест зависит от окружения: других серверов, того, что лежит в базе данных, кэше. И чтобы его починить, нужно тратить много ресурсов, денег и времени разработчиков.

Чем хорош юнит-тест? Ты можешь протестировать всякие безумные кейсы.


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

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

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

Сергей Жук, Skyeng: Ок, смотри, у меня какое-то веб-приложение, и я начинаю в нем писать юнит-тесты. Скажем, я знаю, что у этого класса может быть 5 разных input, я меняю реализацию, и просто делаю юнит-тест провайдеру, чтобы не тестить каждый раз. И еще есть какая-то опенсорсная либа тут без юнит-теста тоже никуда. А для каких еще кейсов их нужно и не стоит писать?

Владимир Янц, Badoo: Я бы написал тесты на то, где есть какая-то бизнес-логика: не в базе данных, а именно в PHP-коде. Какие-то хелперы, которые считают что-то и выводят, какие-то бизнес-правила отличные кандидаты для тестирования. Также видел, что пытались тестировать тонкие контроллеры в приложении.

Основное, что должны делать юнит-тесты, спасать чистую функцию.


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

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

Сергей Жук, Skyeng: Когда я начинал, мне казалось, что круто юнит-тестировать максимально детально. Вот у меня есть объект, у него есть какой-то метод и несколько зависимостей (например, модель, которая ходит в базу). Я мокал все. Но со временем понял, что ценность таких тестов нулевая. Они тестируют опечатки в коде и также еще больше связывают тесты с ним. Но я до сих пор общаюсь с теми, кто за такой вот тру-подход. Твоя позиция какова: нужно ли так активно мокать? И в каких кейсах оно того точно стоит?

Владимир Янц, Badoo: В целом, мокать полезно. Но одна из самых вредных конструкций, которых, мне кажется, есть в том же PHPUnit, это ожидания.

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

Мы пытаемся проверить тестом не контракт. Хороший тест этим не занимается.


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

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

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

Сергей Жук, Skyeng: Вот ты начал про вред ожиданий. Моки про них. Нужны ли тогда вообще моки, если есть фейки и стабы?

Владимир Янц, Badoo: Проблема стабов в том, что они не всегда удобны: ты должен их заранее создать, описать. Это удобно, когда есть объект, который часто переиспользуется ты один раз его написал, а затем во всех тестах, где нужно, используешь.

А для объекта, который ты применяешь в одном-двух тестах, писать стаб очень долго.


Поэтому я активно использую моки как стабы. Можно создать мок, замокать все нужные функции и работать с ними. Я использую их только в таком ключе.

Ну а фейки мой любимый подход. Очень помогает тестировать многие вещи. Например, когда ты хочешь протестировать, что все правильно закэшировал. Для Memcached легко создать фейк-объект, он есть в большинстве стандартных поставок фреймворков. В качестве типизации вы используете интерфейс, а не конечный класс, и ваш фейк может быть безопасно передан в качестве аргумента в любую функцию. А внутри, вместо того, чтобы ходить в сам кэш, вы реализуете функционал кэша внутри объекта, делая массив. Это очень здорово облегчает тестирование, потому что вы можете проверить логику с кэшированием иногда это бывает важно.

Сергей Жук, Skyeng: Смотри, еще одна крайность. Я встречал людей, которые говорят: Ок, а как мне протестировать приватный метод/класс?

Владимир Янц, Badoo: Бывает, что не очень правильно структурирован код, и правильный метод может быть отдельной чистой функцией, которая реализовывает функционал. По-хорошему, это должно быть вынесено в отдельный класс, но по каким-то причинам лежит в protected-элементе. В крайнем случае, можно протестировать такое юнит-тестом. Но лучше сделать отдельной сущностью, которая будет иметь свой тест. Что-то вроде своего явного контракта.

Сергей Жук, Skyeng: Вот ты говоришь, есть контракт, а остальное детали реализации. Если мы говорим о веб-приложении с точки зрения интерфейса: у него есть контракт, по которому оно общается с юзерами. В то же время мы формируем реквест, отправляем, инспектируем респонс, сайд-эффекты, БД, еще что-то. Если делать юнит-тесты, то можно вынести логику общения с БД в отдельный слой, завести интерфейс для репозитория, сделать отдельную in memory реализацию репозитория для тестов. Но стоит ли оно того?

Владимир Янц, Badoo: У тебя есть приложение, которое должно правильно работать целиком. И юнит-тесты должны прикрыть какие-то важные вещи, у которых есть большая вариативность ответов, и которые легко выделены в чистую функцию. Вот там они должны быть в первую очередь.

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

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

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

Сергей Жук, Skyeng: Давай напоследок поговорим о мутационных тестах. Нужны ли они?

Владимир Янц, Badoo: Нужно внедрять мутационные тесты, как только вы задумались о юнит-тестах. Они поднимут из-под ковра все те проблемы, которые так часто обсуждают бесполезное тестирование, coverage ради coverage.

Там не нужно ничего писать дополнительно. Это просто библиотека: она берет coverage, который у вас где-то есть, идет в исходный код и начнет менять операторы кода на противоположные. После каждой такой мутации она прогоняет тесты, которые, согласно code coverage, эту строчку покрывают. Если ваши тесты не упали, они бесполезны. И, наоборот, можно находить строчки, для которых есть потенциальные мутации, но нет coverage.

Внедрить это на ранних этапах ничего не стоит. А пользы много.
Подробнее..

Так как же не страдать от функциональных тестов?

22.04.2021 18:21:21 | Автор: admin

На написание этой статьи меня подтолкнуло обсуждение докладов с Heisenbug 2021 в нашем корпоративном чате. Причиной является тот факт, что достаточно много внимания уделяется правильному написанию тестов. В кавычках потому что на бумаге все действительно логично и аргументированно, однако на практике такие тесты получаются достаточно медленными.

Эта статья ориентирована скорее на новичков в программировании, но возможно кто-то сможет вдохновиться одним из описанных далее подходов.

Я думаю, все знают принципы хороших тестов:

  • Тест должен быть атомарным, т.е. проверять единицу логики (например, один HTTP-метод или один метод класса)

  • Тест должен быть изолированным, т.е. прохождение тестов не должно зависеть от порядка их выполнения

  • Тест должен быть повторяемым, т.е. выполнение теста локально и в CI должно приводить к одному результату

Проблема возникает тогда, когда вы начинаете пытаться реализовывать подобные тесты и сталкиваетесь с реальностью: pipeline всего из 3000 функциональных тестов проходит около двух часов!


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

Любой запуск функциональных тестов для API можно разделить на следующие этапы:

  1. Применить миграции (Структура БД)

  2. Применить фикстуры (Тестовые данные в БД)

  3. Выполнить HTTP-запрос

  4. Выполнить необходимые проверки

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

Для обеспечения требования повторяемости (а также, чтобы тесты в принципе были близки к реальности) в ходе тестирования используется настоящая СУБД той же версии, которая используется на промышленной среде. Для каналов передачи данных, по возможности, тоже не делаются заглушки: поднимаются и другие зависимости вроде Redis/RabbitMQ и отдельное HTTP приложение, содержащее моки для имитации вызовов сторонних сервисов.

Требование изолированности отчасти обеспечивается отдельными фикстурами для тестов, а также отдельным процессом сборки DI-контейнера приложения между тестами.

В итоге получается примерно следующий интерфейс:

Пример описания запроса
{  "method": "patch",  "uri": "/v2/project/17558/admin/items/physical_good/sku/not_existing_sku",  "headers": {    "Authorization": "Basic MTc1NTg6MTIzNDVxd2VydA=="  },  "data": {    "name": {      "en-US": "Updated name",      "ru-RU": "Обновленное название"    }  }}
Пример описания запроса
{  "status": 404,  "data": {    "errorCode": 4001,    "errorMessage": "[0401-4001]: Can not find item with urlSku = not_existing_sku and project_id = 17558",    "statusCode": 404,    "transactionId": "x-x-x-x-transactionId-mock-x-x-x"  }}
Пример описания самого теста
<?php declare(strict_types=1);namespace Tests\Functional\Controller\Version2\PhysicalGood\AdminPhysicalGoodPatchController;use Tests\Functional\Controller\ControllerTestCase;class AdminPhysicalGoodPatchControllerTest extends ControllerTestCase{    public function dataTestMethod(): array    {              return [                // Negative cases                'Patch -- item doesn\'t exist' => [                        '001_patch_not_exist'                ],            ];    }}

Структура директории с тестами:

TestFolder Fixtures    store       item.yml Request    001_patch_not_exist.json Response    001_patch_not_exist.json   Tables    001_patch_not_exist        store            item.yml AdminPhysicalGoodPatchControllerTest.php

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

...

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

1. Применять миграции единожды

Миграция это скрипт, который позволяет перевести текущую структуру БД из одного консистентного состояния в другое.

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

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

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

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

2. Кэшировать миграции

Чем дольше живет приложение, тем больше миграций он несет вместе с собой. Усугубляться все может сценарием, в котором несколько приложений работают с одной БД (соответственно и миграции общие).

Для одной из наших самых старых схем в БД существует около 667 миграций на текущий момент. А таких схем не один десяток. Надо ли говорить, что каждый раз применять все миграции может оказаться достаточно расточительным?

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

Пример скрипта для сборки миграций
#!/usr/bin/env bashif [[ ! -f "dump-cache.sql" ]]; then    echo 'Generating dump'    # Загрузка миграций из удаленного репозитория    migrations_dir="./migrations" sh ./scripts/helpers/fetch_migrations.sh    # Применение миграций к БД    migrations_dir="./migrations" host="percona" sh ./scripts/helpers/migrate.sh    # Генерируется дамп только для интересующих нас схем (store, delivery)    mysqldump --host=percona --user=root --password=root \      --databases store delivery \      --single-transaction \      --no-data --routines > dump.sql    cp dump.sql dump-cache.sqlelse    echo 'Extracting dump from cache'    cp dump-cache.sql dump.sqlfi

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

Пример CI-job (gitlab)
build migrations:  stage: build  image: php72:1.4  services:    - name: percona:5.7  cache:    key:      files:        - scripts/helpers/fetch_migrations.sh    paths:      - dump-cache.sql  script:    - bash ./scripts/ci/prepare_ci_db.sh  artifacts:    name: "$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME"    paths:      - dump.sql    when: on_success    expire_in: 30min

3. Использовать транзакции БД при применении фикстур

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

  1. Применить фикстуры

  2. В цикле для каждого теста:

    1. Начать транзакцию

    2. Выполнить тест

    3. Проверить результат

    4. Откатить транзакцию

При локальном запуске 19 тестов (каждый из которых заполняет 27 таблиц) по 10 раз были получены результаты (в среднем): 10 секунд при использовании данного подхода и 18 секунд без него.

Что необходимо учесть:

  • У вас должно использоваться одно соединение внутри приложения, а также для инициации транзакции внутри теста. Соответственно, необходимо достать инстанс соединения из DI-контейнера.

  • При откате транзакции счетчики AUTO INCREAMENT не будут сбрасываться, как это происходит при применении TRUNCATE. Могут возникнуть проблемы в тестах, в которых в ответе возвращается идентификатор созданной сущности.

Пример кода
public static function setUpBeforeClass(): void{        parent::setUpBeforeClass();        foreach (self::$onSetUpCommandArray as $command) {            self::getClient()->$command(self::getFixtures());        }}.../** * @dataProvider dataTestMethod */public function testMethod(string $caseName): void{        /** @var Connection $connection */        $connection = self::$app->getContainer()->get('doctrine.dbal.prodConnection');        $connection->beginTransaction();                $this->traitTestMethod($caseName);        $this->assertTables(\glob($this->getCurrentDirectory() . '/Tables/' . $caseName . '/**/*.yml'));                $connection->rollBack();}

4. Разделить тесты по типу операции

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

Что необходимо учесть:

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

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

Пример кода
public function tearDown(): void{        parent::tearDown();        // После первого выполненного теста массив команд DB-клиента будет обнулен        // Поэтому в последующие разы фикстуры не будут применяться        self::$onSetUpCommandArray = [];}public static function tearDownAfterClass(): void{        parent::tearDownAfterClass();        self::$onSetUpCommandArray = [            Client::COMMAND_TRUNCATE,            Client::COMMAND_INSERT        ];}

5. Распараллелить выполнение тестов

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

Распараллелить выполнение тестов можно как в рамках целого pipelineа, так и в рамках конкретной джобы.

При распараллеливании в рамках pipelineа необходимо просто создать отдельные джобы для каждого набора тестов (Используя testsuite у phpunit). У нас тесты разделены по версии контроллера.

Пример кода
<testsuite name="functional-v2">        <directory>./../../tests/Functional/Controller/Version2</directory></testsuite>
functional-v2:  extends: .template_test  services:    - name: percona:5.7  script:    - sh ./scripts/ci/migrations_dump_load.sh    - ./vendor/phpunit/phpunit/phpunit --testsuite functional-v2 --configuration config/test/phpunit.ci.v2.xml --verbose

Альтернативно можно распараллелить тесты в пределах одного джоба, используя, например, paratest. Библиотека позволяет запускать несколько процессов для выполнения тестов.

Однако в нашем случае такой подход будет неприменим, т.к. фикстуры разных процессов будут перетирать друг друга. Если же все процессы будут использовать один и тот же набор фикстур, а тесты будут выполняться в рамках транзакции (чтобы не видеть изменения в БД от других тестов), это не даст никакого прироста, т.к. из-за транзакционности операции в БД будут банально выполняться последовательно.

Если подвести итог:

  • Сделать несколько отдельных джоб на уровне CI самый простой способ ускорить прохождение тестов

  • Распараллелить функциональные тесты в рамках одной джобы сложно, но подобный подход может быть приемлем для юнит-тестов

  • Стоит учесть, что из-за накладных расходов (порождение нового процесса, поднятие нового контейнера для тестов) выполнение тестов параллельно может быть медленнее. Если у вас недостаточно свободных раннеров в CI, то такое деление может не иметь смысла.

...

6. Не пересоздавать экземпляр приложения

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

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

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

Пример кода
interface StateResetInterface{    public function resetState();}
$container = self::$app->getContainer();foreach ($container->getKnownEntryNames() as $dependency) {        $service = $container->get($dependency);        if ($service instanceof StateResetInterface) {                $service->resetState();        }}

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

После всех оптимизаций время прохождения в CI для функциональных тестов уменьшилось до 12-15 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru