Skip to content
← All insights
Testing13 min read

When Scala tests mock too much

Why interaction-heavy unit tests become coupled to implementation, how to recognise the damage, and how state, stubs, fakes, contract tests and integration tests restore useful confidence.

A passing mock verifies only the conversation you specified

Mocks are useful when an interaction itself is the behaviour: an audit event must be emitted once, a payment must not be submitted twice or a notification must follow a successful change. Problems begin when every collaborator is mocked and the test reproduces the implementation call by call.

Such a test can pass while repository queries, JSON codecs or dependency wiring fail in the real application. It can also fail after a safe refactor that preserves the public result but changes the order or number of internal calls.

The question is not whether Mockito is acceptable. It is whether the assertion provides evidence about a requirement or merely confirms that the production method matches the test author’s script.

A test coupled to internal choreographyscala 2.13 / Mockito
when(repository.find(id)).thenReturn(Future.successful(Some(account)))
when(repository.save(updated)).thenReturn(Future.successful(()))

service.rename(id, name).futureValue shouldBe Right(updated)

verify(repository, times(1)).find(id)
verify(repository, times(1)).save(updated)
verifyNoMoreInteractions(repository)

// A cache or combined repository operation can preserve behaviour
// while breaking this test.

Common symptoms appear in the test design

Long setup blocks, deep mock graphs and repeated lenient stubbing indicate that the test boundary may be too wide. Tests that know private call ordering or verify every getter make refactoring costly without adding meaningful protection.

Another smell is a mock returning a mock. It often means the production code navigates several collaborators or exposes infrastructure shapes instead of asking for one application capability. Frequent static or singleton mocking points to dependencies that are difficult to substitute at a real boundary.

A suite can contain many fast unit tests and still have low confidence if codecs, database behaviour and framework wiring are absent. Count the behaviours and risks covered, not only the number of test cases.

  • Most lines arrange mock behaviour rather than domain input
  • Tests break when calls are reordered but output is unchanged
  • verifyNoMoreInteractions appears throughout ordinary service tests
  • Mocks return other mocks or framework objects
  • The same repository behaviour is restubbed in many classes
  • Few tests cross JSON, persistence or application wiring boundaries

Assert observable state and results first

Start from the public outcome: the returned Either, updated entity, persisted record or emitted event. Verify a call only when that call is itself contractually important or when no stable state can expose the behaviour.

A simple stub supplies predetermined data without asserting how it was consumed. A fake implements a small working version of a dependency, often in memory. Both can let the service change its internal route while preserving the same externally visible test.

Do not build a complete imitation of MongoDB or an HTTP server in memory. Once a fake begins reproducing query semantics, concurrency and failure modes, a focused integration test against the real boundary is more trustworthy.

Test the transition through a small fakescala 2.13 / ScalaTest
final class InMemoryAccounts(initial: List[Account]) extends AccountRepository {
  private var accounts = initial.map(a => a.id -> a).toMap

  def find(id: AccountId): Future[Option[Account]] =
    Future.successful(accounts.get(id))

  def save(account: Account): Future[Unit] = {
    accounts = accounts.updated(account.id, account)
    Future.successful(())
  }

  def stored(id: AccountId): Option[Account] = accounts.get(id)
}

service.rename(id, name).futureValue shouldBe Right(expected)
repository.stored(id) shouldBe Some(expected)

Move infrastructure claims into integration tests

A repository unit test with a mocked collection cannot prove that the actual query finds the intended MongoDB document, that codecs preserve fields or that indexes support the access pattern. Those risks belong in an integration test using the real persistence adapter and a controlled database.

Controller tests should exercise Play request parsing, validation, routing and response JSON when those are the concern. Service tests can stay focused on typed commands and results. Keeping the boundaries distinct avoids both an enormous end-to-end suite and a unit suite that mocks away every important integration.

Contract tests can define shared behaviour for production and test implementations. They are useful when a fake is relied on broadly, but they do not eliminate a smaller number of full-path acceptance tests.

Share the behaviour expected of repository implementationsscala 2.13 / ScalaTest
trait AccountRepositoryContract { this: AsyncWordSpec with Matchers =>
  def repository(): AccountRepository

  "an account repository" should {
    "round-trip an account" in {
      val repo = repository()
      repo.save(account).futureValue
      repo.find(account.id).futureValue shouldBe Some(account)
    }
  }
}

// Run for the real adapter and any fake used by service tests.

Interaction assertions should protect meaningful boundaries

Some requirements are interactions. A failed validation must not write, an idempotency rule must avoid a second external submission, and an audit record may be legally or operationally significant. Verify those calls narrowly.

Avoid exact ordering unless order changes correctness. Prefer never for a prohibited side effect and an argument captor when the content sent across the boundary is the result under test. Broad verifyNoMoreInteractions often freezes harmless implementation details.

Time, randomness and generated identifiers are easier to test through small explicit interfaces or supplied functions than through static mocking. These dependencies also become visible in the production constructor rather than hidden ambient behaviour.

Verify the interaction that carries the requirementscala 2.13 / Mockito
service.rename(closedAccount.id, newName).futureValue shouldBe
  Left(RenameError.AccountClosed)

verify(repository, never()).save(any[Account])

// The absence of a write matters. The number of reads or internal
// helper calls does not need to be specified.

Rebalance the suite around risk

Keep pure domain rules under fast value-based unit tests. Use focused service tests with stubs or fakes for branching workflows. Test actual JSON, persistence and framework adapters at their boundaries, then retain a small number of acceptance tests for critical journeys.

Replace over-mocked tests gradually. Identify what each test was meant to prove, write the observable assertion at the right boundary and only then remove interaction setup. Deleting all mocks at once can discard the few tests that were protecting an important side effect.

A maintainable suite permits internal refactoring while failing for behavioural change. If tests do the opposite, changing the test doubles is not enough; the test boundary and the production design need review together.

  • What user, caller or operational behaviour does this test prove?
  • Could the implementation be safely refactored without changing the assertion?
  • Which real integration risk is currently mocked away?
  • Would a stub, fake or explicit dependency be smaller?
  • Is an interaction being asserted because it matters or because it is visible?
  • Does the suite include failure and recovery behaviour at real boundaries?