The C# testing ecosystem has consolidated around xUnit as the test runner, FluentAssertions for readable assertions, Moq for mocking, and Bogus for test data. Each tool has a specific role: xUnit runs and discovers tests, FluentAssertions makes failures readable, Moq controls dependencies, and Bogus generates realistic data that avoids “magic strings” that make tests brittle and unreadable.
Setting Up the Test Project
// ── Create test project ────────────────────────────────────────────────────
// dotnet new xunit -n BlogApp.Tests
// dotnet add BlogApp.Tests reference BlogApp.Api
// dotnet add BlogApp.Tests package FluentAssertions
// dotnet add BlogApp.Tests package Moq
// dotnet add BlogApp.Tests package Bogus
// dotnet add BlogApp.Tests package Microsoft.AspNetCore.Mvc.Testing
// ── xUnit test lifecycle ───────────────────────────────────────────────────
public class PostsServiceTests : IDisposable
{
// Constructor runs before each test (replaces [SetUp] in NUnit)
private readonly Mock<IPostsRepository> _repo = new();
private readonly Mock<ICurrentUserService> _user = new();
private readonly PostsService _sut; // System Under Test
public PostsServiceTests()
{
_sut = new PostsService(_repo.Object, _user.Object);
}
// IDisposable.Dispose runs after each test (replaces [TearDown])
public void Dispose() { /* cleanup if needed */ }
// ── [Fact] — single test case ─────────────────────────────────────────
[Fact]
public async Task CreateAsync_WithValidRequest_ReturnsPostDto()
{
// Arrange — set up test data and mocks
var request = new CreatePostRequest("Getting Started", "getting-started",
"Body content here.", "draft");
_repo.Setup(r => r.SlugExistsAsync("getting-started", default))
.ReturnsAsync(false);
_repo.Setup(r => r.CreateAsync(It.IsAny<Post>(), default))
.ReturnsAsync(new Post { Id = 1, Slug = "getting-started" });
_user.Setup(u => u.UserId).Returns("user-123");
// Act
var result = await _sut.CreateAsync(request, default);
// Assert — FluentAssertions
result.Should().NotBeNull();
result.Slug.Should().Be("getting-started");
result.Id.Should().BePositive();
}
// ── [Theory] with [InlineData] — parameterised tests ─────────────────
[Theory]
[InlineData("", false)] // empty slug — invalid
[InlineData("valid-slug", true)] // valid slug — ok
[InlineData("has spaces", false)] // spaces — invalid
[InlineData("UPPERCASE", false)] // uppercase — invalid
public void SlugIsValid_ReturnsExpectedResult(string slug, bool expected)
{
var result = SlugValidator.IsValid(slug);
result.Should().Be(expected);
}
}
// ── Bogus — generating realistic test data ────────────────────────────────
public static class TestDataBuilder
{
private static readonly Faker<Post> PostFaker = new Faker<Post>()
.RuleFor(p => p.Id, f => f.IndexFaker + 1)
.RuleFor(p => p.Title, f => f.Lorem.Sentence(5))
.RuleFor(p => p.Slug, (f, p) => p.Title.ToLowerInvariant()
.Replace(" ", "-"))
.RuleFor(p => p.Body, f => f.Lorem.Paragraphs(3))
.RuleFor(p => p.Status, f => "draft")
.RuleFor(p => p.ViewCount, f => f.Random.Int(0, 10000))
.RuleFor(p => p.PublishedAt, f => f.Date.Past());
public static Post Post() => PostFaker.Generate();
public static Post[] Posts(int n) => PostFaker.Generate(n).ToArray();
}
[SetUp] attribute (which runs before each test on the same instance), xUnit’s constructor runs fresh. This means shared state between tests is impossible by accident — each test starts with a clean mock setup. Use IClassFixture<T> for expensive shared resources (like a test database) that should be created once per test class, not per test method.MethodName_Scenario_ExpectedBehaviour: CreateAsync_WithDuplicateSlug_ThrowsConflictException. This makes the test report self-documenting — a failing test immediately tells you what failed, under what condition, and what was expected. Avoid test names like Test1, CreateTest, or WhenPostCreated — they require reading the test body to understand the intent.new Faker().Random.Int() directly. However, for unit tests, deterministic data is usually preferable — a failing test should be reproducible. Reserve true randomness for property-based testing (FsCheck, AutoFixture) where the framework tracks seeds for you.Common Mistakes
Mistake 1 — Hardcoded magic strings in tests (brittle, unreadable)
❌ Wrong — var post = new Post { Title = "test", Slug = "test-slug", Body = "test body" }; unclear intent; breaks when validation changes minimum length.
✅ Correct — var post = TestDataBuilder.Post(); realistic data; validation passes naturally.
Mistake 2 — Asserting too many things in one test (hard to diagnose failures)
❌ Wrong — one [Fact] that asserts 10 different properties; one failure masks the others.
✅ Correct — one assertion per [Fact] for the primary behaviour; use [Theory] for multiple input variations of the same assertion.