建立一个Scala模拟库教程

386 阅读7分钟

我用Scala写测试已经有好几年了,而对我来说,Mock库一直是个谜。为了让事情变得不那么神秘,我决定自己动手建立一个!这篇文章将介绍我所做的工作,以及我学到的东西。这篇文章将介绍我所做的事情,以及我所学到的东西。这是一个学习Scala高级功能的好借口,比如宏和反射。

如果你曾经对这些功能感到好奇,或者只是对更好地理解Scala感兴趣,这篇文章就是为你准备的

什么是模拟库?

首先是基础知识:模拟库有什么用?假设我们有一个类Bar,它依赖于某个类Foo ,该类有一个具有某些非确定性行为的方法,比如获得一个随机数。

class Foo {
    def doesFooStuff = getRandomNumber()
}

class Bar(foo: Foo) {
    def doesBarStuff =  foo.doesFooStuff + 1
} 

在我的测试中,我想确保Bar 的行为是正确的。然而,这很难做到,因为如果我们使用一个真正的 Foo 对象,它每次都会返回一个随机数!

理想情况下,我们可以有一些与Foo 具有相同接口的对象,它不会得到一个随机数,而是返回一些假值。这将使我们能够真正正确地测试Bar 。我们当然可以写一个这样的类!

class FakeFoo extends Foo { 
    override def doesFooStuff = 5
}

/* In a test */
val bar = new Bar(new FakeFoo)
// ... test Bar's behavior, knowing that `doesFooStuff` will always return 3.

这就达到了我们想要的效果。然而,这意味着对于任何我们想拥有一个 "模拟 "的类,我们都必须为它建立一个手动类,就像我们在这里做的那样。

值得庆幸的是,Scala有一个可以在编译时自动生成类的工具,即宏。例如,常用的ScalaMock库就采用了这种机制。

API应该是什么?

在我们深入研究这个模拟库的实际实现之前,我们首先要确定我们要实现的接口。

正如上一节所暗示的,我们将编写一个宏,为一个给定的类实例化一个 "Mock "对象。为此,我们将使用ScalaMock使用的相同接口。

val fooMock = mock[Foo]

这段代码中的期望是,调用mock[Foo] ,返回一个与Foo 相同接口的模拟对象。

接下来我们需要的是,作为测试的作者,指定我们希望Foo 方法的返回值。为此,我们需要能够指定给定一个特定参数,应该返回什么值。

为此,我们将使用Mockito使用的相同接口。

when(fooMock.doFooStuff).thenReturn(5)

在这篇文章的后面会有更多关于这个API的讨论。

创建Mock对象

让我们从第一节中提到的开始,写一个创建模拟对象的宏吧

什么是宏?

宏是Scala的一个语言特性,它允许开发者在编译时修改程序的语法树。宏的定义方式与Scala中的普通函数类似,使用def 关键字,接受参数,并有一个返回类型。

宏需要定义一个实现,这个实现是一个函数,它接收与宏的参数相对应的 "表达式 "类型,并返回一个 "表达式 "类型,该类型在评估时与宏的返回类型相对应。这些都是 "表达式 "类型,因为宏是在编译时运行的,而宏的参数还没有被评估过。正是在这个实现函数中,你可以检查宏的参数,并以编程方式定义新的类型和类。我将用一个例子来说明这一点。

import scala.reflect.macros.blackbox

def exampleMacro(x: Int) : Int  = macro exampleMacroImplementation

def exampleMacroImplementation(c: blackbox.Context)(x: c.Expr[Int]): c.Expr[Int] = {
    /* ... */
}

注意这里面的几件事--首先,exampleMacroImplementation ,它需要一个宏Context 对象。在这里,我们使用了一个blackbox 宏--关于这意味着什么,请看这一页。另外,参数和返回类型是Expr ,参数化的参数和返回类型是exampleMacro

另一个重要的问题是,宏是在编译时执行的。这意味着,如果exampleMacro 被调用为exampleMacro(1 + 1) ,完整的1 + 1 表达式可以在exampleMacroImplementation 中读取。xexampleMacroImplementation 中是一个表达式,其结果是一个整数。同样的情况也发生在返回类型上,exampleMacroImplementation 所返回的是一个未评估的表达式,返回的是一个Int

定义一个表达式

那么,现在我们有了一个实现函数的接口,那么在实现中究竟要做什么呢?

再次重申,宏是在编译时运行的,所以我们的实现是在语法树上运行的。宏Context 对象有一些类,允许开发者建立语法树。例如,你可以这样构建一个表达式,它只由字面意思组成:10

  import c.universe._
  val s = Literal(Constant(10))
  c.Expr(s)
}

其他的类包括:ValDef ,用于定义值;DefDef ,用于定义函数;Block ,用于定义块,等等。

