Where to Draw the Line Between Unit Tests and Integration Tests

· · Testing, Unit Testing, Integration Testing, Test Design, Windows Development, C# / .NET

In test design discussions, the quietly difficult question every time is how much to push into unit tests and from where to promote things into integration tests.

The dangerous positions here are the two extremes:

  • “We want a fast loop, so everything becomes a unit test”
  • “It’s closer to the real thing, so everything becomes an integration test”

The former ends up full of mocks and easily misses the points that break in production; the latter tends to produce a slow, brittle test suite. In practice, the axes to look at are a bit clearer:

  • Is what you want to verify your own logic, or the wiring to the outside?
  • If you swap in an in-memory fake, does the meaning survive?
  • Is the real behavior of the DB / files / HTTP / DI / configuration / framework / OS the actual subject?
  • Do you want to run a large number of input patterns fast?

Once these four are visible, the boundary between unit tests and integration tests becomes much easier to draw.

In this article, we organize the boundary between unit tests and integration tests with a practical bent, based on the public information from Microsoft Learn and Martin Fowler available as of March 2026.123

1. The Conclusion First

Putting it roughly, but in a form that is easy to use in practice:

  1. Pure logic goes in unit tests
  2. Connections, wiring, conversions, and environment differences go in integration tests
  3. If either could verify it, start with a unit test
  4. Rather than making integration tests broad and heavy, narrow them to the boundary

In one sentence: unit tests are “tests of decisions,” integration tests are “tests of connections.”

Things whose meaning is complete without external resources — price calculations, state transitions, input validation, approval conditions, exception classification — are faster, less brittle, and can cover input patterns more thickly when pushed toward unit tests. On the other hand, things that “betray you the moment they are connected” — SQL execution, JSON / CSV serialization, routing, model binding, DI registration, file locks, permissions, COM registration, 32-bit / 64-bit, STA / MTA — are safer placed on the integration-test side.

Microsoft Learn’s Integration tests in ASP.NET Core likewise advises narrowing integration tests to the important infrastructure scenarios and choosing unit tests whenever they suffice.

2. What This Article Means by Unit Tests and Integration Tests

Here, we use the terms as follows.

Level What it verifies Typical setup
Unit test Correctness of one isolated responsibility Use fakes / mocks / stubs and cut off external resources
Integration test Connections between multiple components, and behavior including infrastructure and frameworks Real DB, real files, real serializer, real host, real pipeline, etc.
E2E / functional test User flows through the whole app Deployed app, multiple services, real browser or real process

In .NET’s unit testing guidance, a good unit test is described as fast / isolated / repeatable, depending on no external factors such as the file system or a database. Unit testing best practices for .NET lays this out clearly.

Also, an integration test does not only mean “a heavy test that necessarily uses another process or another server.” Even within the same process, if you wire up multiple real components and verify the genuine behavior of the framework or infrastructure, that leans toward an integration test.

For example, when unit testing an ASP.NET Core controller action, the official guidance is to narrow the subject to the decisions in the action body and handle framework-side interactions like routing, model binding, and filters in integration tests. See Unit test controller logic in ASP.NET Core for a clear breakdown.

3. The Decision Table at a Glance

First, the table that is most useful in practice.

What you want to verify Primary test Notes
Price calculation, discounts, state transitions, input validation Unit test You want to run input patterns thickly
Exception classification, error-message selection, deciding whether to retry Unit test Meaning is complete without real I/O
Repository SQL / ORM translation, transactions Integration test The behavior of the real DB or real provider is the subject
JSON / XML / CSV serialize / deserialize Integration test Wire-format drift is hard to find with fakes
Routing, model binding, filters, middleware Integration test Verifying the connection to the framework
State transitions of WPF / WinForms ViewModels or Presenters Unit test Meaningful without standing up the UI
Actual Binding, Dispatcher, control lifecycle, message loop Integration test or UI test Framework and thread behavior is the subject
File paths, permissions, locks, shared folders, line endings, character encodings Integration test Needs the real behavior of the OS and file system
COM registration, 32-bit / 64-bit, STA / MTA, DLL load source Integration test Environment differences and process boundaries are the subject
Whole-app startup, end-to-end checks of the main use cases E2E / smoke A small number is fine

The trick to reading this is asking which test is closest to “the reason it breaks in production.” Deciding by the uncertainty you want to reduce, rather than by where the code lives, keeps you from drifting.

