Effects测试

有许多方法可以测试 effect,包括使用 free monad,使用 tagless-final 和使用环境 effect。尽管所有这些方法都与 ZIO 兼容,但最简单,最符合习惯的是 环境 effect

本节介绍 环境 effect,并向您展示如何使用它们来编写可测试的功能代码。

环境

ZIO 数据类型有一个类型参数 R,它用来描述 effect 所需的环境类型。

ZIO effects 可以使用 ZIO.environment 来访问环境,通过它直接得到 R 类型的环境值:

for {
  env <- ZIO.environment[Int]
  _   <- putStrLn(s"The value of the environment is: $env")
} yield env

环境不必是整数等原始类型。它可能要复杂得多,比如可以是一个 traitcase class

如果环境带有属性字段,则可以通过 ZIO.access 单个调用直接访问环境的给定部分:

final case class Config(server: String, port: Int)

val configString: URIO[Config, String] = 
  for {
    server <- ZIO.access[Config](_.server)
    port   <- ZIO.access[Config](_.port)
  } yield s"Server: $server, port: $port"

甚至 effect 本身也可以存储在环境中!在这种情况下,要访问和执行 effect,可以使用 ZIO.accessM 方法。

trait DatabaseOps {
  def getTableNames: Task[List[String]]
  def getColumnNames(table: String): Task[List[String]]
}

val tablesAndColumns: ZIO[DatabaseOps, Throwable, (List[String], List[String])] = 
  for {
    tables  <- ZIO.accessM[DatabaseOps](_.getTableNames)
    columns <- ZIO.accessM[DatabaseOps](_.getColumnNames("user_table"))
  } yield (tables, columns)

如上例所示,从环境访问 effect 时,该效果称为 environment effect

稍后,我们将看到环境 effect 是怎样提供一种简便的方法来测试 ZIO 应用程序的。

提供 Environments

必须先为 effect 提供(providing)环境,然后它们才能运行。

最简单的为一个 effect 提供所需环境的方法是使用 ZIO#provide 函数:

val square: URIO[Int, Int] = 
  for {
    env <- ZIO.environment[Int]
  } yield env * env

val result: UIO[Int] = square.provide(42)

您为 effect 提供了所需的环境后,如果其返回的 effect 的环境类型为 AnyUIO[_]),这表明其要求已完全得到满足。

ZIO.accessMZIO#provide 的组合对于充分地利用环境 effect 来使得测试变得简单是必需的。

环境化的 Effects

环境 effect 背后的基本思想是面向接口编程,而非面向实现。对于函数式语言 Scala 而言,接口不包含任何具有副作用的方法,但是它们可能包含返回函数化了的effect 的方法。

与其在整个代码库中手动地实现依赖注入或者使用不连贯的隐式来传递接口,不如使用 ZIO Environment 来进行繁重的工作,从而使代码优雅,可推断且轻松自如。

在本节中,我们将通过开发可测试的数据库服务来探索如何使用环境 effect。

定义服务(Service)

我们以模块的形式定义数据库服务,该模块是仅包含单个字段的接口,该字段提供对服务的访问。

object Database {
  trait Service {
    def lookup(id: UserID): Task[UserProfile]
    def update(id: UserID, profile: UserProfile): Task[Unit]
  }
}
trait Database {
  def database: Database.Service
}

在这个例子中,Database 代表 模块,它包含了 Database.Service 服务。 这个 服务 只是一个普通的接口,定义在模块的伴随对象中,包含了该服务提供的功能函数。

辅助函数

为了简化访问数据库服务的环境 effect,我们将定义一些辅助函数来调用 ZIO.accessM

object db {
  def lookup(id: UserID): RIO[Database, UserProfile] =
    ZIO.accessM(_.database.lookup(id))

  def update(id: UserID, profile: UserProfile): RIO[Database, Unit] =
    ZIO.accessM(_.database.update(id, profile))
}

虽然这些辅助函数不是必须的,因为我们可以直接通过 ZIO.accessM 来访问数据库模块,但是这些帮助程序易于编写并且使得调用端的代码看上去更简单。

调取服务

我们已经定义了一个模块和辅助函数,现在我们准备构建一个使用数据库服务的示例:

val lookedupProfile: RIO[Database, UserProfile] = 
  for {
    profile <- db.lookup(userId)
  } yield profile

在此示例中,effect 仅能通过环境与数据库进行交互,在这种情况下,环境就成为一个提供对数据库服务的访问的模块。

要实际运行这个 effect,我们仅需要提供数据库模块的实现。

实现服务实体

现在我们将实现数据库模块的服务实体,它将切实负责与我们的生产厂数据库的交互:

trait DatabaseLive extends Database {
  def database: Database.Service = 
    new Database.Service {
      def lookup(id: UserID): Task[UserProfile] = ???
      def update(id: UserID, profile: UserProfile): Task[Unit] = ???
    }
}
object DatabaseLive extends DatabaseLive

在以上这段代码片段中,我们不打算提供这两个数据库方法的实现,因为这将需要超出本教程的范围。

运行数据库 Effect

现在我们有了一个数据库模块,和于数据库模块交互的辅助方法,可以一个数据库模块的具体实现。

我们现在使用  ZIO.provide 将数据库模块的实现提交给我们的应用程序:

def main: RIO[Database, Unit] = ???

def main2: Task[Unit] = 
  main.provide(DatabaseLive)

自此,我们对产生出的 effect 没有了进一步的要求,所以现在可以将它提交给运行时去执行了。

实现服务的测试案例

为了测试代码于数据库的交互,我们不需要提供一个真实的数据库,因为这样的话我们的测试将会很慢很脆弱,并且即便应用的逻辑都是正确的也可能产生随机性的错误。

虽然我们可以使用模拟(mocking)库来创建测试模块,但是在本节中,我们将直接直接创建一个测试模块,以表明这里面没有任何魔术:

class TestService extends Database.Service {
  private var map: Map[UserID, UserProfile] = Map()

  def setTestData(map0: Map[UserID, UserProfile]): Task[Unit] = 
    Task { map = map0 }

  def getTestData: Task[Map[UserID, UserProfile]] = 
    Task(map)

  def lookup(id: UserID): Task[UserProfile] = 
    Task(map(id))

  def update(id: UserID, profile: UserProfile): Task[Unit] = 
    Task.effect { map = map + (id -> profile) }
}
trait TestDatabase extends Database {
  val database: TestService = new TestService
}
object TestDatabase extends TestDatabase

由于此模块仅在测试中使用,因此我们通过硬编码读取和更新 map 中的数据来模拟于数据库的交互。为了测试模块具有纤程安全性,可以使用 Ref 而不是 var 来定义 map。

测试数据库代码

现在要测试请求数据库的代码,我们只需要为它提供这个用于测试的数据库模块就可以了:

def code: RIO[Database, Unit] = ???

def code2: Task[Unit] = 
  code.provide(TestDatabase)

我们的应用程序代码可以像测试数据库模块一样使用在生产数据库模块上。

Next Steps

如果您对测试效果感到满意,那么下一步就是学习运行 effects

Leave a Reply
Your email address will not be published.
*
*

BACK TO TOP