Scala有一种方便的方法来生成这些对象,称为准引号,我将在文章的其余部分使用这种方法。

有了准引号,你可以用一个字符串来写代码,这个字符串代表你要建立的语法树,使代码更加合理。例如,对于一个函数定义,你可以这样写

def exampleMacroImplementation(c: blackbox.Context)(x: c.Expr[Int]): c.Expr[Int] = {
  import c.universe._
  val funcDef = q"""
  def blah(x: Int) =  x + 5
  """

  /* And you could then use this function using: */
  val block = q"""
  $funcDef
  blah(5)
  """

  c.Expr(block)
}

上面的变量funcDef 解析为一个DefDef 对象,而block变量解析为一个Block 对象。因此,当调用exampleMacro ,例如exampleMacro(6) ,这将扩展为

def blah(x: Int) =  x + 5
blah(5)

并在代码实际运行时返回10

关于准引号如何扩展的更多信息,请看这一页

回到mocks

好了,现在我们对宏的工作原理有了一个大致的了解,让我们来看看我们在这里到底想实现什么。

我们想要一个叫做mock 的宏,它返回一个与被模拟的类型具有相同接口的对象,并且所有方法都被重载。对于这个例子,让我们使用类似的东西。我们想模拟一个名为Foo 的类,它有一个名为fooify 的方法。

class Foo {
    def fooify(x: Int) = x + 3

}

fooify 接收一个 ,并返回一个 。Int Int

考虑到我们到目前为止对宏的了解,我们可以写一个宏,返回一个Foo 的实例,并重载fooify

def mock[T]: T = macro mockImpl

def mockImpl[T](c: blackbox.Context): c.Expr[T] =  {
    val result = q"""
    new Foo {
        override def fooify(x: Int) = 10
    }
    """
    c.Expr(result)
}

现在,调用mock[Foo] 将返回一个类型为Foo 的实例,无论何时调用fooify ,都会返回10 的假值。

当然,由于这个宏总是返回一个Foo 的实例,它并没有完全解决我们所要解决的问题。我们希望能够对任何类型的对象进行模拟,并对它们的任何方法进行模拟。

注意,在这个模拟中,我们使用了泛型,并且有一个单一的类型参数T ,它表示我们正在模拟的类型。为了实现我们在这里实际想要实现的目的,我们需要能够:

  1. 读取T 的类型,这样我们就可以返回一个表达式,该表达式返回的类型为T
  2. 读取T 的方法,这样我们就可以覆盖它们。

为了达到这个目的,我们将使用反射

使用反射

反射是一个允许你检查对象类型的功能--无论是在编译时还是在运行时。因此,如果一个函数接收了一个具有通用类型的对象T ,通过反射,你可以检查该对象的实际类型。因此,例如,如果一个特定的调用TInt ,你可以用反射来发现这一点。除了发现该对象的类型之外,你还可以发现其他信息,比如一个对象的members (方法和字段)是什么。

反射的工作方式是,对于你想使用反射的函数,你添加一个隐含参数,称为 "证据",让编译器知道你想对一个类型进行操作。

import scala.reflect.runtime.universe._

def f[T](v: T)(implicit ev: TypeTag[T]) = ev.toString

如果在调用f 的同时调用5 ,这将返回字符串 "TypeTag[Int]"。

一个语法上的简称是在类型上使用一个 "上下文绑定"。

import scala.reflect.runtime.universe._

def f[T: TypeTag](v: T) = typeOf[T].toString

通过这种语法方法,你可以使用反射库中的typeOf 函数来获得T 的类型。这个类型对象包含了很多关于T 的信息,除了它的名字之外,重要的是你可以获得对其成员的访问。

import scala.reflect.runtime.universe._

def f[T: TypeTag](v: T) = typeOf[T].members

members 包含了 T的字段和方法,对于每一个字段,我们都可以获得参数列表和返回类型。

把它放在一起

有了宏和反射的基础知识,我们现在终于有足够的知识把它们放在一起,写一个mock[T] 宏,为它的每个成员提供假值。

members 我们在这里采取的方法是:首先,使用反射来获得我们正在模拟的T ,然后使用准引号,构造一个新的T ,每个成员都被覆盖。我们将在这之后整理出每个被覆盖的方法应该做什么(这段代码是不完整的)。

def mock[T] : T = macro mockImpl[T]
/* Macros require that WeakTypeTag is used -- this is a more general
 * form of TypeTag that can be used to detect abstract & generic type params.
 */
