Where to Draw the Line Between Unit Tests and Integration Tests

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

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614587)
First published
Cite this article(DOI: 10.5281/zenodo.21614586)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Where to Draw the Line Between Unit Tests and Integration Tests. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614586 https://comcomponent.com/en/blog/2026/03/25/004-unit-test-vs-integration-test-boundary-guide/

DOI (latest version)
10.5281/zenodo.21614586
DOI (this version)
10.5281/zenodo.22220386

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.

What is dangerous here are the two extremes:

  • we want a fast loop, so everything becomes a unit test
  • it is 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 worth looking at are a bit clearer than that.

  • Is what you want to verify your own logic, or the wiring to the outside world?
  • 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.

This article assumes automated test design in C# / .NET. The code examples use xUnit, but the decision criteria themselves do not depend on the framework. They carry over to JUnit or pytest as written.

The content is based on material available as of March 2026: Microsoft Learn’s “Integration tests in ASP.NET Core”1, the same site’s “Unit testing best practices for .NET”2, and Martin Fowler’s “The Practical Test Pyramid”3. To keep the references from being bare numbers, the source names appear in the body text as well. The original URLs are collected in the references at the end of the article.

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.

Knowledge map for this article

This article sorts out the boundary between unit tests and integration tests along the axis of whether a test is a test of decisions or a test of connections. It holds that exhaustive branch coverage, as in amount calculations or state transitions, should be pushed toward unit tests, while the four boundaries of format, wiring, environment, and time should be placed in integration tests because an in-memory fake strips them of meaning. After sorting out the differences among the test doubles stub, mock, and fake, it presents a growing number of mocks as a sign that wiring is being forced into unit tests, and offers as the practical landing point a three-layer structure that covers the core layer heavily with unit tests, the boundary layer with narrow integration tests, and the whole-system layer with a small number of smoke and E2E tests.

