Модульный тест - это один из самых основных тестов при создании нашего приложения. Он предназначен для тестирования каждого компонента или функции как единицы - для получения входных данных, вставки входных данных в функцию и утверждения выходных данных. Однако мы сталкиваемся с большой сложностью в модульном тесте, который связан со сложным вводом-выводом или побочными эффектами.
Хорошее практическое правило - создать имитацию этого побочного эффекта, которая возвращает желаемое значение. Однако при этом нам нужно извлечь один компонент из функции, чтобы смоделировать эту операцию. Это может быть громоздко.
В этом блоге я хотел бы поделиться тем, как вы можете протестировать асинхронный код с помощью одной-единственной настройки вашей функции - абстрагируя ее от конструктора типов.
Давайте рассмотрим пример, чтобы проиллюстрировать, что я имею в виду.
Примечание. Мы используем классы типов категорий и 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 вещи:
- Тип Класс
- Тип экземпляры
- Синтаксис интерфейса, объект интерфейса
Мы определим 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, чтобы увидеть больше подобных сообщений.