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

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

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

Давайте рассмотрим пример, чтобы проиллюстрировать, что я имею в виду.

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

Проблема

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

Вот DBClient реализация:

trait DBClient {
  def get(url:String):Future[Int]
}

Вот реализация DBService:

class DBService(dbClient:DBClient) {
  def sumAllPrice(urls:List[String]): Future[Int] = Future.traverse(urls)(dbClient.get).map(_.sum)
}

Теперь, если мы хотим протестировать sumAllPrice, мы можем создать заглушку DBClient.

class TestDBClient extends DBClient {
  override def get(url:String): Future[Int] = Future.successful{1}
}

Как мы можем протестировать sumAllPrice в модульном тесте?

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

Действие

Мы можем решить эту проблему двумя способами. Первый - преобразовать код в конструктор типа. Во втором я хочу изменить код на шаблон класса типа.

Абстрагирование над конструктором типов

Делаем DBClient, чтобы получить конструктор типа F[_] типа.

trait DBClient[F[_]] {
  def get(url:String):F[Int]
}

Примечание. Вам необходимо импортировать Высшие типы в свое приложение.

Это означает, что get(url:String) возвращает любой тип конструктора. Это может быть Future[Int] или List[Int].

Мы используем Cats библиотеку для генерации асинхронного кода для производственной версии и синхронного кода для тестовой версии.

Cats библиотека имеет тип Monad id, который позволяет оборачивать типы в конструктор типа без изменения их значения:

package cats

type Id[A] = A

У нас есть признак TestDBClient, который используется для модульного тестирования, и признак ProdDBClient, который используется для основного кода:

import cats.Id
trait TestDBClient extends DBClient[Id]

trait ProdDBClient extends DBClient[Future]

Затем мы также абстрагируем конструктор DBService над типом.

import cats.implicits._

class DBService[F[_]:Applicative](dbClient:DBClient[F]) {
  def sumAllPrice(urls:List[String]): F[Int] = urls.traverse(dbClient.get).map(_.sum)
}

F[_]: Applicative - это синтаксический сахар, привязанный к контексту, для implicit значения ap: Applicative[F].

Вышеупомянутая функция аналогична class DBService[F[_]](dbClient:DBClient[F])(implicit ap:Applicative[F])

Здесь мы делаем конструктор типа аппликативом, потому что traverse работает только с последовательностью значений, имеющей Applicative. В контексте Future у него есть Applicative, и это приводит к List[Future[Int]]. Однако, абстрагируясь от конструктора типа List[F[Int]], нам нужно доказать компилятору, что значение имеет Applicative при передаче в функцию.

В этом случае мы делаем конструктор типа в DBClient не привязанным к какому-либо конкретному контексту, чтобы его можно было легко использовать в других службах. Однако мы ограничиваем контекст DBService, потому что он должен иметь Applicative для выполнения traverse операции.

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

Использование класса типа

Чтобы определить класс Type, нам нужно сделать 3 вещи:

  1. Тип Класс
  2. Тип экземпляры
  3. Синтаксис интерфейса, объект интерфейса

WTF - это типовой класс?

Мы определим DBClient как класс типа:

trait DBClient[F[_]] {
  def get(url:String): F[Int]
}

Затем мы определим экземпляры. Объект экземпляра - это то место, куда мы помещаем ProdDBClient и TestDBClient.

object DBClientInstances {
  implicit val getFutureInstance: DBClient[Future] = new DBClient[Future] {
    override def get(url: String): Future[Int] = ???
  }

  implicit val getIdInstance:DBClient[Id] = new DBClient[Id] {
    override def get(url: String): Id[Int] = ???
  }
}

Наконец, мы создаем объект интерфейса, DBService, и внедряем наши экземпляры в sumAllPrice:

object DBService {
  def sumAllPrice[F[_]:Applicative](urls:List[String])(implicit dbClient:DBClient[F]): F[Int] = urls.traverse(dbClient.get).map(_.sum)
}

Мы также ограничиваем наш конструктор типа Applicative для использования traverse.

Почему мы не использовали вместо этого Monad здесь и Applicative? Это потому, что Monad более ограничен, подтип Applicative в иерархии классов типов и для текущей функции Applicative может выполнять эту работу. Нам не нужно ограничивать входящий элемент до Monad, поскольку мы можем иметь более широкий диапазон поведения с Applicative и меньше законов, которым нужно подчиняться (не flatMap). Следовательно, при реализации DBService вызывающий может выполнять более широкий диапазон действий.

Забрать

  • Мы можем протестировать асинхронный код, абстрагируя наше приложение с помощью конструктора типа.
  • При абстрагировании вашего приложения с помощью конструктора типа рекомендуется сделать конструктор типа минимальным ограничением для поведения, необходимого для текущей реализации. Например, DBClient не ограничивается наличием какого-либо контекста, тогда как DBService ограничивается Applicative, потому что мы хотим иметь возможность использовать traverse в конструкторе типа.

Весь исходный код в этом руководстве находится здесь.

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

Вы также можете подписаться на меня на Medium, чтобы увидеть больше подобных сообщений.