The boundary between unit tests and integration testsDiagram showing that a unit test tests decisions while an integration test tests connections, that the four boundaries of format, wiring, environment, and time belong on the integration test side, the differences among stub, mock, and fake and how mock overuse signals that wiring has crept in, and the relationship of the three-layer test structure made up of the core layer, the boundary layer, and the whole-system layerusesusesusesrecommended forrecommended forrecommended forrecommended forrecommended forrequiresrequiresmay causenot recommended forrequiresrequiresrequiresnot recommended fornot recommended fornot recommended forUnit TestIntegration TestStub (Test Double)Mock (Test Double)Fake (Test Double)Format BoundaryWiring BoundaryEnvironment BoundaryTime and Concurrency BoundaryExhaustive Branch CoverageBitness Match RequirementCOM apartment model (STA/MTA)Mock Overuse SmellThree-Layer Test StructureEnd-to-End Test

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (18 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

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.

2.1. Telling fake, mock, and stub apart

The table above lists fake / mock / stub side by side, but the three are not the same thing. As a classification of test doubles (stand-ins used in tests), this article uses them with the following meanings, following the distinctions Martin Fowler draws in “Mocks Aren’t Stubs.”4

Name What it does Where it typically fits
stub A stand-in that only returns predetermined values. How it is called is not verified When you want to pin down input conditions, such as “stock is always 3”
mock A stand-in that verifies how it was called. Asserts call counts and arguments When you want to confirm that “save is called exactly once”
fake A lightweight implementation that behaves like the real thing An in-memory repository, a file store in a temporary directory

Roughly speaking, a stub stands in for input, a mock verifies calls, and a fake is a simplified implementation. This distinction starts to pay off in section 4. The symptom “this test needs 7 mocks” precisely means there are 7 things whose calls are being verified, which is a sign that the test is not checking one decision but how several parts are wired together.

2.2. How this article treats E2E

The subject of this article is strictly the boundary between unit tests and integration tests. E2E / functional tests appear in the table above for contrast, but the body of the article does not go into them.

Just to place them: in the test pyramid, the shape is a triangle where the lower layers have more tests and run faster, and the upper layers have fewer tests and run slower. Martin Fowler’s “The Practical Test Pyramid” is the starting point for this framing.3 In other words, the ratio is unit tests as the foundation, integration tests placed at each boundary, and E2E narrowed to the main flows. The concrete arrangement is collected in the three-layer structure in section 7.

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, with

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

the more branching conditions pile up, the heavier it gets to run them all through integration tests. 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 you sit down to write a unit test and find that

  • you need 7 mocks
  • the setup is long
  • the arrange section is longer than the body
  • 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.

4.2. A minimal unit test example

Words alone stay abstract, so here is one example from each side of the boundary in code. Start with the unit test side. A ViewModel’s state transitions are complete in meaning without standing up the UI, so this is unit-test territory.

// .NET 8 / xUnit
// Subject: a ViewModel that touches no external resource at all
public sealed class OrderViewModel
{
    public decimal Subtotal { get; set; }
    public bool IsMember { get; set; }

    public bool CanCheckout => Subtotal > 0m;
    public decimal Total => IsMember ? Subtotal * 0.9m : Subtotal;
}

public class OrderViewModelTests
{
    [Fact]
    public void Members_get_a_ten_percent_discount()
    {
        var viewModel = new OrderViewModel { Subtotal = 1000m, IsMember = true };

        Assert.Equal(900m, viewModel.Total);
    }

    [Theory]
    [InlineData(0, false)]
    [InlineData(1, true)]
    public void Cannot_check_out_when_the_subtotal_is_zero(int subtotal, bool expected)
    {
        var viewModel = new OrderViewModel { Subtotal = subtotal };

        Assert.Equal(expected, viewModel.CanCheckout);
    }
}

There is no DB, no file, and no HTTP here. That is why it is fast, runs in parallel, and lets you grow input patterns without limit through [Theory]. Pushing brute-force branch coverage down here means, concretely, tests shaped like this.

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
  • null and the 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 whether your own function is 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 the execution environment themselves are what drive the outcome. 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.

  • timeout
  • cancellation
  • The actual behavior of retries
  • Timer-driven processing
  • Stopping background work
  • race condition
  • Shutdown ordering

What matters here is separating decisions from actual behavior.

For example,

  • how many times to retry
  • which exceptions are retryable

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

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

lean toward integration tests.

5.5. A minimal integration test example

Using the same subject matter as section 4.2, here is the other side of the boundary. What we want to verify is whether a saved amount survives a round trip through the database. It goes through a real SQLite file.

// .NET 8 / xUnit / Microsoft.Data.Sqlite
using System.Globalization;
using Microsoft.Data.Sqlite;

public sealed class OrderRepository(SqliteConnection connection)
{
    public void Save(int id, decimal total)
    {
        using var command = connection.CreateCommand();
        command.CommandText = "INSERT INTO orders (id, total) VALUES ($id, $total);";
        command.Parameters.AddWithValue("$id", id);
        command.Parameters.AddWithValue("$total", total.ToString(CultureInfo.InvariantCulture));
        command.ExecuteNonQuery();
    }

    public decimal FindTotal(int id)
    {
        using var command = connection.CreateCommand();
        command.CommandText = "SELECT total FROM orders WHERE id = $id;";
        command.Parameters.AddWithValue("$id", id);
        var stored = (string)command.ExecuteScalar()!;
        return decimal.Parse(stored, CultureInfo.InvariantCulture);
    }
}

public sealed class OrderRepositoryTests : IDisposable
{
    private readonly string _databasePath =
        Path.Combine(Path.GetTempPath(), $"orders-{Guid.NewGuid():N}.db");
    private readonly SqliteConnection _connection;

    public OrderRepositoryTests()
    {
        // Without Pooling=False, cleanup can fail to delete the db file
        _connection = new SqliteConnection($"Data Source={_databasePath};Pooling=False");
        _connection.Open();

        using var create = _connection.CreateCommand();
        create.CommandText = "CREATE TABLE orders (id INTEGER PRIMARY KEY, total TEXT NOT NULL);";
        create.ExecuteNonQuery();
    }

    [Fact]
    public void The_saved_amount_reads_back_without_rounding()
    {
        var repository = new OrderRepository(_connection);

        repository.Save(id: 1, total: 1234.56m);

        Assert.Equal(1234.56m, repository.FindTotal(1));
    }

    public void Dispose()
    {
        _connection.Dispose();
        File.Delete(_databasePath);
    }
}

What this test verifies is not the branching inside OrderRepository. It is the connection: whether the SQL goes through, whether the column type lines up with decimal, and whether the value is preserved across the round trip. SQLite has no decimal type, so “which type do I store this in so it survives the round trip” is not an implementation question but a question of connection design. An in-memory fake cannot show you this.

In this example, each test class creates one temporary DB file and deletes it in Dispose. Integration tests hold state, so being explicit every time about where you create it and where you throw it away matters more than it does in unit 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
  • 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
  • which dependent services get called

Meanwhile,

  • whether the route matches
  • whether model binding goes through
  • whether the filter takes effect
  • 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 one is best avoided.

Realism 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
  • 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 the 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 out with these five questions makes it easier to avoid the sloppy decision-making of “integration test because it is vaguely closer to the real thing” or “unit test because it is 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.

Frequently Asked Questions

Common questions about the topic of this article.

How should I decide between a unit test and an integration test?
In one sentence, a unit test is a test of decisions and an integration test is a test of connections. Anything whose meaning is complete without external resources - price calculation, state transitions, input validation, exception classification - belongs with unit tests. Things that betray you the moment they are connected - SQL execution, JSON/CSV serialization, routing, DI registration, file locks, permissions, COM registration, 32-bit/64-bit - go on the integration-test side. If either level could verify it, start with a unit test.
What kinds of things should be promoted to integration tests?
They sort into roughly four boundaries: format, wiring, environment, and time. Format covers JSON/CSV, database mapping, character encodings, and the like. Wiring covers hooking real parts together: DI registration, routing, model binding. Environment covers real OS behavior such as file permissions, COM registration, 32-bit/64-bit, and STA/MTA. Time covers timeouts, cancellation, and race conditions. All of these lose their meaning against an in-memory fake, so it is safer to pin them down with integration tests.
What is the problem when a unit test ends up with too many mocks?
If you need seven mocks, the setup runs long, and you can no longer see what the test is verifying, then either the class under test has too many responsibilities, or you are pushing wiring that really belongs in an integration test into a unit test. A mock is a tool for cutting off the outside world, not a tool for proving that the connection to the real thing is correct. Mistake one for the other and you get suites that are all green while production falls over.
What structure and ratio should a test suite have?
A three-layer structure is broadly applicable. The core layer carries business rules and state transitions thickly in unit tests, the boundary layer places narrow integration tests around databases, files, serializers, and DI, and the whole-app layer covers startup and the main flows with a small number of smoke/E2E tests. Brute-force the branches in unit tests, and narrow integration tests to at least one happy path and a representative failure path per boundary. When a bug shows up, add the test at the layer that can reproduce it at minimum cost.

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