Testing in C#
A test is not a ritual or a line in a report — it is an executable specification: the only code that asserts the rest of your code does what you think it does. In .NET that specification rests on very concrete machinery — the runner discovers tests by attribute and decides whether to reuse the class instance; the DI container lets you swap a dependency for a double; the EF Core provider decides whether your LINQ ever reaches real SQL. Until you understand that machinery, you write tests that are green and guarantee nothing.
That is exactly why the topic degenerates so easily — and the degeneration is what interviews probe. An over-mocked suite asserts on interactions — which calls happened, in what order — so it pins the implementation: it survives broken logic and breaks on a harmless refactor. The EF Core in-memory provider is LINQ-to-objects: it silently accepts a query the real PostgreSQL would reject. 100% coverage only means the lines executed: delete every assertion from the suite and the number does not move. And DateTime.Now read straight from the code makes the test a hostage of the calendar. Each layer below takes one mechanism apart and names the trap it breaks on.
Topic map
- Test levels — unit, integration and end-to-end differ in scope, cost, and what they actually prove.
- Test frameworks —
xUnit,NUnit,MSTest, and whyxUnitre-creates the test class per test. - Arrange-Act-Assert — the three blocks of a test body, and the single-Act rule that makes a failure point at exactly one behaviour.
- Parameterized tests —
[Theory]with[InlineData]/[MemberData]: one scenario per data row, each row a separate reported test. - Test data setup —
Test Data BuilderandObject Motherkeep Arrange short when a valid object needs many fields. - Mocks and fakes — mock roles and boundaries you do not own, not types; an over-mocked suite asserts on calls, not behaviour.
- Testable time —
TimeProviderandFakeTimeProviderinstead of a global frozen clock that leaks across parallel tests. - Integration testing —
WebApplicationFactory<T>boots the realProgrampipeline in-process; a real database comes from SQLite orTestcontainers. - Snapshot testing — serialize the result and diff it against an approved file; right for large, stable output.
- Code coverage — line versus branch, why 100% proves nothing, and what mutation testing measures instead.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
| Calling a test that hits a real database a unit test | Integration-test cost and fragility with unit-test promises — a slow, flaky suite |
Looking for [SetUp]/[TearDown] in xUnit | They do not exist: the constructor is setup, IDisposable is teardown, and the class is re-created per test |
| Relying on execution order or on state left by an earlier test | The runner guarantees no order and runs classes in parallel — green alone, red in the suite |
| Cramming several Acts into one test | The failure stops naming one behaviour — you must read the test to learn what broke |
Putting method parameters on a [Fact] | xUnit rejects the test at discovery — it needs [Theory] |
| Mocking everything, including value objects and in-process dependencies | The test asserts on calls, not results: it survives broken logic and breaks on refactoring |
Believing Moq can intercept DateTime.Now | A static property cannot be intercepted — time must be injected as a dependency (TimeProvider) |
| Faking time with mutable statics (a globally frozen clock) | State leaks across parallel tests and kills parallel execution |
| Trusting the EF Core in-memory provider as "a database" | No unique constraints, no cascade deletes, no SQL translation — it passes queries the real database rejects |
Mocking DbContext and DbSet<T> | You assert on LINQ over a list, not on what EF Core translates your query into |
| Re-approving a failed snapshot without reading the diff | A regression is quietly blessed as the new normal |
| Snapshotting output with an embedded timestamp or GUID | The test is flaky by construction — it diffs on every run |
| Reading a covered line as a verified line | Coverage records execution, not verification: a suite with no assertions reports the same 100% |
| Setting a coverage threshold as a quality bar | Over-mocked tests clear the bar while proving nothing; surviving mutants go unnoticed |
| Hitting Retry on a flaky test | A real race or order dependency hides behind a green run and eventually reaches production |
Interview relevance
Testing comes up on almost every mid-level C# interview, but the check is not attribute syntax. The check is whether you can tell a test that proves something from a test that merely runs. A candidate who answers the coverage question with "100% means the lines executed, not that anything asserted on them" closes the topic in one sentence.
Typical checks:
- The boundary between unit, integration and end-to-end tests — and what each actually proves.
- The
xUnitmodel: a fresh class instance per test, the constructor as setup,IDisposableas teardown. [Fact]versus[Theory], and that each[InlineData]row is reported as its own test.- What to mock and what not to: a boundary you do not own is a mock; in-process state is a fake.
- How to test code reading
DateTime.Nowwithout freezing the clock globally. - What
WebApplicationFactory<T>gives you, and why the EF Core in-memory provider is not a database. - How branch coverage differs from line coverage, and what mutation testing measures.
Common wrong answer: "We have 100% coverage, so the code is tested." That opens the discussion that coverage is an execution metric, not a verification one; that a suite with zero assertions reports the same 100%; and that the question "would the test catch a defect" is answered by injecting defects (mutation testing), not by counting lines.