Where to Draw the Line Between Unit Tests and Integration Tests
· Go Komura · 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:
- Pure logic goes in unit tests
- Connections, wiring, conversions, and environment differences go in integration tests
- If either could verify it, start with a unit test
- 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:
- The class has too many responsibilities
- 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
decimalwas rounded, - the handling of
DateTimeOffsetin the DB drifted, or nullversus 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.
7. A Recommended Structure in Practice
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:
- First, enumerate the application’s boundaries
- Shape the logic so it can be cut off from the outside world
- For each boundary, place “at least one happy path” and “a representative failure path”
- Keep the number of end-to-end runs small
- 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.
- If you swap in an in-memory fake, does the meaning you wanted to verify survive?
- If it survives, lean toward a unit test.
- When it breaks, would you suspect the connections or configuration rather than the logic?
- If so, lean toward an integration test.
- Is the DB / files / serializer / DI / route / model binding / OS / permissions / bitness / threads the actual subject?
- If so, lean toward an integration test.
- Do you want to run a large number of input patterns fast?
- If so, lean toward a unit test.
- 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.
10. Related Articles
- A Minimum Security Checklist for Windows Application Development
- How Far Can a Windows App Really Be a Single Binary?
- When Do You Actually Need Administrator Privileges on Windows?
- What Is Reg-Free COM?
11. References
-
Microsoft Learn, Integration tests in ASP.NET Core ↩
-
Microsoft Learn, Unit testing best practices for .NET ↩
-
Martin Fowler, The Practical Test Pyramid ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Minimum Requirements for a Custom Logger, with an Integration Test Checklist
To make a custom app's diagnostic logs trustworthy, we lay out UTF-8 JSON Lines, the required fields, flush, rotation, and failure behavi...
How to Speed Up App Validation with Windows Sandbox
How to use Windows Sandbox to isolate administrator-privilege issues, reproduce problems in a clean environment, and simulate missing pri...
A Decision Table for Whether to Exit or Continue After an Unexpected Exception
When an unexpected exception occurs, should the app exit or keep running? We organize the decision from the perspectives of state corrupt...
How to Concretely Isolate "Only the Operations That Need Administrator Privileges" in a Windows App
A concrete walkthrough of keeping a Windows app UI at asInvoker while isolating only the administrator-privileged operations into a helpe...
Storing Secrets in Windows Apps - Avoiding Plaintext Configuration with DPAPI
To avoid storing connection credentials and API tokens in plaintext configuration files in Windows apps, we walk through DPAPI / Protecte...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Technical Consulting & Design Review
Where to cut the boundary between unit tests and integration tests is easy to work through as a pre-implementation design review or a test-strategy consultation.
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.
Public links