A good unit test is fast, isolated, repeatable, and self-validating, and it verifies one logical behavior of code you own. The practices that get you there: structure tests as Arrange, Act, Assert, name them after the scenario and expected outcome, use builders for test data, mock only external boundaries, and test behavior instead of implementation details.
A test suite is either a safety net or an anchor. The difference rarely shows up on day one. It shows up six months in, when a simple rename breaks 40 tests that were all asserting implementation details, and the team starts treating red builds as noise. These practices keep .NET unit tests fast, trustworthy, and cheap to change.
What Makes a Good Unit Test?
A good unit test has four properties:
- Fast - runs in milliseconds, not seconds
- Isolated - doesn't depend on databases, file systems, or external services
- Repeatable - produces the same result every time
- Self-validating - passes or fails without manual inspection
If your tests take minutes to run, developers stop running them. If they fail randomly, developers stop trusting them. If they're hard to maintain, developers stop writing them.
The AAA Pattern
Structure every test with Arrange, Act, Assert:
[Fact]
public void Order_AddLineItem_IncreasesTotalAmount()
{
// Arrange
var order = Order.Create(Guid.NewGuid());
var price = Money.Create(25.00m, "USD");
// Act
order.AddLineItem(Guid.NewGuid(), price, quantity: 2);
// Assert
order.TotalAmount.Amount.Should().Be(50.00m);
}
Clear separation between setup, execution, and verification. One glance tells you what's being tested.
Name Tests Clearly
Test names should describe the scenario and expected outcome:
// Bad - what does this tell you?
[Fact]
public void Test1() { }
// Bad - too vague
[Fact]
public void OrderTest() { }
// Good - describes the behavior
[Fact]
public void Cancel_WhenOrderIsShipped_ReturnsFailure() { }
[Fact]
public void AddLineItem_WithValidProduct_IncreasesTotal() { }
[Fact]
public void Create_WithEmptyEmail_ReturnsValidationError() { }
Use the pattern: Method_Scenario_ExpectedResult or Given_When_Then. Pick one convention and use it consistently across your project.
Test One Behavior Per Test
Each test should verify one logical behavior. Not one assertion - one behavior.
// Bad - testing multiple behaviors
[Fact]
public void PlaceOrder_WorksCorrectly()
{
var order = Order.Create(customerId);
order.AddLineItem(productId, price, 2);
order.Complete();
order.LineItems.Should().HaveCount(1);
order.TotalAmount.Should().Be(Money.Create(50, "USD"));
order.Status.Should().Be(OrderStatus.Completed);
order.DomainEvents.Should().ContainSingle(e => e is OrderCompletedDomainEvent);
}
// Good - one behavior per test
[Fact]
public void AddLineItem_IncreasesLineItemCount()
{
var order = Order.Create(customerId);
order.AddLineItem(productId, price, 2);
order.LineItems.Should().HaveCount(1);
}
[Fact]
public void Complete_RaisesOrderCompletedEvent()
{
var order = CreateOrderWithItems();
order.Complete();
order.DomainEvents.Should().ContainSingle(e => e is OrderCompletedDomainEvent);
}
When a test fails, you immediately know which behavior broke.
Use Domain-Specific Assertions
Assertion libraries like FluentAssertions (or the free Shouldly and AwesomeAssertions alternatives, worth knowing since FluentAssertions v8 moved to a paid license for commercial use) make tests more readable. The FluentAssertions alternatives comparison covers the licensing and API trade-offs in detail:
// Without FluentAssertions
Assert.Equal(OrderStatus.Completed, order.Status);
Assert.True(result.IsSuccess);
Assert.NotNull(customer);
// With FluentAssertions
order.Status.Should().Be(OrderStatus.Completed);
result.IsSuccess.Should().BeTrue();
customer.Should().NotBeNull();
For domain-specific assertions, create extension methods:
public static class ResultAssertionExtensions
{
public static void ShouldBeSuccess<T>(this Result<T> result)
{
result.IsSuccess.Should().BeTrue(
$"Expected success but got failure: {result.Error}");
}
public static void ShouldBeFailure<T>(
this Result<T> result, Error expectedError)
{
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(expectedError);
}
}
[Fact]
public void Cancel_WhenDraft_ReturnsSuccess()
{
var order = CreateDraftOrder();
var result = order.Cancel();
result.ShouldBeSuccess();
}
Use Test Data Builders
Constructing test objects inline makes tests brittle and noisy:
// Brittle - if Customer constructor changes, every test breaks
[Fact]
public void Test()
{
var customer = new Customer(
Guid.NewGuid(),
"John Doe",
Email.Create("[email protected]").Value,
Address.Create("123 Main St", "NYC", "NY", "10001", "US").Value,
false);
}
Use a builder:
public class CustomerBuilder
{
private Guid _id = Guid.NewGuid();
private string _name = "John Doe";
private Email _email = Email.Create("[email protected]").Value;
private bool _isVip = false;
public CustomerBuilder WithName(string name) { _name = name; return this; }
public CustomerBuilder WithEmail(string email) { _email = Email.Create(email).Value; return this; }
public CustomerBuilder AsVip() { _isVip = true; return this; }
public Customer Build() => Customer.Create(_id, _name, _email, _isVip);
}
[Fact]
public void VipCustomer_GetsDiscount()
{
var customer = new CustomerBuilder().AsVip().Build();
var discount = _discountService.CalculateDiscount(customer);
discount.Should().Be(10);
}
Builders isolate your tests from constructor changes and make the test's intent clear. I go deeper on this pattern (composition, factory methods, combining with Bogus) in test data builders in C#.
Don't Test Implementation Details
Test behavior, not implementation. Tests that verify internal state or call order are brittle.
// Bad - testing implementation details
[Fact]
public void PlaceOrder_CallsRepositoryAdd()
{
_mockRepository.Verify(r => r.Add(It.IsAny<Order>()), Times.Once);
}
// Good - testing observable behavior
[Fact]
public async Task PlaceOrder_ReturnsOrderId()
{
var result = await _handler.Handle(new PlaceOrderCommand(...), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeEmpty();
}
The first test breaks if you rename the repository method. The second test passes as long as the behavior is correct, regardless of how it's implemented. The focused guide to testing CQRS handlers applies this rule to success paths, validation failures, and persistence boundaries.
When to Use Mocks (and When Not To)
Mock external dependencies - things at the system boundary:
- Database repositories
- HTTP clients
- Email services
- Message brokers
- Time providers
Don't mock domain objects - test them directly:
// Don't mock the Order - test it directly
[Fact]
public void Order_Cancel_WhenShipped_Fails()
{
var order = CreateShippedOrder();
var result = order.Cancel();
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(OrderErrors.AlreadyShipped);
}
If you find yourself mocking too many things, your class has too many dependencies. Consider refactoring.
And know your vocabulary: mocks, stubs, fakes, and spies are different tools with different failure modes. I break down when to use each test double separately.
Parameterized Tests
Use [Theory] with data sources to test multiple scenarios without duplicating code. I covered data-driven tests with xUnit in detail, but here's the short version:
[Theory]
[InlineData("", false)]
[InlineData("not-an-email", false)]
[InlineData("[email protected]", true)]
[InlineData("[email protected]", true)]
public void Email_Create_ValidatesFormat(string input, bool shouldSucceed)
{
var result = Email.Create(input);
result.IsSuccess.Should().Be(shouldSucceed);
}
For complex test data, use [MemberData]:
public static IEnumerable<object[]> InvalidOrderData =>
new List<object[]>
{
new object[] { Guid.Empty, "Customer ID is required" },
new object[] { Guid.NewGuid(), null }, // null items
};
[Theory]
[MemberData(nameof(InvalidOrderData))]
public void PlaceOrder_WithInvalidData_Fails(Guid customerId, string errorMessage)
{
// ...
}
Test Domain Logic, Not Frameworks
Focus your unit tests on code you own - domain entities, value objects, and domain services.
Don't unit test:
- EF Core configurations (use integration tests instead)
- ASP.NET Core middleware
- Third-party library behavior
- DTOs and simple mappings
// Worth unit testing - domain invariant
[Fact]
public void Order_AddLineItem_WhenNotDraft_ThrowsDomainException()
{
var order = CreateCompletedOrder();
var act = () => order.AddLineItem(productId, price, 1);
act.Should().Throw<DomainException>()
.WithMessage("Cannot modify a non-draft order.");
}
// NOT worth unit testing - it's just EF Core
[Fact]
public void DbContext_CanSaveOrder()
{
// This tests EF Core, not your code. Use integration tests.
}
Organize Tests to Mirror Source
tests/
Domain.UnitTests/
Orders/
OrderTests.cs
OrderLineItemTests.cs
Customers/
CustomerTests.cs
EmailTests.cs
Application.UnitTests/
Orders/
PlaceOrderCommandHandlerTests.cs
CancelOrderCommandHandlerTests.cs
Mirror the source project structure so tests are easy to find.
Key Takeaways
Good unit tests protect your domain logic and give you confidence to refactor. Bad unit tests slow you down and break every time you change anything.
Follow these practices:
- Use AAA structure consistently
- Name tests descriptively
- Test behaviors, not implementation details
- Use builders for test data
- Mock only external boundaries
- Prefer parameterized tests for multiple scenarios
Your unit tests should be assets, not liabilities. And keep them in proportion: unit tests protect your domain logic, but they can't tell you the system works end to end, which is why I don't chase the classic test pyramid shape.
Frequently Asked Questions
What is the AAA pattern in unit testing?
AAA stands for Arrange, Act, Assert: set up the objects and data, execute the behavior under test, then verify the outcome. Structuring every test this way makes it obvious what is being tested and why a failure matters.
How should I name unit tests in .NET?
Use a convention that states the method, the scenario, and the expected result, such as Cancel_WhenOrderIsShipped_ReturnsFailure. The exact convention matters less than applying it consistently.
What should you not unit test?
Skip framework behavior (EF Core, ASP.NET Core internals), third-party libraries, trivial DTOs, and simple mappings. Unit tests should focus on domain logic and code you own; infrastructure belongs in integration tests.
How many assertions should a unit test have?
As many as it takes to verify one logical behavior. The rule is one behavior per test, not one assertion per test. Asserting three properties of the same outcome is fine; verifying two unrelated behaviors is not.