4. What Unit Tests Should Own

What suits unit tests is responsibility whose meaning remains after the outside world is removed.

For example:

  • Business rules
  • Branching
  • State transitions
  • Input validation
  • Error classification
  • Deciding the retry policy
  • ViewModel / Presenter state changes
  • Conversion logic itself

In particular, the more combinations something has, the higher the value of pushing it into unit tests.

For example, as branching conditions multiply —

  • coupon present / absent
  • in stock / out of stock
  • first order / repeat order
  • administrator / regular user
  • valid value / boundary value / invalid value

— running them all through integration tests gets heavy. Here it is more rational to slice things finely with unit tests.

It is also important in unit tests to keep external factors controllable.

  • Inject the current time
  • Make GUIDs and random numbers replaceable
  • Do not wait with sleeps
  • Do not touch a real DB or real files
  • Do not go out on the real network

When these are observed, the tests become quite stable.

4.1. When Mocks Multiply in a Unit Test

If, when trying to write a unit test,

  • you need 7 mocks,
  • the setup is long,
  • the arrange section is longer than the body, or
  • you can no longer see what you wanted to verify,

it is usually one of these two:

  1. The class has too many responsibilities
  2. You are pushing wiring that should really be verified by an integration test into a unit test

Mocks are a tool for cutting off the outside world — they are not a tool for proving that the connection to the real thing is correct. Confuse the two, and “everything is green yet production falls over” becomes likely.

5. The Four Boundaries to Promote to Integration Tests

The places worth promoting to integration tests can be organized into roughly four: formats, wiring, environment, and time.

5.1. The format boundary

Formats here include things like:

  • JSON / XML / CSV
  • DB schema and mapping
  • nullable / precision / timezone
  • Serialization of enums and dates
  • Character encodings and BOMs
  • Line endings

Martin Fowler also lists boundaries involving serialize / deserialize as integration-test candidates. The Practical Test Pyramid is a good reference.

For example, defects like

  • a DTO serialized to JSON came out with different field names,
  • CSV quoting or line breaks got mangled,
  • a decimal was rounded,
  • the handling of DateTimeOffset in the DB drifted, or
  • null versus empty string behaved differently than expected,

slip past unit tests easily.

5.2. The wiring boundary

The wiring boundary includes parts like these:

  • DI registration
  • Configuration binding
  • Routing
  • Model binding
  • Filters
  • Middleware
  • Host startup
  • Event wiring
  • WPF Binding and command hookup

Here, the subject is not “is my function correct?” but whether multiple real parts are connected correctly.

In ASP.NET Core, the official guidance is to narrow controller-action unit tests to the action’s decisions, and look at routing, model binding, and filters on the integration-test side. The thinking is the same outside the web: in a desktop app too, ViewModel state transitions belong in unit tests, while behavior involving actual XAML Binding or the Dispatcher leans toward integration tests.

5.3. The environment boundary

In Windows development, this one matters a great deal.

  • File permissions
  • Shared folders
  • File locks
  • Rename from a temporary file
  • Administrator privileges
  • Service start permissions
  • COM registration
  • 32-bit / 64-bit
  • STA / MTA
  • Where DLLs are loaded from

Here, the conditions of the OS and execution environment themselves are the lead actors. With in-memory fakes the meaning largely evaporates, so it is safer to cover these with integration tests.

In particular, in configurations involving existing Windows software or COM / ActiveX, it is entirely normal to trip over registration, bitness, threading model, and permissions before the logic ever gets a chance. These failures are the territory that environment-inclusive integration tests pick up — not unit tests.

5.4. The time boundary

One more thing that is easy to overlook is time and concurrency.

  • Timeouts
  • Cancellation
  • The actual behavior of retries
  • Timer-driven processing
  • Stopping background work
  • Race conditions
  • Shutdown ordering

What matters here is separating decisions from actual behavior.

For example,

  • how many times to retry, and
  • which exceptions are retryable

are perfectly served by unit tests. On the other hand,

  • whether the timeout actually fires,
  • whether cancellation propagates,
  • whether things survive a collision between a timer and async work, and
  • whether handles and tasks close cleanly at shutdown

lean toward integration tests.

6. Common Judgment Mistakes

6.1. Mocking the Repository and Calling It Done

