单元测试:验证是否调用了方法,而无需测试Mockito或MockK等框架

atmip9wb  于 2022-11-08  发布在  其他
关注(0)|答案(2)|浏览(226)

不使用像MockK或Mockito这样的测试框架似乎变得越来越流行了。我决定尝试这种方法。到目前为止,返回假数据很简单。但是我如何验证一个函数(不返回数据)已经被调用了呢?想象一下有一个这样的类:

class TestToaster: Toaster {

  override fun showSuccessMessage(message: String) {
    throw UnsupportedOperationException()
  }

  override fun showSuccessMessage(message: Int) {
    throw UnsupportedOperationException()
  }

  override fun showErrorMessage(message: String) {
    throw UnsupportedOperationException()
  }

  override fun showErrorMessage(message: Int) {
    throw UnsupportedOperationException()
  }
}

有了MockK我会做

verify { toaster.showSuccessMessage() }

我不想重新发明一个轮子,所以决定问。在谷歌上找到任何东西似乎是非常困难的。既然这是一件事,我认为重点将是完全删除嘲笑库,没有它们一切都可以做。

zqdjd7g9

zqdjd7g91#

在模拟库出现之前,传统的做法是手动创建一个测试实现。测试实现将存储一个方法是如何被调用到某个内部状态的,这样测试代码就可以通过检查相关状态来验证一个方法是否是用预期的参数调用的。
例如,一个非常简单的Toaster测试实现可以是:

public class MockToaster implements Toaster {

    public String showSuccesMessageStr ;
    public Integer showSuccesMessageInt;

    public String showErrorMessageStr;
    public Integer showErrorMessageInt;

    public void showSuccessMessage(String msg){
        this.showSuccesMessageStr = msg;
    }

    public void showSuccessMessage(Integer msg){
        this.showSuccesMessageInt = msg;
    }

    public void showErrorMessage(String msg){
        this.showErrorMessageStr = msg;
    }

    public void showErrorMessage(Integer msg){
        this.showErrorMessageInt = msg;
    }
}

然后在测试代码中,配置要测试的对象使用MockToaster。为了验证它是否真的调用了showSuccessMessage("foo"),可以在测试结束时Assert它的showSuccesMessageStr是否等于foo

rsl1atfo

rsl1atfo2#

很多人似乎都在建议非常直接的解决方案,这完全有道理。我决定用一点花哨的语法来实现这个语法:
verify(toaster = toaster, times = 1).showErrorMessage(any<String>()) .
我创建了简单的匹配器:

inline fun <reified T> anyObject(): T {
  return T::class.constructors.first().call()
}

inline fun <reified T> anyPrimitive(): T {
  return when (T::class) {
    Int::class -> Int.MIN_VALUE as T
    Long::class -> Long.MIN_VALUE as T
    Byte::class -> Byte.MIN_VALUE as T
    Short::class -> Short.MIN_VALUE as T
    Float::class -> Float.MIN_VALUE as T
    Double::class -> Double.MIN_VALUE as T
    Char::class -> Char.MIN_VALUE as T
    String:: class -> "io.readian.readian.matchers.strings" as T
    Boolean::class -> false as T
    else -> {
      throw IllegalArgumentException("Not a primitive type ${T::class}")
    }
  }
}

添加了一个Map来存储TestToaster中每个方法的调用计数,其中key是函数的名称,value是计数:
private var callCount: MutableMap<String, Int> = mutableMapOf()
每当一个函数被调用时,我增加一个方法的当前调用计数值。我通过反射得到当前方法名

val key = object {}.javaClass.enclosingMethod?.name + param::class.simpleName
addCall(key)

为了实现“花哨”的语法,我为TestToaster创建了内部子类和一个verify函数:

fun verify(toaster: Toaster , times: Int = 1): Toaster {
  return TestToaster.InnerToaster(toaster, times)
}