def mockImpl[T: c.WeakTypeTag](c: blackbox.Context): c.Expr[T] = {
  val mockingType = weakTypeOf[T]
  val methodDefs = mockingType.members.map { member => 
    val method = member.asMethod
    val returnType = method.returnType
    /* It's required that param lists are a sequence of 
     * ValDefs
     */
    val paramsString = method.paramLists.map { paramList => 
      paramList.map {  symbol =>
        q"""val ${symbol.name.toTermName}: ${symbol.typeSignature}"""
      }
    }
    
    val name = method.name

    /* We will fill in these definitions, but need a mechanism for 
     * storing the dummy values first.
     */
    q"""
    override def ${name.toTermName}(...${paramsString}) : ${returnType} = {
        ???
    }
    """ 
  }.toList

  /* This extra variable is required in order for quasiquotes to 
   * interpret these as the correct types, see: https://docs.scala-lang.org/overviews/quasiquotes/syntax-summary.html  */
  var classBody = q"""
   ..${methodDefs}
  """
  val result = q"""new ${mockingType.resultType} { ..$classBody}"""
  c.Expr(result)
}

关于这一点的一些说明:

  1. 请注意,在组织方面,我们首先使用准引号为T的每个成员创建方法定义,然后使用准引号将其粘贴到Block
  2. 我们仍然需要弄清楚如何为这些方法中的每一个配置假值。
  3. 有一些方法是在每个对象上定义的,我们在这里要重写它们。我们将不得不过滤掉这些

指定和读取模拟值

下一个要处理的问题是弄清楚在模拟对象中被重写的方法的主体中到底有什么。它应该是某种新的字段或者是定义在被模拟对象本身的方法吗?

这里的一个问题是,一旦我们调用:

val myMock = mock[Foo]

myMock 具有与 相同的接口。从Foo这段代码的角度来看,我们正在调用 , 具有相同的接口,除了已经在 上的方法外,没有任何方法可以被调用。所以这就排除了一种方法,即我们在 的这个实例上附加一个方法,叫做 ,我们可以像这样调用。mock myMock Foo Foo setMockValue

/* Not possible as an API */
val myMock = mock[Foo]
myMock.setMockValue(methodName = "fooify", argument = 3, returnValue = 5)

我们知道,这种API在这里是不可能的。另外,由于我们对这个模拟对象的API的限制,我们很可能不想在模拟对象本身上存储关于假值的信息。我们在文章的前面讨论过,其他嘲讽库所使用的常见API是这样的。

val myMock = mock[Foo]
when(myMock.fooify(3)).thenReturn(10)

既然我们不能在mock本身上突变状态,那么我们怎样才能实现这个API呢?

ScalaMock 这样的库在这里采取的方法是,在mock对象的外部有一个可以在测试中使用的对象,它可以跟踪Mocks的状态!

特别是在ScalaMock中,有一个叫做MockContext 的类,用来在测试中跟踪Mock的状态。我们在这里也将遵循类似的方法。

我们将如何实现这一目标的粗略轮廓是:

  1. 要求任何想要使用这个mock库的Scala代码路径必须扩展一个trait,我们称之为Mocking ,这个trait已经定义了一个MockContext 的隐含实例。
  2. MockContext 本身,支持向MockContext添加 "handlers",每个 "handlers "都对应于当某个特定参数被传递时,将返回给某个 特定的 mock(这将是一个tuples的列表)。
  3. MockContext ,支持 "当前正在被模拟 "方法的概念,其原因很快就会清楚。
  4. 我们修改mock 宏,使其接受一个implicit MockContext ,这样就会自动传递到mock 调用中。
  5. 然后,在mock 宏中,当我们覆盖模拟类的方法时,我们将其改为在MockContext 对象中进行查找,看是否有为该特定调用配置的假值,否则返回一个异常。然后,它还应该在MockContext 上设置自己的 "当前被模拟 "方法。

一旦mockMockContext 被更新以匹配这种行为,接下来要做的就是实现whenwhen 的目的非常简单--执行一些函数,并捕捉步骤4中指定的异常。它还需要返回一些带有thenReturn 方法的对象,然后利用MockContext 中的 "当前被模拟的 "对象来设置一个返回值。

值得注意的是,我们在这里将把我们的例子限制在接受一个参数的函数上,但应该不难看出我们可以如何扩展到支持其他参数。

让我们看看这些代码

我们将从MockContext 类开始:

trait Mock[T]

class MockContext {
  /* Mutable list of tuples, for each of the methods mocked.
   * An entry in this Buffer looks like this:
   * (mock: Mock[_], functionName: String, argument: Any, returnValue: Any)
    */
  val handlers : Buffer[Any] = Buffer[Any]()

  /* This is used to keep track of the current method
   * that we are mocking, and contains the mock, function name,
   * and argument */
  var currentMockMethod: (Mock[_], String, Any) = null 

  def appendHandler[Value](value: Value) = {
    val fullCall = currentMockMethod match {
      case (mock, methodName, arg) => (mock, methodName, arg, value)
    }
     handlers.append(fullCall)
  }

  def setCurrentMockMethod[Arg](mock: Mock[_], funcName: String, arg: Arg) = {
    currentMockMethod = (mock, funcName, arg)
  }