Even if everything around the Repository passes with mocks, you still do not know

  • whether the SQL is correct,
  • whether transactions take effect,
  • whether it matches the schema,
  • whether the mapping drifts, or
  • whether encodings and precision survive.

A Repository is usually less a target of logic testing and more a connection point at a boundary. In that case, raising the weight of integration tests over unit tests matches reality better.

6.2. Trying to Cover the Framework in a Controller / Endpoint Unit Test

What you want to see in a controller-action unit test is roughly

  • the conditional branching,
  • the choice of return value, and
  • which dependent services get called.

Meanwhile,

  • whether the route matches,
  • whether model binding goes through,
  • whether the filter takes effect, and
  • how things look after passing through the middleware

belong on the integration-test side. Mix these, and it becomes hard to tell what broke.

6.3. Brute-Forcing Input Patterns in Integration Tests

Integration tests, being closer to the real thing, are inevitably slower. So it pays to split: brute-forcing the branches goes to unit tests, representative cases at the boundary go to integration tests.

Microsoft Learn’s integration-test guidance likewise recommends, for databases and the file system, not running every pattern through integration tests but narrowing to representative scenarios such as read / write / update / delete.

6.4. Hitting Production Instances of External Services Directly from CI

This is safer avoided.

“Realness” matters in integration tests, but that does not mean hitting production SaaS or production APIs every run. Fowler likewise recommends running external services locally, placing fakes, or using a dedicated test instance.

In practice, a combination of

  • a local DB,
  • temporary directories,
  • a test host,
  • a dedicated test environment, and
  • a fake service with a pinned contract

is easy to work with.

There is no absolute correct ratio. But this three-layer structure is broadly applicable.

Layer Mainstay What goes there
Core layer Thick unit tests Business rules, state transitions, input validation, error classification
Boundary layer Narrow integration tests DB, files, HTTP, serializer, DI, configuration, COM, permissions
Whole-app layer A few smoke / E2E tests Startup checks, main flows, regression prevention for serious incidents

Intuitively, unit tests get thick in count, integration tests get thick in the density of the boundary.

The recommended way to proceed:

  1. First, enumerate the application’s boundaries
  2. Shape the logic so it can be cut off from the outside world
  3. For each boundary, place “at least one happy path” and “a representative failure path”
  4. Keep the number of end-to-end runs small
  5. When a bug appears, add a test at the layer that can reproduce that bug at minimum cost

The last point, 5, is the important one.

  • If it is a rule error, add a unit test
  • If it is an error in SQL / binding / configuration / permissions / registration, add an integration test
  • If it is a failure involving startup or distribution, add a smoke or E2E test

Growing the suite this way keeps the responsibilities of the tests from drifting.

8. Five Questions to Ask When in Doubt

Finally, here are five questions condensed for checking when in doubt.

  1. If you swap in an in-memory fake, does the meaning you wanted to verify survive?
    • If it survives, lean toward a unit test.
  2. When it breaks, would you suspect the connections or configuration rather than the logic?
    • If so, lean toward an integration test.
  3. Is the DB / files / serializer / DI / route / model binding / OS / permissions / bitness / threads the actual subject?
    • If so, lean toward an integration test.
  4. Do you want to run a large number of input patterns fast?
    • If so, lean toward a unit test.
  5. When that test fails, is it immediately clear what to fix?
    • If not, the test layers are mixed.

Sorting things with these five questions makes it easier to avoid the sloppy decision-making of “integration test because it’s vaguely closer to the real thing” or “unit test because it’s vaguely faster.”

9. Summary

The boundary between unit tests and integration tests is most practically decided not by where the code lives but by what uncertainty you want to reduce.

The essentials come down to these five.

  • Unit tests are tests of decisions
  • Integration tests are tests of connections
  • Brute-forcing the branches goes to unit tests
  • Formats, wiring, environment, and time go to integration tests
  • Cover whole-app end-to-end checks with a few smoke / E2E tests

What you most want to avoid are these three:

  • Believing mocks have proven the connection to the real thing
  • Trying to run every branch through integration tests
  • Mixing the responsibilities of unit tests and integration tests

When in doubt, first ask: does this defect break a “decision,” or does it break a “connection”? That one question sorts out a large share of cases.

11. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Windows App Development

In Windows apps, boundaries like files, permissions, COM, and 32-bit / 64-bit map directly onto the test layers, so this pairs well with sorting out the implementation approach.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog