Component Testing
Testing one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
2 min read · updated 19 September 2026
A component test starts one deployable, with its own real database, stubs everything past its boundary, and drives it through the interface its callers actually use — usually HTTP.
It sits between integration testing, which tests one seam, and end-to-end testing, which tests every service at once. For a backend service it is frequently the highest-value level in the whole suite, and it is the one most often missing.
#Why it earns its place
An integration test proves the repository works. A unit test proves the
pricing rules work. Neither proves that a POST /orders with a valid body
results in a 201, a row in the database, a message on the queue and a
Location header — which is what the service actually promises.
That whole promise, tested in about 300ms, with no environment and no deployment.
#In-process, .NET
// C#, xUnit + WebApplicationFactory. The real app, real middleware,
// real routing, real DI — with the outbound gateway swapped for a stub.
public class OrdersApiTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
private readonly FakePaymentGateway _payments;
public OrdersApiTests(ApiFactory factory)
{
_payments = factory.Payments;
_client = factory.CreateClient();
}
[Fact]
public async Task Placing_an_order_persists_it_and_returns_its_location()
{
var response = await _client.PostAsJsonAsync("/orders",
new { sku = "book-1", quantity = 2, cardToken = "tok_ok" });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
// Read it back through the API, not the database: the test asserts
// on the contract, so an internal schema change does not break it.
var order = await _client.GetFromJsonAsync<OrderView>(response.Headers.Location);
Assert.Equal(2, order!.Quantity);
Assert.Equal("reserved", order.Status);
}
[Fact]
public async Task A_declined_card_returns_402_and_leaves_no_order()
{
_payments.DeclineNext(); // the failure a real gateway will not give you
var response = await _client.PostAsJsonAsync("/orders",
new { sku = "book-1", quantity = 1, cardToken = "tok_declined" });
Assert.Equal(HttpStatusCode.PaymentRequired, response.StatusCode);
Assert.Empty(await _client.GetFromJsonAsync<OrderView[]>("/orders"));
}
}
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db =
new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
public FakePaymentGateway Payments { get; } = new();
public Task InitializeAsync() => _db.StartAsync();
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IPaymentGateway>();
services.AddSingleton<IPaymentGateway>(Payments); // only the boundary is faked
services.Configure<DbOptions>(o => o.ConnectionString = _db.GetConnectionString());
});
}Everything inside the service is real: routing, model binding, validation, authorization filters, DI wiring, the ORM, the migrations, the database. Only what is beyond the boundary is a double.
That is the rule, and it is what makes these tests both fast and meaningful.
#In-process, elsewhere
# Python, pytest + FastAPI. Same shape: real app, stubbed boundary.
@pytest.fixture
def client(postgres_url, monkeypatch):
monkeypatch.setenv("DATABASE_URL", postgres_url)
app.dependency_overrides[get_payment_gateway] = lambda: FakeGateway()
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
def test_placing_an_order_returns_201_and_a_location(client):
response = client.post("/orders", json={"sku": "book-1", "quantity": 2})
assert response.status_code == 201
assert client.get(response.headers["location"]).json()["status"] == "reserved"// TypeScript, supertest + Express/Fastify. No listening port needed.
import request from 'supertest';
it('rejects an order for a SKU that does not exist', async () => {
await request(app)
.post('/orders')
.send({ sku: 'nope', quantity: 1 })
.expect(422)
.expect(({ body }) => expect(body.error).toMatch(/unknown sku/i));
});#What to assert, and what not to
Assert through the interface. Read the order back with GET /orders/{id}
rather than querying the table. The test then describes the contract, and
survives an internal schema change — which is the difference between a
component test and a slow integration test.
Assert the status code and the error shape. Most services are wrong
about their error responses, because nothing tests them. 402 versus 400
versus 500 is a real behavioural difference that callers depend on.
Do not re-test the domain. If there are forty pricing rules, forty component tests for them is forty HTTP round trips to prove something the unit suite already proved in 8ms. Component tests cover the wiring: one happy path, the interesting failure modes, authorization, and anything that only appears when the pieces are assembled.
Do cover authorization here. This is the natural level for it, and the one where authorization testing is cheapest: the same request as user A and user B, asserting 200 and 403.
#The boundary is the design question
Deciding what counts as "past the boundary" is the only hard part.
- Your own database: inside. Run it for real.
- Your own cache, queue, blob store: inside, containerised.
- Another team's service: outside. Stub it, and back the stub with contract tests.
- A third-party API: outside, always. Stub it — see integration testing with stubs.
- Clock, random, IDs: replace. A non-deterministic component test is a flaky one waiting to happen.
#What it cannot do
It cannot tell you the services agree with each other — that is contract testing — and it cannot tell you the user-facing journey works, because there is no browser and no other service involved.
Those two gaps are exactly why the pyramid still has a top. But a solid component suite shrinks what needs to be up there from hundreds of tests to a couple of dozen, and that is the single biggest lever most backend teams have on their pipeline time.
Common questions
- Is a component test the same as a React component test?
- No, and the collision of names is unfortunate. A React component test exercises one UI component in a DOM; a component test in the service sense exercises one whole deployable through its HTTP interface. Both are legitimate uses of the word; this page is about the second.
- What is the difference between a component test and an end-to-end test?
- A component test runs one service with its own database and stubs for everything beyond its boundary. An end-to-end test runs every service for real. The component test is faster, fully deterministic, and can produce any upstream failure on demand; it just cannot prove that the services agree.
- Do I need a deployed environment for component tests?
- No — that is the point. The service runs in the test process or in a container started by the test, so the suite runs on a laptop and in CI with no shared environment.
Runnable samples for this page
last test results ↗- C# (WebApplicationFactory)
dotnet/Tests.Component/testing-levels/component-testing - TypeScript
typescript/src/testing-levels/component-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Integration TestingTesting your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.
- Contract TestingProving two services still agree without deploying both — consumer-driven contracts with Pact, provider verification in CI, and where contract testing beats end-to-end.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Testing ReactComponents, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.
- Writing Tests in C#The .NET testing stack — xUnit, Moq or NSubstitute, WebApplicationFactory, Testcontainers — and the dependency injection story that makes it the most testable of the four.
- Writing Tests in JavaJUnit 5, Mockito, AssertJ and Testcontainers — the stack that has been stable for a decade, plus Spring's test slices and what they cost.