Unit Testing in .NET with xUnit and Moq

I inherited a codebase once where the previous team was proud of their test coverage number, ninety-something percent. I spent a week making a change that should have been routine, and the tests didn't catch a single one of the three genuine bugs I introduced along the way. I found all three manually, in staging. That coverage number had measured almost nothing about whether the code actually worked.
Coverage measures execution, not correctness
A test that calls a method and asserts nothing meaningful, or asserts something trivially true, inflates coverage without verifying behavior. Coverage tells you code ran during a test. It tells you nothing about whether the test would have caught a real bug.
Arrange, Act, Assert, and why the shape matters
csharp public class LoanApprovalTests { [Fact] public void Approve_WhenPending_SetsStatusToApproved() { // Arrange var application = new LoanApplication(amount: 5000m); // Act application.Approve(approverId: Guid.NewGuid()); // Assert Assert.Equal(LoanStatus.Approved, application.Status); } }
Keeping the three phases visually distinct pays for itself the first time someone other than you has to debug a failing test six months later.
Mocking with Moq: isolate the thing you're actually testing
csharp [Fact] public async Task ProcessPayment_WhenGatewayFails_ReturnsFailureResult() { var mockGateway = new Mock(); mockGateway.Setup(g => g.ChargeAsync(It.IsAny())) .ThrowsAsync(new PaymentGatewayException());
var service = new PaymentService(mockGateway.Object);
var result = await service.ProcessAsync(100m);
Assert.False(result.IsSuccess);
}
Mocking the gateway verifies your service's error handling specifically, without a real network call. The failure mode I've seen teams fall into is over-mocking, replacing so much of the real code path that the test ends up verifying the mock's configuration instead of your code's actual behavior.
Testing behavior, not implementation details
Tests coupled to internal implementation details break every time you refactor, even when behavior hasn't changed, which trains people to see failing tests as noise. Test through the public contract, what the class promises callers, not its private internals.
The tests that actually catch bugs are the edge cases
The happy path is usually the easiest part to get right. The genuinely valuable tests check a null input, an empty collection, a boundary value, a concurrent call. The bug that would have shipped on the loan portal wasn't in the standard approval flow, it was in approving an application that had already been rejected.
What I actually optimize for
Not a coverage percentage. Whether the test suite gives genuine confidence to change code without manually re-verifying every affected path. A smaller number of tests that check real behavior gives more of that confidence than a large number that mostly verify the code ran.


