Unit and integration testing: choose the boundary that proves the behaviour
How unit and integration tests provide different evidence, where mocks and real adapters belong, and how to build a suite that stays fast without testing away production risk.
The distinction is the boundary under test
A unit test exercises one coherent unit of behaviour with its external dependencies controlled. That unit may be a pure function, a class or a small group of closely related objects. It is not defined by whether the test uses a particular annotation or finishes within a millisecond.
An integration test checks that separately implemented parts agree at a real boundary. It may exercise routing and JSON decoding, a repository and database, dependency injection, or an application and an external API substitute. The important point is that the integration behaviour itself is present rather than mocked away.
Speed is a consequence of these boundaries, not the complete definition. Unit tests are usually fast and precise; integration tests are usually slower and require more setup. Both should answer a specific question about behaviour.
Unit tests are strongest around decisions
Pure domain rules are excellent unit-test candidates. Inputs and outputs are explicit, failures are reproducible, and the test does not need framework or persistence setup.
Test representative equivalence classes and boundaries rather than every possible value. For a withdrawal rule, useful cases might be an exact available balance, one amount above it, a closed account and an invalid amount. The assertion should describe the decision, not helper methods used to reach it.
Keep construction honest. If production code can only create PositiveAmount through validation, tests should normally use the same path rather than bypassing the invariant with unsafe fixtures.
"withdraw" should "reject an amount above the available balance" in {
val account = openAccount(balance = BigDecimal("100.00"))
val amount = PositiveAmount.from(BigDecimal("100.01")).toOption.get
account.withdraw(amount) shouldBe
Left(WithdrawalError.InsufficientFunds(
available = BigDecimal("100.00"),
requested = BigDecimal("100.01")
))
}
// No controller, database or mock is needed to prove this rule.A service unit test can control collaborators without scripting everything
Application services often coordinate repositories, clocks or external operations. A small stub or fake can provide controlled behaviour while the test asserts the service result and observable state.
Mocks are appropriate when an interaction is the requirement, such as preventing a write after failed validation or emitting an audit event. Verifying every call and its exact order couples the test to implementation without proving that the real adapter works.
A fake repository is not an integration test merely because several classes are involved. It proves service behaviour against the fake’s semantics. Keep separate integration evidence for the production repository.
val accounts = new InMemoryAccounts(List(openAccount))
val service = new RenameAccount(accounts)
service.rename(openAccount.id, AccountName("New name")).futureValue shouldBe
Right(openAccount.copy(name = AccountName("New name")))
accounts.find(openAccount.id).futureValue.map(_.name) shouldBe
Some(AccountName("New name"))Integration tests belong where assumptions cross
Serialization, SQL or MongoDB queries, routing, dependency injection and framework configuration contain behaviour that a mocked unit test cannot verify. Use the real adapter or framework boundary when those details are part of the risk.
Keep an integration test focused. A repository test can prove document round-tripping, missing records, uniqueness and query behaviour without starting a browser. A Play route test can prove request parsing, validation and response JSON without exercising an unrelated third-party system.
Name the integration being tested. “Creates account” is vague; “accepts the supported JSON shape and returns the persisted account response” describes the evidence and makes failures easier to place.
"POST /accounts" should {
"reject a blank display name at the HTTP boundary" in {
val request = FakeRequest(POST, "/accounts")
.withJsonBody(Json.obj("displayName" -> ""))
val result = route(app, request).value
status(result) shouldBe BAD_REQUEST
contentAsJson(result) shouldBe Json.obj(
"code" -> "invalid_display_name"
)
}
}Use real persistence semantics where they matter
A repository mock cannot prove field names, indexes, uniqueness, transaction behaviour or whether a query matches the stored representation. Integration tests should apply the real mapping and talk to a controlled database compatible with production.
Start each test from known data and verify externally visible repository behaviour. Avoid assertions against incidental driver calls. If production relies on a unique constraint, exercise the duplicate path and confirm how the adapter translates the failure.
A lightweight substitute can be useful only when its semantics match the claims being tested. An in-memory map cannot stand in for MongoDB query behaviour, and a different relational engine may disagree with PostgreSQL on types, constraints or SQL.
- Round-trip representative records
- Read older supported representations where compatibility matters
- Exercise missing and duplicate data
- Apply migrations from a clean database
- Check the important production-shaped query and index assumptions
Do not use integration tests to compensate for tangled design
If every business rule requires booting the full application, the production boundary may be too broad. Extract pure decisions and typed application operations so they can be tested directly, then retain integration tests for the framework and infrastructure joins.
The opposite extreme is equally risky: replacing every dependency with a mock can produce a fast suite that never exercises the paths most likely to fail in production. Test design should follow risk, not a fixed percentage of unit and integration tests.
A healthy suite often has many focused unit tests, a smaller set of adapter and boundary integration tests, and a very small number of full journeys. That shape is a consequence of where behaviour lives rather than a quota.
Test failure channels, not only successful composition
Integrations fail in ways pure domain functions do not: timeouts, unavailable databases, malformed payloads, duplicate keys and incompatible stored data. The application boundary should preserve enough meaning to respond appropriately.
Do not make an integration suite unreliable by injecting random infrastructure failures into every run. Test deterministic failure cases at the narrowest real boundary and use focused application tests for retry, fallback and error-translation policies.
Keep absence separate from operational failure. A database outage should not pass a test merely because the service returns the same empty Option used for a missing record.
repository.find(missingId).futureValue shouldBe None
recoverToExceptionIf[RepositoryUnavailable] {
repositoryWithUnavailableDatabase.find(existingId)
}
// The service boundary can now translate these paths differently.Asynchronous tests must wait for the assertion
A test that starts a Future and returns before it completes can pass before the assertion runs. Return the assertion future or use the asynchronous support provided by the test framework. Avoid Thread.sleep: it makes a timing guess and slows the suite even when the operation completes immediately.
Control eventual consistency with bounded polling against an observable condition. Give failures a useful timeout message and keep the overall test deadline explicit.
Isolation also matters. Shared mutable fixtures make failures depend on test order and parallel execution. Give each test unique identifiers and clean ownership of the data it creates.
"save" should {
"make the account available to a later read" in {
for {
_ <- repository.save(account)
stored <- repository.find(account.id)
} yield stored shouldBe Some(account)
}
}
// ScalaTest observes the returned Future[Assertion].Make local and CI feedback intentional
Run fast unit tests on every ordinary change. Run focused integration suites whenever their application or adapter changes, and run the complete required set before merge. Parallelise independent suites only when test data and shared infrastructure support it.
A separate integration task can make ownership and runtime visible, but it should not become an optional check nobody runs. Provide one documented command that starts required dependencies, applies migrations and executes the suite from a clean checkout.
Track duration and flakiness. A slow test with high-value evidence may be worth keeping; an unreliable test that nobody trusts is not protecting delivery. Diagnose it, narrow it or redesign its data and synchronisation rather than retrying indefinitely.
Choose the smallest boundary that can prove the claim
Start each test with a sentence: what could be wrong, and which real components must run to reveal it? A pricing rule needs values. A JSON compatibility claim needs the codec. A repository query needs the database adapter and compatible database. A wired endpoint may need the application.
Do not ask one end-to-end test to diagnose every layer. Keep lower-level tests around important decisions so a full-path failure is supported by faster, more precise evidence.
Unit and integration testing are not competing schools. They are different instruments. A maintainable suite uses each where its evidence is strongest and leaves as little important behaviour as possible protected only by an imitation.
- Can a pure input and output prove this behaviour?
- Which dependency semantics are part of the claim?
- Has a mock removed the risk the test is meant to cover?
- Can the failure identify the responsible boundary?
- Is test data isolated and repeatable?
- Will the suite still help after an internal refactor?