Unit Testing in C# with xUnit — The Complete Practical Guide
Every production codebase eventually reaches a tipping point where a developer changes one function and silently breaks three others. Without automated tests, you only find out when a customer tweets at you at 2am. Unit testing is not a 'nice to have' — it is the safety harness that lets your team move fast without falling off a cliff. Companies like Microsoft, Stripe, and Shopify treat untested code as unshippable code, and for good reason.
The specific pain xUnit solves is the chaos of manual regression checking. Without it, every new feature means re-clicking through the entire app to make sure nothing broke. xUnit lets you encode that mental checklist as code, run it in under a second, and get a precise pass/fail report with zero human effort. It also integrates natively into the .NET ecosystem, works beautifully with GitHub Actions and Azure DevOps pipelines, and produces machine-readable output that pull-request bots can act on automatically.
By the end of this article you will know how to structure a real xUnit test project from scratch, write both simple Fact tests and data-driven Theory tests, isolate dependencies using the Moq library, and avoid the three mistakes that waste most beginners' first week with xUnit. You will also understand why each xUnit design decision exists, so you can make smart choices on your own projects rather than blindly copying Stack Overflow snippets.
Setting Up a Real xUnit Project — Structure That Scales
The number one mistake teams make is dumping tests into the same project as production code. That forces your shipping binary to carry test dependencies, and it blurs the line between what you own and what you are testing. The industry-standard layout is a separate .Tests project that references your production project.
Here is exactly how to scaffold this from the terminal. The key insight is that dotnet new xunit gives you a ready-to-run test runner — xUnit's runner is baked in via the xunit.runner.visualstudio package, which is what lets Visual Studio's Test Explorer and dotnet test both work without extra wiring.
Notice that your test project references your production project directly. xUnit discovers test classes by scanning for public classes with methods decorated with [Fact] or [Theory] — no base class, no interface, no ceremony. That is a deliberate philosophy: tests should read like plain C#, not like a framework DSL.
# Run these commands in your terminal to scaffold the solution # 1. Create a solution folder mkdir OrderProcessingApp && cd OrderProcessingApp # 2. Create the production class library dotnet new classlib -n OrderProcessing.Core # 3. Create the xUnit test project dotnet new xunit -n OrderProcessing.Tests # 4. Create a solution file to hold both dotnet new sln -n OrderProcessingApp # 5. Add both projects to the solution dotnet sln add OrderProcessing.Core/OrderProcessing.Core.csproj dotnet sln add OrderProcessing.Tests/OrderProcessing.Tests.csproj # 6. Reference the production project FROM the test project dotnet add OrderProcessing.Tests/OrderProcessing.Tests.csproj \ reference OrderProcessing.Core/OrderProcessing.Core.csproj # Your folder structure should look like this: # OrderProcessingApp/ # ├── OrderProcessingApp.sln # ├── OrderProcessing.Core/ <-- production code # │ ├── OrderProcessing.Core.csproj # │ └── OrderCalculator.cs # └── OrderProcessing.Tests/ <-- test code (separate project) # ├── OrderProcessing.Tests.csproj # └── OrderCalculatorTests.cs # Run all tests from the solution root dotnet test
Test run for OrderProcessing.Tests.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.x
Starting test execution, please wait...
Passed! - Failed: 0, Passed: 0, Skipped: 0, Total: 0, Duration: < 1 ms
Fact vs Theory — Writing Tests That Actually Prove Something
xUnit gives you two test primitives: [Fact] and [Theory]. Understanding the difference is the key to writing tests that are genuinely useful rather than tests that only prove one lucky path through your code.
A [Fact] is a single, unconditional assertion: 'this is always true, no arguments needed.' Use it for edge cases, boundary conditions, and single-scenario checks. A [Theory] is a parameterised test that says 'this should be true for all of these inputs.' You supply multiple data sets via [InlineData], [MemberData], or [ClassData], and xUnit runs your test method once per set, independently.
Why does this matter? Because a bug in a calculation function usually lives at an edge — zero, negative numbers, null strings, max integer. A single [Fact] with one happy-path number gives you false confidence. A [Theory] with seven representative inputs — including edge cases — is what actually catches real bugs before production does.
Below is a production-realistic example using an OrderCalculator that applies discounts. Notice the test names are descriptive English sentences — that is intentional. When a test fails in CI, the name is your first clue, so 'CalculateTotal_WhenDiscountExceedsHundredPercent_ThrowsArgumentException' tells you exactly what broke without opening the file.
// OrderProcessing.Core/OrderCalculator.cs — the class we are testing namespace OrderProcessing.Core { public class OrderCalculator { // Applies a percentage discount to the subtotal and returns the final price public decimal CalculateTotal(decimal subtotal, decimal discountPercent) { if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal), "Subtotal cannot be negative."); if (discountPercent < 0 || discountPercent > 100) throw new ArgumentOutOfRangeException(nameof(discountPercent), "Discount must be between 0 and 100."); var discountAmount = subtotal * (discountPercent / 100m); return subtotal - discountAmount; } // Returns true only if the order qualifies for free shipping public bool QualifiesForFreeShipping(decimal orderTotal, string customerTier) { return customerTier == "Gold" && orderTotal >= 50m || customerTier == "Standard" && orderTotal >= 100m; } } } // ───────────────────────────────────────────────────────── // OrderProcessing.Tests/OrderCalculatorTests.cs using Xunit; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class OrderCalculatorTests { // Create one instance of the calculator — it has no state, so it's safe to share private readonly OrderCalculator _calculator = new OrderCalculator(); // ── [Fact] tests: single, unconditional scenarios ── [Fact] public void CalculateTotal_WhenSubtotalIsNegative_ThrowsArgumentOutOfRangeException() { // Arrange: a clearly invalid subtotal decimal negativeSubtotal = -10.00m; decimal validDiscount = 10m; // Act + Assert: xUnit's Assert.Throws captures the exception cleanly var exception = Assert.Throws<ArgumentOutOfRangeException>( () => _calculator.CalculateTotal(negativeSubtotal, validDiscount) ); // Also verify the exception message mentions the right parameter name Assert.Equal("subtotal", exception.ParamName); } [Fact] public void CalculateTotal_WhenDiscountIsZero_ReturnFullSubtotal() { // Arrange decimal subtotal = 200.00m; decimal noDiscount = 0m; // Act decimal result = _calculator.CalculateTotal(subtotal, noDiscount); // Assert: no discount means we pay full price Assert.Equal(200.00m, result); } // ── [Theory] tests: same logic, many data sets ── [Theory] // Format: subtotal, discountPercent, expectedTotal [InlineData(100.00, 10, 90.00)] // standard 10% off [InlineData(200.00, 25, 150.00)] // 25% off a larger order [InlineData(50.00, 100, 0.00)] // 100% off — free item promotion [InlineData(99.99, 0, 99.99)] // zero discount — pay full price [InlineData(0.00, 50, 0.00)] // zero subtotal — result must also be zero public void CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal( decimal subtotal, decimal discountPercent, decimal expectedTotal) { // Act: run the same logic for every [InlineData] row above decimal actualTotal = _calculator.CalculateTotal(subtotal, discountPercent); // Assert.Equal on decimals — xUnit compares value, not reference Assert.Equal(expectedTotal, actualTotal); } [Theory] [InlineData(50.00, "Gold", true)] // Gold hits threshold of 50 [InlineData(49.99, "Gold", false)] // Gold just under threshold [InlineData(100.00, "Standard", true)] // Standard hits threshold of 100 [InlineData(99.99, "Standard", false)] // Standard just under threshold [InlineData(500.00, "Bronze", false)] // Unknown tier never qualifies public void QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected( decimal orderTotal, string customerTier, bool expectedResult) { bool actualResult = _calculator.QualifiesForFreeShipping(orderTotal, customerTier); Assert.Equal(expectedResult, actualResult); } } }
Passed! CalculateTotal_WhenSubtotalIsNegative_ThrowsArgumentOutOfRangeException
Passed! CalculateTotal_WhenDiscountIsZero_ReturnFullSubtotal
Passed! CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(subtotal: 100.00, discountPercent: 10, expectedTotal: 90.00)
Passed! CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(subtotal: 200.00, discountPercent: 25, expectedTotal: 150.00)
Passed! CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(subtotal: 50.00, discountPercent: 100, expectedTotal: 0.00)
Passed! CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(subtotal: 99.99, discountPercent: 0, expectedTotal: 99.99)
Passed! CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(subtotal: 0.00, discountPercent: 50, expectedTotal: 0.00)
Passed! QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(orderTotal: 50.00, customerTier: Gold, expectedResult: True)
Passed! QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(orderTotal: 49.99, customerTier: Gold, expectedResult: False)
Passed! QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(orderTotal: 100.00, customerTier: Standard, expectedResult: True)
Passed! QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(orderTotal: 99.99, customerTier: Standard, expectedResult: False)
Passed! QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(orderTotal: 500.00, customerTier: Bronze, expectedResult: False)
Failed: 0, Passed: 12, Skipped: 0, Total: 12, Duration: 38 ms
Mocking Dependencies With Moq — Testing Code in Isolation
Real services talk to databases, payment gateways, and email providers. If your unit test actually hits a database, it is not a unit test — it is a slow, flaky integration test that fails whenever the DB is unreachable. The solution is dependency injection plus mocking: you inject a fake version of the dependency that behaves exactly as you dictate, so your test owns the scenario completely.
Moq is the most widely used mocking library in the .NET ecosystem. You install it into your test project only — production code never sees it. The mental model is simple: Mock creates a stand-in actor who plays the role of IEmailService. You script its lines with Setup(...), run the test, then verify it delivered those lines with Verify(...).
This pattern only works if your production class accepts its dependencies through a constructor (constructor injection). If a class creates its own new EmailService() internally, you cannot mock it. This is why dependency injection is not just an architectural nicety — it is a testability requirement.
Below we test an OrderService that sends a confirmation email after a successful order. We want to verify the email is sent exactly once with the right address, without firing off a real email during our test run.
// First, add Moq to the test project: // dotnet add OrderProcessing.Tests package Moq // ─── OrderProcessing.Core/IEmailService.cs ─── namespace OrderProcessing.Core { public interface IEmailService { // Sends a confirmation to the given address and returns true on success bool SendOrderConfirmation(string recipientEmail, int orderId); } } // ─── OrderProcessing.Core/OrderService.cs ─── namespace OrderProcessing.Core { public class OrderService { private readonly IEmailService _emailService; private readonly OrderCalculator _calculator; // Dependencies injected through constructor — this is what makes mocking possible public OrderService(IEmailService emailService, OrderCalculator calculator) { _emailService = emailService; _calculator = calculator; } // Places an order, calculates final price, and sends a confirmation email // Returns the final order total, or throws if email fails public decimal PlaceOrder(string customerEmail, decimal subtotal, decimal discountPercent) { if (string.IsNullOrWhiteSpace(customerEmail)) throw new ArgumentException("Customer email is required.", nameof(customerEmail)); decimal finalTotal = _calculator.CalculateTotal(subtotal, discountPercent); // Generate a deterministic order ID (simplified for the example) int orderId = Math.Abs(customerEmail.GetHashCode() % 100000); bool emailSent = _emailService.SendOrderConfirmation(customerEmail, orderId); if (!emailSent) throw new InvalidOperationException("Order confirmation email could not be sent."); return finalTotal; } } } // ─── OrderProcessing.Tests/OrderServiceTests.cs ─── using Xunit; using Moq; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class OrderServiceTests { // Moq creates a fake IEmailService — no real emails, no SMTP server needed private readonly Mock<IEmailService> _mockEmailService; private readonly OrderService _orderService; public OrderServiceTests() { _mockEmailService = new Mock<IEmailService>(); var calculator = new OrderCalculator(); // real calculator — no need to mock pure math // Inject the MOCK email service into OrderService _orderService = new OrderService(_mockEmailService.Object, calculator); } [Fact] public void PlaceOrder_WhenEmailSucceeds_ReturnsFinalTotal() { // Arrange: script the fake email service to succeed for ANY string and int _mockEmailService .Setup(emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), // match any email address It.IsAny<int>())) // match any order ID .Returns(true); // always say 'yes, email was sent' // Act: place a real order through the real OrderService decimal finalTotal = _orderService.PlaceOrder( customerEmail: "alice@example.com", subtotal: 150.00m, discountPercent: 10m ); // Assert 1: the math came out right (10% off 150 = 135) Assert.Equal(135.00m, finalTotal); // Assert 2: verify the email service was called exactly once // This proves we didn't forget to send the confirmation _mockEmailService.Verify( emailSvc => emailSvc.SendOrderConfirmation( "alice@example.com", // must be this exact address It.IsAny<int>()), // any order ID is fine Times.Once() // called exactly once — not zero, not twice ); } [Fact] public void PlaceOrder_WhenEmailServiceFails_ThrowsInvalidOperationException() { // Arrange: script the fake email service to FAIL _mockEmailService .Setup(emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), It.IsAny<int>())) .Returns(false); // simulate an SMTP outage // Act + Assert: placing the order should throw because email failed var exception = Assert.Throws<InvalidOperationException>( () => _orderService.PlaceOrder("bob@example.com", 200.00m, 0m) ); Assert.Contains("confirmation email", exception.Message); } [Fact] public void PlaceOrder_WhenEmailIsEmpty_ThrowsArgumentException_BeforeCallingEmailService() { // Act + Assert: an empty email should fail immediately Assert.Throws<ArgumentException>( () => _orderService.PlaceOrder("", 100.00m, 0m) ); // Verify the email service was NEVER called — we should have thrown before reaching it _mockEmailService.Verify( emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), It.IsAny<int>()), Times.Never() // this line proves our guard clause fired correctly ); } } }
Passed! PlaceOrder_WhenEmailSucceeds_ReturnsFinalTotal
Passed! PlaceOrder_WhenEmailServiceFails_ThrowsInvalidOperationException
Passed! PlaceOrder_WhenEmailIsEmpty_ThrowsArgumentException_BeforeCallingEmailService
Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 52 ms
Test Lifecycle with IClassFixture — Shared Setup Without Shared State
xUnit creates a fresh instance of your test class for every single test method. This is a deliberate design decision that eliminates a whole class of bugs caused by tests accidentally sharing state. NUnit and MSTest use [SetUp]/[TearDown] methods, which run before and after each test but on the same object — that means a dirty field from test A can corrupt test B if your setup is incomplete.
xUnit's answer is simpler: constructor and IDisposable. Anything you put in the test class constructor runs before each test. Anything you put in Dispose() runs after. No magic attributes — just C# you already know.
But sometimes setup is genuinely expensive — spinning up an in-memory database, loading a large config file — and you do not want to repeat it for every test. That is what IClassFixture is for. It creates the expensive resource once per test class, shares it across all tests in that class, then disposes it when the class is done. Crucially, each test still gets a fresh test class instance — only the fixture is shared.
Here is a pattern you will see in real .NET projects using an in-memory database fixture.
// dotnet add OrderProcessing.Tests package Microsoft.EntityFrameworkCore.InMemory // ─── OrderProcessing.Core/Order.cs ─── namespace OrderProcessing.Core { public class Order { public int Id { get; set; } public string CustomerEmail { get; set; } = string.Empty; public decimal Total { get; set; } public DateTime PlacedAt { get; set; } } } // ─── OrderProcessing.Core/AppDbContext.cs ─── using Microsoft.EntityFrameworkCore; namespace OrderProcessing.Core { public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Order> Orders => Set<Order>(); } } // ─── OrderProcessing.Tests/DatabaseFixture.cs ─── // This class is created ONCE and shared across all tests in OrderRepositoryTests using Microsoft.EntityFrameworkCore; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class DatabaseFixture : IDisposable { public AppDbContext Context { get; } public DatabaseFixture() { // Build an in-memory database — fast, isolated, no file system needed var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(databaseName: "OrderTests_" + Guid.NewGuid()) // unique name prevents bleed-over .Options; Context = new AppDbContext(options); // Seed with known data that tests can rely on Context.Orders.AddRange( new Order { Id = 1, CustomerEmail = "alice@example.com", Total = 99.99m, PlacedAt = DateTime.UtcNow }, new Order { Id = 2, CustomerEmail = "bob@example.com", Total = 249.00m, PlacedAt = DateTime.UtcNow } ); Context.SaveChanges(); } // Called automatically by xUnit after all tests in the class have run public void Dispose() { Context.Database.EnsureDeleted(); // clean up the in-memory database Context.Dispose(); } } } // ─── OrderProcessing.Tests/OrderRepositoryTests.cs ─── using Xunit; using OrderProcessing.Core; namespace OrderProcessing.Tests { // IClassFixture<DatabaseFixture> tells xUnit: create DatabaseFixture once, share it with all tests here public class OrderRepositoryTests : IClassFixture<DatabaseFixture> { private readonly AppDbContext _context; // xUnit injects the shared fixture through the constructor public OrderRepositoryTests(DatabaseFixture fixture) { _context = fixture.Context; } [Fact] public void GetOrder_WhenIdExists_ReturnsCorrectOrder() { // Act: query the seeded in-memory database var order = _context.Orders.Find(1); // Assert: the seeded data is exactly what we expect Assert.NotNull(order); Assert.Equal("alice@example.com", order.CustomerEmail); Assert.Equal(99.99m, order.Total); } [Fact] public void GetOrder_WhenIdDoesNotExist_ReturnsNull() { // Act: look for an order that was never seeded var order = _context.Orders.Find(999); // Assert: EF Core returns null for a missing key — not an exception Assert.Null(order); } [Fact] public void GetAllOrders_ReturnsExactlySeedCount() { // The fixture seeded exactly 2 orders int orderCount = _context.Orders.Count(); Assert.Equal(2, orderCount); } } }
Passed! GetOrder_WhenIdExists_ReturnsCorrectOrder
Passed! GetOrder_WhenIdDoesNotExist_ReturnsNull
Passed! GetAllOrders_ReturnsExactlySeedCount
Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 124 ms
| Feature / Aspect | xUnit | NUnit | MSTest |
|---|---|---|---|
| Test attribute | [Fact] / [Theory] | [Test] / [TestCase] | [TestMethod] / [DataTestMethod] |
| Parameterised tests | [InlineData], [MemberData], [ClassData] | [TestCase], [TestCaseSource] | [DataRow], [DynamicData] |
| Setup per test | Constructor + IDisposable | [SetUp] / [TearDown] | [TestInitialize] / [TestCleanup] |
| Shared setup across tests | IClassFixture | [OneTimeSetUp] / [OneTimeTearDown] | [ClassInitialize] / [ClassCleanup] |
| Test isolation | New class instance per test (safest) | Same instance, reset via [SetUp] | Same instance, reset via [TestInitialize] |
| Parallel execution | Enabled by default per test class | Opt-in via attribute | Opt-in via settings file |
| Microsoft .NET team uses | Yes (runtime, ASP.NET Core repos) | No | No |
| Output capture | Built-in ITestOutputHelper | Console capture built-in | Console capture built-in |
| NuGet package | xunit + xunit.runner.visualstudio | nunit + NUnit3TestAdapter | MSTest.TestFramework + MSTest.TestAdapter |
🎯 Key Takeaways
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.