  /* Search through the existing handlers, and find one matching the given
   * mock, function name, and argument
   */
  def findMatchingHandler(mock: Mock[_], funcName: String, arg: Any): Option[Any] = {
    handlers.collect { handler =>
      handler match {
        case (savedMock, savedFunctionName, savedArg, value) if mock == savedMock && funcName == savedFunctionName && arg == savedArg => 
          value
      }
    }.headOption
  }
}

如前所述,这个类提供的主要功能是handlers 字段,它存储了一个我们正在嘲弄的调用列表。请注意,我还添加了一个Mock[T] 特质,我们将把它添加到由mock 产生的对象中,以使这些对象能够被类型检查。

因为我们要模拟的函数可以有任何参数或返回类型,所以handlers 字段除了是Any ,不能有任何进一步的限制。我将在后面详细说明这个问题。

接下来,让我们来看看我们需要对mock[T] 这个宏所做的修改。

class MockUndefinedException(s:String) extends Exception(s)

def mock[T](implicit mockContext: MockContext) : T with Mock[T] = macro mockImpl[T]
def mockImpl[T: c.WeakTypeTag](c: blackbox.Context)(mockContext: c.Expr[MockContext]): c.Expr[T] = {
  import c.universe._

    ...

    val firstParamName = method.paramLists.headOption.flatMap(_.headOption).map { symbol => symbol.name}.get

    q"""
    override def ${name.toTermName}(...${paramsString}) : ${returnType} = {
      ${mockContext}.setCurrentMockMethod(this, ${name.toString()}, ${firstParamName.toTermName})
      val foundHandler = ${mockContext}.findMatchingHandler(this, ${name.toString()}, ${firstParamName.toTermName})
      foundHandler match {
        case Some(value) => value.asInstanceOf[${returnType}]
        case None => throw new MockUndefinedException("no mock found")
      }
    }
    """
  }.toList
  
  ...
}

我跳过了上一个例子中相同的代码部分(完整代码见Github repo)。在这里,我们既要调用setCurrentMockMethod 来设置MockContext 的currentMockMethod,又要查询MockContexthandlers 来获得一个值。如果没有值,我们抛出一个MockUndefinedException

接下来,我们实现whenthenReturn

class Stubbing[T](implicit val mockContext: MockContext) {
  def thenReturn(returnVal: T) = {
    mockContext.appendHandler(returnVal)
  }
}

object MockHelpers {
  def when[T](getReturnVal: => T)(implicit mockContext: MockContext): Stubbing[T] = {
    try {
      getReturnVal
    } catch {
      case e: MockUndefinedException => ()
      case e: Throwable => throw e
    }
    new Stubbing[T]()
  }
}

这些都是非常直接的 - 我们有一个Stubbing 对象,允许我们为当前被模拟的方法设置返回值,还有一个when 函数,执行一个提供的函数,然后返回一个新的Stubbing 。这个函数中的T 是指被模拟的给定函数的返回类型。所以在

when(mockfoo.fooify(3)).thenReturn(10)

TInt

最后,让我们添加一些额外的设置来运行这段代码。

/* In a file called Mock.scala */
trait Mocking {
  import scala.language.implicitConversions

  implicit val mockContext = new MockContext
}

/* In a file called Main.scala */
import MockHelpers._

object Main extends App with Mocking {
  val fooMock = mock[Foo]
  when(fooMock.fooify(7)).thenReturn(200)
  println(fooMock.fooify(7)) // returns 200
}

就这样,我们有了!我们自己的mock库!同样,请看Github Repo,了解这一切是如何结合在一起的。

有什么办法可以绕过使用Any

在写完这篇文章后,我想到的一个主要问题是,在MockContext 处理程序中使用Any ,似乎是一种代码气味。这方面的主要问题是,现在,我们可以在mock 的实现中发现一个错误,导致我们对一个类型不正确的方法使用处理程序,而不会出现编译时错误。虽然这对模拟库的开发者有影响,但如果你不改变模拟库本身,那么使用Any ,其实并不存在导致问题的风险。

综上所述,我花了一些时间来探索这里的可能性,包括不同的数据结构,以及通用编程库Shapeless。然而,由于MockContext 类没有任何关于可能创建的模拟的上下文,而且这只有在运行时才知道,看起来进一步约束类型可能很棘手。

然而,我不能确认这是不可能做到的。

总结

建立一个模拟库是一个很好的练习,让我对Scala有了很多了解。这是一次迫使我对Scala的类型系统如何工作进行更多思考的经历--我肯定会觉得自己因此对这门语言有了更多的了解。

我的高层次收获是,承担那些需要你以通常情况下不需要的方式使用某种语言的项目是很有价值的。在这个项目中,我获得的许多见解来自于对语言中的限制条件的冲击。