Unit testing with xUnit: real-world lessons and examples
Most teams write tests that pass once and become maintenance debt. You run them every morning, they take forever, and when you refactor the code, half of them break for the wrong reasons. Unit testing with xUnit: real-world lessons and examples isn't just about coverage—it's about building a test suite that stays fast, readable and worth keeping for years.
We've shipped production systems where tests caught real bugs before deployment and others where tests were obstacles to shipping. The difference came down to discipline: which patterns we chose, which we banned, and why we tested one thing at a time.
Arrange, act, assert — one behavior per test
Every test should verify exactly one outcome. The name should tell you what condition you're testing and when that condition matters. This is the foundation.
[Fact]
public void Total_includes_tax()
{
// Arrange
var order = new Order(
items: new[] { new LineItem(100m) },
taxRate: 0.1m
);
// Act
var total = order.Total;
// Assert
Assert.Equal(110m, total);
}Three sections. No branching. One assertion.
In a real e-commerce system, we tested order totals in dozens of scenarios: free shipping, discount codes, tax holidays, B2B exemptions. Each test was 5 lines of setup and one assertion. When a test failed, the name told you exactly what broke.
Compare that to a test that verifies multiple things:
[Fact]
public void OrderCalculation_Works()
{
var order = new Order(items, taxRate: 0.1m);
Assert.Equal(110m, order.Total);
Assert.Equal(100m, order.Subtotal);
Assert.Equal(10m, order.TaxAmount);
}When this test fails, you don't know which assertion broke. You've optimized for writing fewer tests and paid for it with debugging time. Split it up. One test per assertion. Your teammates will thank you when they're reading failures at 2am.
Theory for variations — InlineData covers edge cases without copy-paste tests
You need to test the same logic with different inputs. Most teams copy the whole test and change one number. That's how you end up with 50 almost-identical tests that drift.
xUnit's [Theory] and [InlineData] solve this:
[Theory]
[InlineData(100m, 0.1m, 110m)]
[InlineData(50m, 0.1m, 55m)]
[InlineData(0m, 0.1m, 0m)]
[InlineData(100m, 0m, 100m)]
[InlineData(1000m, 0.2m, 1200m)]
public void Total_calculated_for_various_amounts_and_rates(
decimal subtotal, decimal taxRate, decimal expected)
{
var order = new Order(
items: new[] { new LineItem(subtotal) },
taxRate: taxRate
);
Assert.Equal(expected, order.Total);
}One test method. Five scenarios. When one fails, xUnit tells you which [InlineData] row broke. You fix the edge case once, not five times.
We used this pattern for payment processing. Different card types, currencies, amounts, and fraud flags. A single [Theory] with 20 [InlineData] rows caught more bugs than 20 separate [Fact] methods would have, and it was easier to read.
For more complex input combinations, use [MemberData] to pull test data from a method:
[Theory]
[MemberData(nameof(OrderTestCases))]
public void Total_calculated_from_member_data(
decimal subtotal, decimal taxRate, decimal expected)
{
var order = new Order(
items: new[] { new LineItem(subtotal) },
taxRate: taxRate
);
Assert.Equal(expected, order.Total);
}
public static IEnumerable<object[]> OrderTestCases => new List<object[]>
{
new object[] { 100m, 0.1m, 110m },
new object[] { 50m, 0.1m, 55m },
// ... more cases
};Test behavior, not implementation
Tests that verify private methods, internal calls, or specific implementation details are traps. They break the moment you refactor, even if the behavior is correct.
In a ride-hailing system, we had code that calculated driver ratings:
// Bad: tests the private method
[Fact]
public void CalculateAverageRating_returns_correct_value()
{
var driver = new Driver();
// This won't compile because the method is private
var avg = driver.CalculateAverageRating();
Assert.Equal(4.5m, avg);
}
// Good: tests the observable behavior
[Fact]
public void Driver_profile_shows_correct_average_rating()
{
var driver = new Driver(ratings: new[] { 5, 4, 4 });
var profile = driver.GetProfile();
Assert.Equal(4.33m, profile.AverageRating, precision: 2);
}The second test doesn't care how you calculate the average. It cares about the outcome. If you later optimize by caching the rating or computing it differently, the test still passes as long as the behavior is correct.
This is why mocking can hurt. When you mock every dependency, you're testing your mocking code, not your actual system. Mock external systems (databases, APIs, file systems). Test the logic with real objects.
Keep the domain pure — rich domain models are the cheapest code to test
The code that's hardest to test is usually entangled with infrastructure. The easiest to test is pure business logic with no dependencies.
A CRM system we maintained had discount calculation logic scattered across service classes that touched the database, cache, and a logging framework. Testing a discount took 30 seconds and required setting up fixtures. We refactored it:
// Domain model — no dependencies, fast to test
public class DiscountPolicy
{
private readonly decimal[] _thresholds;
private readonly decimal[] _rates;
public DiscountPolicy(decimal[] thresholds, decimal[] rates)
{
_thresholds = thresholds;
_rates = rates;
}
public decimal CalculateDiscount(decimal orderValue)
{
for (int i = _thresholds.Length - 1; i >= 0; i--)
{
if (orderValue >= _thresholds[i])
return orderValue * _rates[i];
}
return 0m;
}
}
// Test it without any infrastructure
[Theory]
[InlineData(50m, 0m)] // Below first threshold
[InlineData(100m, 5m)] // 100 * 0.05
[InlineData(500m, 50m)] // 500 * 0.10
public void CalculateDiscount_applies_correct_rate(
decimal orderValue, decimal expectedDiscount)
{
var policy = new DiscountPolicy(
thresholds: new[] { 100m, 300m },
rates: new[] { 0.05m, 0.10m }
);
var discount = policy.CalculateDiscount(orderValue);
Assert.Equal(expectedDiscount, discount);
}This test runs in microseconds. It needs no database, no mocking, no setup. You can run thousands of them in the time it took to run ten integration tests before.
The rule: move business logic into plain domain objects with no infrastructure dependencies. Cover those objects with xUnit tests. Use integration tests only where they matter—at the edges where your domain meets the database or API.
Common mistakes
- Test names that don't describe the scenario. Avoid names like
Test1,OrderTests, or evenTest_Order. Name tests after the behavior being verified and the condition:Expired_orders_are_excluded_from_totals. Future maintainers (including you) will scan test names to understand what the code does. - Tests that verify implementation details instead of outcomes. If your test breaks when you refactor private methods or swap out internal calls, the test is too coupled to the code. Test what the class does, not how it does it.
- Tests with logic or loops inside them. A test should be straightforward. If you need loops or if-statements inside a test, that's a sign you should be using
[Theory]with multiple datasets, not control flow. - Massive test fixtures and setup. If your Arrange section is 20 lines and your Assert is 1 line, something is wrong. Either the class is too complex to test, or you're testing at the wrong level. Consider splitting tests into smaller units or moving complex setup into a builder.
- Testing the framework instead of your code. Don't write tests for Entity Framework's queries or ASP.NET Core's routing unless you've written custom logic on top. Test your business logic. The framework is tested by Microsoft.
Key takeaways
- One test, one behavior, one assertion. The test name tells you what's being verified and when.
- Use
[Theory]and[InlineData]to cover edge cases without copy-paste. - Test observable behavior, not implementation. Tests should survive refactoring.
- Push logic into pure domain models with no infrastructure. They're fast and cheap to test.
- A test that doesn't fail when the code breaks is worse than no test. Keep tests simple enough to trust.
- Run tests constantly. If they're slow, split them. Integration tests belong in a separate suite.






Comments
Loading comments…