Software Engineering
Unit Tests, Integration Tests and End-to-End Tests in .NET
A useful test strategy does not maximise test count or worship a pyramid. It places evidence at the cheapest boundary capable of detecting the failure that would stop a safe release.

Test classifications describe boundaries, not speed
A unit test exercises a meaningful unit without crossing the infrastructure boundary relevant to its claim. An integration test proves components integrate with real framework or infrastructure behaviour. An end-to-end test exercises a deployed user journey across the system. “Fast” and “slow” are consequences, not definitions.
A test calling a controller with six mocks is not automatically a valuable unit test. A database test using an in-memory provider may be quick while failing to prove SQL translation, constraints or transaction behaviour. Name tests by the risk they cover, then choose the narrowest credible boundary.
Use a portfolio, not a geometric slogan
| Risk | Cheapest credible evidence |
|---|---|
| Calculation or state transition | Unit test of domain behaviour |
| JSON, routing or middleware | Hosted API integration test |
| EF query, constraint or transaction | Integration test against the production database engine |
| External API contract | Adapter test plus provider sandbox or contract test |
| Critical browser journey | Small end-to-end suite |
| Deployment wiring | Post-deployment smoke test |
The ideal shape depends on the product. A rules engine earns many unit tests. A thin data service gains more confidence from database and HTTP integration tests. Count protected risks, not layers in a diagram.

Unit tests should survive refactoring
Test public behaviour: given this state and command, what outcome and state change follow? Tests that assert which private collaborator was called first freeze implementation and discourage improvement. Mocks are appropriate for true ports-payment gateways, clocks, message publishers-not every neighbouring class.
Inject time, identifiers and nondeterministic policy where deterministic control matters. Avoid exposing internals solely for tests. If a rule cannot be tested without constructing half the application, the production design may be telling you that ownership is unclear.
Integration tests must use faithful substitutes
SQLite and EF in-memory behaviour differ from SQL Server or PostgreSQL in collation, translation, constraints and transactions. Use containers or an isolated real database when those details are the risk. A fake queue cannot prove lock renewal, redelivery or dead-letter behaviour.
Faithful does not require cloning production. It means preserving the semantics behind the assertion. Testcontainers can provision dependencies, but image startup and migration cost must be controlled. Reuse infrastructure safely at suite scope while isolating test data.
Test data is a concurrency problem
Shared mutable fixtures cause order-dependent failures when tests run in parallel. Give each test an isolated tenant, schema, database or uniquely keyed dataset. Clean-up after failure is unreliable; design data so abandoned rows cannot collide.
Factories and builders should create valid minimal objects, making the one important variation visible. Giant “golden” JSON files hide irrelevant changes. Seed only reference data that production also requires, and apply migrations through the same mechanism used in deployment.
Browser tests should protect journeys, not CSS trivia
End-to-end tests are valuable for authentication, routing, JavaScript, browser security and service wiring. They are expensive diagnostic tools. Keep them around critical journeys and assert user-observable outcomes rather than DOM structure that changes with harmless refactoring.
Use resilient locators based on accessible roles or explicit test identifiers. Wait for conditions, never fixed sleeps. Capture trace, screenshot, console and network evidence on failure. A flaky test is production code with an intermittent defect; quarantine with ownership and a removal date, not indefinite retries.
Contract tests sit between integration and end to end
Provider schema checks can detect removed fields, while consumer contracts record behaviours known clients need. Neither proves an open ecosystem is compatible. Run representative supported client versions against the current API and test stable error codes, unknown enum handling and defaults.
Mocks generated from an OpenAPI document prove agreement with the document, not that production implements it. Keep at least one path that compares the published description and real endpoint.
CI should optimise diagnosis, not only duration
Run deterministic unit and focused integration tests on every change. Parallelise isolated suites and reserve expensive browser, migration and performance checks for appropriate gates without letting them drift into a nightly graveyard nobody trusts.
Record duration and failure rate by test. Split genuinely slow suites, remove redundant cases and fix the causes of flakes. Retrying infrastructure startup once may handle platform noise; retrying assertions conceals nondeterminism.
A failed pipeline should identify the broken contract quickly. Thousands of tests that finish in two minutes but produce an unreadable wall of mock failures are not fast feedback.
Production defects should change the evidence
When a defect escapes, add the narrowest test that would have caught the underlying class of failure. Do not automatically add a browser replay. A timezone calculation bug belongs in boundary-value tests; a missing middleware registration belongs in a hosted integration test.
Delete tests whose protected behaviour no longer exists or whose evidence is completely dominated by a stronger cheaper test. Test code has maintenance cost and can preserve obsolete design as effectively as production code.
Review checklist
- Every test names the behaviour and risk it protects.
- Mocks represent genuine boundaries, not the internal call graph.
- Database tests use the engine semantics the assertion requires.
- Test data remains isolated under parallel execution and failure.
- Critical errors and security paths are tested, not only success.
- Browser tests use resilient selectors and collect diagnostics.
- Old clients and current contracts are tested together.
- Flaky and slow tests have measured ownership.
- Escaped defects improve the appropriate layer.
Related C3 Software guides
Frequently asked questions
Should .NET projects follow the test pyramid?
Use it as a cost warning, not a quota. Choose the test mix from the system's risks and boundaries.
Should EF Core tests use an in-memory provider?
Only when database semantics are irrelevant. Use the production engine for query translation, constraints, collation and transaction claims.
Are flaky tests acceptable?
No. They erode trust. Diagnose nondeterminism, isolate data and record failure artifacts rather than adding unlimited retries.
How many end-to-end tests are enough?
Enough to protect critical journeys and deployment wiring. Cover rules and edge cases at narrower, more diagnostic boundaries.
Build a test suite that supports release decisions
C3 Software Limited has built and supported business software in the UK since 2001. If tests are slow yet releases remain uncertain, talk to C3 Software about an engineering review.