该函数将当前的toaster示例发送到内部子类以创建新示例并返回它。当我用上面的语法调用子类的方法时,会进行检查。如果检查通过,则什么也不发生,测试也通过;如果条件不满足,则抛出异常。
为了使它更通用和可扩展,我创建了这个接口:

interface TestCallVerifiable {
  var callCount: MutableMap<String, Int>
  val callParams: MutableMap<String, CallParam>

  fun addCall(key: String, vararg param: Any) {
    val currentCountValue = callCount.getOrDefault(key, 0)
    callCount[key] = currentCountValue + 1
    callParams[key] = CallParam(param.toMutableList())
  }

  abstract class InnerTestVerifiable(
    private val outer: TestCallVerifiable,
    private val times: Int = 1,
  ) {

    protected val params: CallParam = CallParam(mutableListOf())

    protected fun check(functionName: String) {
      val actualTimes = getActualCallCount(functionName)
      if (actualTimes != times) {
        throw IllegalStateException(
          "$functionName expected to be called $times, but actual was $actualTimes"
        )
      }
      val callParams = outer.callParams.getOrDefault(functionName, CallParam(mutableListOf()))
      val result = mutableListOf<Boolean>()
      callParams.values.forEachIndexed { index, item ->
        val actualParam = params.values[index]
        if (item == params.values[index] || (item != actualParam && isAnyParams(actualParam))) {
          result.add(true)
        }
      }
      if (params.values.isNotEmpty() && !result.all { it } || result.isEmpty()) {
        throw IllegalStateException(
          "$functionName expected to be called with ${callParams.values}, but actual was with ${params.values}"
        )
      }
    }

    private fun isAnyParams(vararg param: Any): Boolean {
      param.forEach {
        if (it.isAnyPrimitive()) return true
      }
      return false
    }

    private fun getActualCallCount(functionName: String): Int {
      return outer.callCount.getOrDefault(functionName, 0)
    }
  }

  data class CallParam(val values: MutableList<Any> = mutableListOf())
}

下面是完整的类:

open class TestToaster : TestCallVerifiable, Toaster {

  override var callCount: MutableMap<String, Int> = mutableMapOf()
  override val callParams: MutableMap<String, TestCallVerifiable.CallParam> = mutableMapOf()

  override fun showSuccessMessage(message: String) {
    val key = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
    addCall(key, message)
  }

  override fun showSuccessMessage(message: Int) {
    val key = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
    addCall(key, message)
  }

  override fun showErrorMessage(message: String) {
    val key = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
    addCall(key, message)
  }

  override fun showErrorMessage(message: Int) {
    val key = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
    addCall(key, message)
  }

  private class InnerToaster(
    verifiable: TestCallVerifiable,
    times: Int,
  ) : TestCallVerifiable.InnerTestVerifiable(
    outer = verifiable,
    times = times,
  ), Toaster {

    override fun showSuccessMessage(message: String) {
      params.values.add(message)
      val functionName = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
      check(functionName)
    }

    override fun showSuccessMessage(message: Int) {
      params.values.add(message)
      val functionName = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
      check(functionName)
    }

    override fun showErrorMessage(message: String) {
      params.values.add(message)
      val functionName = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
      check(functionName)
    }

    override fun showErrorMessage(message: Int) {
      params.values.add(message)
      val functionName = object {}.javaClass.enclosingMethod?.name + message::class.simpleName
      check(functionName)
    }
  }

  companion object {
    fun verify(toaster: Toaster, times: Int = 1): Toaster {
      return InnerToaster(toaster as TestCallVerifiable, times)
    }
  }
}

我还没有广泛地测试它,它会随着时间的推移而发展,但到目前为止,它对我来说工作得很好。
我还在Medium上写了一篇关于这方面的文章:https://sermilion.medium.com/unit-testing-verify-that-a-method-was-called-without-testing-frameworks-like-mockito-or-mockk-433ef8e1aff4

相关问题