A service component test starts one real microservice (its real controllers, validation, business logic, EF Core mappings and messaging code) inside a test process, replaces everything *outside* the service boundary...
Intro
A service component test starts one real microservice (its real controllers, validation, business logic, EF Core mappings and messaging code) inside a test process, replaces everything outside the service boundary with controlled stand-ins (a real throw-away database in a container, a stubbed HTTP partner, an in-memory or emulated broker), and then drives it only through its public interface (HTTP or messages). It gives you most of the confidence of an end-to-end test at a fraction of the cost, and it is the backbone of a healthy microservice test strategy.
Running example: the Claims Service of an insurance claims system. It accepts a claim, checks the policy with the Policy Service over HTTP, stores the claim in PostgreSQL / SQL Server, and publishes a ClaimSubmitted event.
Why we need this
- Unit tests prove a class works alone; they cannot prove that routing, model binding, JSON serialisation, EF Core mappings, migrations, DI wiring and middleware work together. Most production bugs live in those seams.
- End-to-end suites across 20 services need every service, database and broker running. They are slow, flaky, and a failure rarely points to the guilty service.
- Each team owns one service (see Day 4). The team needs a test level it can run alone, on a laptop and in its own pipeline, in minutes, with no dependency on other teams’ environments.
- A component test is the smallest test that still exercises the service as a deployable unit.
What problem it solves
Problem: End-to-end suites are slow, flaky and expensive.
Without component tests, teams typically end up with one of two bad extremes:
- Only unit tests with mocked repositories. Everything is green, then production fails because a column name mismatched, a JSON property was renamed, or a required DI registration was missing.
- A shared “integration environment” where all services must be deployed and healthy. One team’s broken build blocks everyone; tests fail at random because another team changed shared data; a full run takes an hour.
A component test isolates one service, makes its dependencies deterministic, and runs in seconds to a couple of minutes.
When it is needed (and when it is NOT)
Use it when:
- The service has real behaviour at its boundary: HTTP endpoints, message consumers, database persistence, calls to other services.
- You want to verify failure behaviour (Policy Service returns 503, times out, sends malformed JSON) that is almost impossible to reproduce in a shared environment.
- You are refactoring internals and want a safety net that does not break when classes are renamed.
Do NOT use it (or do not rely on it alone) when:
- The logic is a pure calculation (premium, deductible): a unit test is faster and clearer.
- You need to prove that two separately deployed services agree on a contract: use consumer-driven contract tests (Day 51).
- You need to validate real infrastructure behaviour (Azure networking, Front Door rules, managed identity permissions): use a small number of smoke tests in a real environment.
- The “service” is a thin CRUD wrapper with no logic: a couple of component tests are enough; do not build a big harness.
How to identify the problem (key signals)
- The full regression suite takes more than 20–30 minutes and people stop running it locally.
- Tests fail intermittently and “re-run the pipeline” is the standard fix.
- A pipeline failure cannot be attributed to a team without a meeting.
- Bugs regularly escape that a unit test with real serialisation or a real database would have caught (wrong JSON casing, missing migration, unique-index violation, wrong HTTP status).
- Unit tests use
Mock<IClaimRepository>everywhere, so refactors break dozens of tests while behaviour is unchanged. - Teams wait for a shared test environment slot before they can merge.
- Nobody can test “what if the Policy Service is down” without editing code.
Flow Diagram
The real service runs in-process; everything outside its boundary is controlled.
flowchart LR T["xUnit test"] -- "HTTP" --> HOST["WebApplicationFactory - real Claims API"] HOST --> DB[("Testcontainers PostgreSQL")] HOST -- "HTTP" --> WM["WireMock - Policy Service stub"] HOST --> BUS["In-memory event collector"] T -- "assert status, rows, events" --> HOST WM -. "scripted 200 / 503 / delay" .-> HOSTLevel 1: Beginner
Analogy: Testing a car engine on a test bench. You do not need the whole car, road and driver. You connect fuel, electrics and sensors (stand-ins), start the real engine, and measure what comes out.
The three test levels:
| Level | What is real | What is fake | Speed |
|---|---|---|---|
| Unit | One class | Everything else | milliseconds |
| Component | One whole service + its own DB | Other services, external systems | seconds |
| End-to-end | All services | Nothing | minutes+ |
Minimal example (.NET 10 LTS, xUnit v3 or v2, Microsoft.AspNetCore.Mvc.Testing). This starts the real API in memory and calls it over HTTP:
using System.Net;using System.Net.Http.Json;using Microsoft.AspNetCore.Mvc.Testing;using Xunit;
public class HealthSmokeTests : IClassFixture<WebApplicationFactory<Program>>{ private readonly HttpClient _client;
public HealthSmokeTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();
[Fact] public async Task Get_unknown_claim_returns_404() { var response = await _client.GetAsync($"/claims/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); }}Program must be visible to the test project. With top-level statements add public partial class Program { } at the end of Program.cs.
Level 2: Intermediate
A realistic component test needs (a) a real database, (b) a stubbed Policy Service, (c) a way to swap configuration. In .NET the standard toolkit is WebApplicationFactory<T>, Testcontainers for .NET (Testcontainers.PostgreSql or Testcontainers.MsSql) and WireMock.Net (or HttpMessageHandler fakes).
using Microsoft.AspNetCore.Hosting;using Microsoft.AspNetCore.Mvc.Testing;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;using Testcontainers.PostgreSql;using WireMock.Server;using Xunit;
public class ClaimsApiFactory : WebApplicationFactory<Program>, IAsyncLifetime{ private readonly PostgreSqlContainer _db = new PostgreSqlBuilder() .WithImage("postgres:16-alpine").Build();
public WireMockServer PolicyService { get; private set; } = default!;
public async Task InitializeAsync() { await _db.StartAsync(); PolicyService = WireMockServer.Start(); }
protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureAppConfiguration((_, cfg) => cfg.AddInMemoryCollection(new Dictionary<string, string?> { ["ConnectionStrings:Claims"] = _db.GetConnectionString(), ["Services:Policy:BaseUrl"] = PolicyService.Url }));
builder.ConfigureServices(services => { // Replace the real bus with an in-memory recorder services.AddSingleton<InMemoryEventCollector>(); services.AddSingleton<IEventPublisher>(sp => sp.GetRequiredService<InMemoryEventCollector>());
// Apply schema to the throw-away database using var scope = services.BuildServiceProvider().CreateScope(); scope.ServiceProvider.GetRequiredService<ClaimsDbContext>().Database.Migrate(); }); }
public new async Task DisposeAsync() { PolicyService.Stop(); await _db.DisposeAsync(); }}Note: this snippet uses the xUnit v2 signature (
Task). In xUnit v3,IAsyncLifetimemembers returnValueTask, so adjust the signatures andDisposeAsyncoverride accordingly.
The test itself, using WireMock to script the Policy Service:
using System.Net;using System.Net.Http.Json;using WireMock.RequestBuilders;using WireMock.ResponseBuilders;using Xunit;
public class SubmitClaimTests : IClassFixture<ClaimsApiFactory>{ private readonly ClaimsApiFactory _factory; public SubmitClaimTests(ClaimsApiFactory factory) => _factory = factory;
[Fact] public async Task Submit_claim_for_active_policy_persists_and_publishes_event() { _factory.PolicyService .Given(Request.Create().WithPath("/policies/POL-1001").UsingGet()) .RespondWith(Response.Create().WithStatusCode(200) .WithBodyAsJson(new { policyNumber = "POL-1001", status = "Active" }));
var client = _factory.CreateClient(); var response = await client.PostAsJsonAsync("/claims", new { policyNumber = "POL-1001", amount = 2500m, description = "Hail damage" });
Assert.Equal(HttpStatusCode.Created, response.StatusCode); var body = await response.Content.ReadFromJsonAsync<ClaimDto>(); Assert.NotEqual(Guid.Empty, body!.Id);
var events = _factory.Services.GetRequiredService<InMemoryEventCollector>(); Assert.Contains(events.Published, e => e is ClaimSubmitted s && s.ClaimId == body.Id); }
[Fact] public async Task Submit_claim_when_policy_service_is_down_returns_503() { _factory.PolicyService .Given(Request.Create().WithPath("/policies/*").UsingGet()) .RespondWith(Response.Create().WithStatusCode(503));
var response = await _factory.CreateClient().PostAsJsonAsync("/claims", new { policyNumber = "POL-1002", amount = 100m, description = "x" });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); }}On the Angular side the same idea applies: test the Angular app’s service layer against a stubbed HTTP backend (provideHttpClientTesting() with HttpTestingController), and keep the .NET component tests responsible for the real API behaviour.
Level 3: Advanced
Performance
- Start containers once per test class or per assembly (xUnit collection fixture), not per test. Container start is the main cost (a few seconds).
- Isolate tests by data, not by restarting: unique policy numbers per test, or reset with Respawn (
Respawnlibrary) between tests. - Run test classes that need separate databases in parallel; run tests sharing one database in the same collection.
Determinism
- Never
Thread.Sleep. For async consumers poll with a timeout (Eventually(() => ..., TimeSpan.FromSeconds(5))). - Freeze time by injecting
TimeProvider(built in since .NET 8) and usingFakeTimeProviderfromMicrosoft.Extensions.TimeProvider.Testing. - Do not pin to
latestcontainer tags; pinpostgres:16-alpineor the SQL Server image version you run in production.
Security
- Do not point component tests at real Azure resources or real secrets. Replace token validation with a test authentication handler that issues fixed claims, but keep authorisation policies real so 401/403 behaviour is tested.
Failure modes to cover deliberately
- Partner returns 4xx, 5xx, slow response (WireMock
.WithDelay), malformed body. - Duplicate message delivery (idempotent consumer, Day 17).
- Database unique-constraint violation and concurrency conflict.
- Retry/circuit-breaker behaviour (Days 26, 28) observed through the stubbed dependency’s call count.
Common mistakes
- Using the EF Core InMemory provider: it does not enforce relational constraints, transactions or SQL translation. Use a real engine in a container. SQLite in-memory is a compromise but has different SQL behaviour from SQL Server/PostgreSQL.
- Asserting on internals (private methods, repository calls) instead of externally visible results.
- Sharing mutable state between tests, causing order-dependent failures.
- Stubbing the partner with the wrong shape because nobody verified it. Fix this with contract tests (Day 51).
- Turning component tests into a second copy of every unit test. Test scenarios and boundaries, not every branch.
Level 4: Expert and Architect view
Trade-offs across test levels
| Approach | Confidence | Speed | Flakiness | Isolation of failures | Cost to maintain |
|---|---|---|---|---|---|
| Unit tests with mocks | Low for integration bugs | Very fast | Very low | Excellent | Low, but brittle on refactor |
| Service component test | High for one service | Fast (seconds–minutes) | Low | Excellent (one service) | Medium |
| Consumer-driven contract test | High for inter-service agreement | Fast | Low | Excellent | Medium |
| Full end-to-end test | Highest breadth | Slow | High | Poor | High |
| Testing in production (synthetic monitoring, canary) | Real | Continuous | Medium | Medium | Medium |
In-process vs out-of-process component tests
- In-process (
WebApplicationFactory): fastest, debuggable, can swap DI services. Does not test the Docker image, startup config or real HTTP stack. - Out-of-process (run the built container image with Testcontainers, call over real HTTP): tests the real artefact and container config; slower, harder to substitute internals. Many teams use in-process for most tests and a handful out-of-process as a packaging smoke test.
Combines with: Consumer-Driven Contract Test (Day 51) for the seams, Database per Service (Day 5) which makes a private database easy to spin up, Transactional Outbox (Day 12) to assert events are stored atomically, Idempotent Consumer (Day 17), Circuit Breaker/Retry (Days 26, 28), Health Check API (Day 36).
ADR (for architecture review)
- Title: ADR-050 Adopt service component tests as the primary automated test level for each microservice.
- Context: The Claims platform has many services owned by separate teams. Shared-environment end-to-end tests take about an hour and fail intermittently, blocking releases.
- Decision: Every service ships a component test suite that runs the service in-process with
WebApplicationFactory, a Testcontainers database matching production (PostgreSQL or SQL Server), and WireMock.Net stubs for downstream HTTP services. Inter-service agreement is verified separately by consumer-driven contract tests. End-to-end tests are limited to a small set of critical journeys. - Consequences: (+) Fast, deterministic, team-owned pipelines; failures point to one service. (+) Failure scenarios become testable. (-) Requires Docker on developer machines and CI agents. (-) Stubs can drift from reality unless backed by contract tests. (-) Some infrastructure behaviour is only covered by the small end-to-end/smoke set.
- Alternatives rejected: Mock-only unit testing (misses integration bugs); shared integration environment (slow, flaky, coupled teams).
Azure implementation
Component tests run before anything reaches Azure, but Azure services host and support the pipeline.
Services
- Azure DevOps Pipelines or GitHub Actions: run the tests on every pull request. Microsoft-hosted Ubuntu agents / GitHub-hosted Ubuntu runners include Docker, so Testcontainers works without extra setup.
- Azure Container Registry (ACR): hosts the service image for out-of-process component tests and later deployment.
- Azure Load Testing / Azure Monitor / Application Insights: after deployment, run a few smoke tests and watch telemetry; not a replacement for component tests.
- Emulators for Azure dependencies: Azurite (Blob/Queue/Table storage), the Azure Service Bus emulator (Docker-based, available as a container image), the Azure Cosmos DB emulator (Linux container), and Azure SQL Edge / SQL Server container images. Confirm current emulator feature limits in Microsoft Learn before depending on a specific feature, because emulators do not support every service capability.
Configuration (GitHub Actions sketch)
name: claims-service-cion: [pull_request]jobs: component-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - run: dotnet test tests/Claims.ComponentTests --configuration Release --logger trxPricing and tiers: GitHub-hosted runners are free for public repositories and consume included minutes on private repositories; Azure Pipelines Microsoft-hosted parallel jobs have a free tier with monthly limits and are billed per additional parallel job. Both tiers and limits change, so verify the current numbers on the official pricing pages before budgeting. Emulators and Testcontainers themselves cost nothing beyond agent time. Do not run component tests against paid Azure resources (Service Bus Premium, Azure SQL) unless you must.
Reference architecture (text): Developer pushes a PR, and the pipeline restores and builds the Claims Service. Unit tests run first. Component tests then start on the same agent: Docker launches PostgreSQL and WireMock; the test host starts the real Claims API in-process; tests call it over HTTP. Results publish as TRX/JUnit and code coverage to the pipeline. On success the pipeline builds the container image, pushes it to ACR, and deploys to a staging Azure Container Apps or AKS environment where a short smoke suite and the consumer-driven contract check run. Application Insights and Log Analytics monitor staging and production.
Teaching guide for my team
2-minute beginner explanation: “A unit test checks one part. A component test checks one whole service like a black box. We start the real service, give it a real temporary database, pretend the other services with fake ones we control, send it a request and check the answer. It is fast because we never start the other services.”
5-minute intermediate explanation: Explain the three test levels table, then show WebApplicationFactory plus Testcontainers plus WireMock. Emphasise: real things inside the service boundary (controllers, EF Core, serialisation, migrations), fakes outside it (other services, third-party APIs, sometimes the broker). Explain why EF Core InMemory is not good enough, and that stubs must be validated with contract tests.
Hands-on exercise: Take the Claims Service. Write three component tests: (1) valid claim returns 201 and a row exists in the database; (2) claim for an inactive policy returns 422 and nothing is saved; (3) Policy Service returns 503 and the API returns 503 without saving. Expected outcome: all three pass in under a minute locally with only Docker running; stopping the WireMock server during test 3 is not needed, because the stub scripts the failure.
Interview-style questions
- What is the difference between an integration test and a component test? A component test targets one service through its public interface with its external dependencies replaced; an integration test may check the real connection to a real dependency (for example the actual database or actual partner).
- Why not use the EF Core InMemory provider? It ignores relational behaviour (constraints, transactions, real SQL translation), so tests pass while production fails.
- How do you stop your stubs from lying? Back them with consumer-driven contract tests so the provider’s pipeline verifies the same expectations the stub encodes.
Mastery checklist
- Can draw the test pyramid for microservices and place component tests correctly.
- Can set up
WebApplicationFactorywith a Testcontainers database and overridden configuration. - Can stub an HTTP dependency, including error, delay and malformed-response cases.
- Can test an asynchronous consumer/publisher without
Thread.Sleep. - Can isolate tests (unique data or Respawn) so they run in parallel reliably.
- Can explain why InMemory/mock-only testing misses bugs and when SQLite is an acceptable compromise.
- Can decide what belongs in unit, component, contract and end-to-end tests for a given feature.
- Can run the suite in CI with Docker and keep it under a few minutes.
Key takeaway
Test each microservice as a black box with its real internals and its own real database, and stub everything beyond its boundary; then use contract tests to make sure the stubs tell the truth.
