bdmendes / smockito   2.2.0

MIT License GitHub

Tiny Scala facade for Mockito.

Scala versions: 3.x

Smockito

Build Codecov Maven Central Javadoc

Smockito is a tiny framework-agnostic Scala 3 facade for Mockito. It enables setting up unique method and value stubs for any type in a type-safe manner, while providing an expressive interface for inspecting received arguments and call counts.


Motivation

Even when software components make use of proper dependency injection, a mocking framework is useful as a construction and interception sugar. scalamock is an excellent native tool for that use case, but has its limitations. Mockito, on the other hand, is very powerful and popular, but exposes a Java API that arguably does not fit well with Scala's expressiveness and safety.

Smockito leverages a subset of Mockito’s features and offers a minimal, opinionated interface, guided by a few core principles:

  • A method should be stubbed only once, and use the same implementation for the lifetime of the mock.
  • A method stub should handle only the inputs it expects.
  • A method stub should always be executed, as the real method would.
  • An unstubbed method must throw and not return a lenient sentinel value.

Quick Start

To use Smockito in an existing sbt project with Scala 3, add the following dependency to your build.sbt:

libraryDependencies += "com.bdmendes" %% "smockito" % "<version>" % Test

Do not depend on Mockito directly.

If targeting Java 24+, you need to add the Smockito JAR as a Java agent to enable the runtime bytecode manipulation that Mockito depends on. If you use the sbt-javaagent plugin, you can simply add to your build.sbt:

javaAgents += "com.bdmendes" % "smockito_3" % "<version>" % Test

In your specification, extend Smockito. This will bring the mock method and relevant conversions to scope. To set up a mock, add stub definitions with the on method, which requires an eta-expanded method reference, that you may easily express with it, and a partial function to handle the relevant inputs.

abstract class Repository[T](val name: String):
  def get: List[T]
  def exists(username: String): Boolean
  def greet()(using T): String
  def getWith(startsWith: String, endsWith: String): List[T]

case class User(username: String)

class RepositorySpecification extends Smockito:
  val repository = mock[Repository[User]]
    .on(() => it.name)(_ => "xpto")
    .on(() => it.get)(_ => List(User("johndoe")))
    .on(it.exists)(_ == "johndoe")
    .on(it.greet()(using _: User))(user => s"Hello, ${user.username}!")
    .on(it.getWith):
      case ("john", name) if name.nonEmpty => List(User("johndoe"))

A Mock[T] is a T both at compile time and runtime.

  assert(repository.getWith("john", "doe") == List(User("johndoe")))

You may reason about method interactions with calls and times. If arguments are not needed, times is more efficient.

  assert(repository.calls(it.getWith) == List(("john", "doe")))
  assert(repository.times(it.getWith) == 1)

FAQ

Does Smockito support Scala 2?

No. Smockito leverages a handful of powerful Scala 3 features, such as inlining, opaque types, context functions and match types. If you are on the process of migrating a Scala 2 codebase, it might be a good opportunity to replace the likes of specs2-mock or mockito-scala as you migrate your modules.

Is this really a mocking framework?

This is a facade for Mockito, which in itself is technically a test spy framework. There is a great debate regarding the definitions of mocks, stubs, spies, test duplicates... Here, we assume a mock to be a "faked" object, and a stub a provided implementation for a subset of the input space.

Is Smockito compatible with effect systems?

Yes. Implement your stub as you would in application code. For example, with cats-effect:

abstract class EffectRepository[T]:
  def exists(username: String): IO[Boolean]

val repository =
  mock[EffectRepository[User]].on(it.exists):
    case "johndoe" =>
      IO(true)
    case _ =>
      IO.raiseError(IllegalArgumentException("Unexpected user"))

Notice we are handling partiality explicitly. This is useful if you don't want Smockito to throw UnexpectedArguments behind the scenes.

How do I spy on a real instance?

Though not the main Smockito use case, you may achieve so by setting up a stub on a mock that forwards to a real instance:

val repository =
  val realInstance = Repository.fromDatabase[User]
  mock[Repository[User]].forward(it.exists, realInstance)

assert(repository.times(it.exists) == 0)

That said, make sure you also test the real instance in isolation.

How do I reset a mock?

Don't. Instead of clearing history on a global mock, create a fresh mock for each test case. This approach avoids race conditions entirely, with a negligible performance cost.

Should I override stubs to change behavior?

No. It's always best to define a unique stub and be explicit about behavior change. If you want to perform a different action on a subsequent invocation, for instance to simulate transient failures, consider using onCall:

val repository = 
  mock[Repository[User]].onCall(it.exists):
    case 1 | 2 => _ == "johndoe"
    case _ => _ => false

If you have a mock whose setup is only slightly changed between test cases, instead of overriding a stub defined in some base trait, create a factory method:

def mockRepository(username: String): Mock[Repository[User]] =
  mock[Repository[User]]
    .on(() => it.get)(_ => List(User("johndoe")))
    .on(it.exists)(_ == "johndoe")
    .on(it.greet()(using _: User))(_ => s"Hello, $username!")

Can I reason about invocation orders?

Yes. Use calledBefore or calledAfter:

val repository =
  mock[Repository[User]]
    .on(it.exists)(_ => true)
    .on(() => it.get)(_ => List.empty)

val _ = repository.exists("johndoe")
val _ = repository.get

assert(repository.calledBefore(it.exists, () => it.get))

When doing so, consider whether this behavior is a hard requirement of your system or merely an implementation detail. If it is the latter, the assertion might be an overspecification.

What happens if I call an unstubbed method?

An unstubbed method call will throw an UnstubbedMethod exception. This decision is based on the belief that returning a lenient value would reduce test readability and increase the likelihood of bugs.

Even so, you may want to dispatch an adapter method to its actual implementation in order to stub a method at the bottom of the hierarchy. This can be achieved using real:

trait Getter:
  def getNames: List[String]
  def getNamesAdapter(setting: String) = getNames

val getter = mock[Getter]
  .on(() => it.getNames)(_ => List("john"))
  .real(it.getNamesAdapter)

assert(getter.getNamesAdapter("dummy") == List("john"))
assert(getter.times(() => it.getNames) == 1)

What happens if I stub a method more than once?

The last stub takes precedence. If possible, follow the unique stub principle.

I can't seem to stub a method/I found a bug.

Are you performing eta-expansion correctly? Check out the main SmockitoSpec for more examples covering a variety of situations. If everything looks fine on your side, please file an issue with a minimal reproducible example.

What can I do with the source code?

Mostly anything you want to. Check the license. All contributions are appreciated.

Special Thanks

  • Mockito: for the reliable core.
  • Scalamock: for the excellent Stubs API design that inspired this library.
  • @biromiro: for designing the cute cocktail logo.