A unit test verifies a single unit of behavior (a method or class) in complete isolation from external dependencies. Good unit tests follow the FIRST properties: Fast, Isolated, Repeatable, Self-validating, Timely.
// xUnit example — testing a pure calculation with no external deps:
public class PriceCalculatorTests
{
[Fact]
public void CalculateDiscount_WhenOver100_Returns10PercentOff()
{
// Arrange — set up inputs
var calculator = new PriceCalculator();
// Act — exercise the unit
var result = calculator.CalculateDiscount(totalPrice: 150m);
// Assert — verify one logical outcome
Assert.Equal(135m, result);
}
// Bad test — asserts multiple unrelated outcomes in one test
// Makes failures ambiguous; keep each test focused on one behaviour
[Fact]
public void BadTest_TooManyAsserts()
{
var c = new PriceCalculator();
Assert.Equal(135m, c.CalculateDiscount(150m)); // 10% off
Assert.Equal(100m, c.CalculateDiscount(100m)); // no discount
Assert.Equal(0m, c.CalculateDiscount(0m)); // zero
}
}
A unit test should fail for exactly one reason. If it can fail for multiple reasons, split it into multiple tests.
Rule of thumb: A unit test that touches the database, filesystem, or network is an integration test in disguise — extract the dependency and mock it instead.
All three are supported by the .NET test runner, but they differ in philosophy and syntax. xUnit is the modern default for new .NET projects. NUnit is older but feature-rich. MSTest ships with Visual Studio and is Microsoft's first-party framework.
// Test marker attributes — same idea, different names:
// xUnit
[Fact] // single test
[Theory] // data-driven test
[InlineData(1,2)] // inline dataset
// NUnit
[Test]
[TestCase(1, 2)]
[TestFixture] // marks the test class
// MSTest
[TestMethod]
[DataTestMethod]
[DataRow(1, 2)]
[TestClass] // marks the test class
// xUnit creates a new instance per test (isolation by design):
public class XUnitTests
{
private readonly List<int> _items = new(); // fresh per test
[Fact] public void Test1() => _items.Add(1);
[Fact] public void Test2() => _items.Add(2); // _items is always empty here
}
// NUnit/MSTest reuse the same instance — use [SetUp]/[TestInitialize] to reset:
[TestFixture]
public class NUnitTests
{
private List<int> _items;
[SetUp] public void Setup() => _items = new List<int>(); // reset each time
}
Key xUnit differentiator: no [SetUp]/[TearDown] — use constructor for setup
and IDisposable for teardown, which makes the lifecycle explicit.
Rule of thumb: Choose xUnit for new projects; it enforces better isolation patterns by design. Match the framework your team already uses if joining existing code.
Arrange-Act-Assert (AAA) is a structural convention for writing tests clearly. It separates setup, execution, and verification into three distinct phases, making the test's intent immediately readable.
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_WhenStockAvailable_ReturnsConfirmedOrder()
{
// Arrange — prepare everything the test needs
var mockInventory = new Mock<IInventoryService>();
mockInventory
.Setup(i => i.IsInStock("SKU-42", quantity: 2))
.Returns(true);
var service = new OrderService(mockInventory.Object);
var order = new Order { Sku = "SKU-42", Quantity = 2 };
// Act — call exactly ONE thing being tested
var result = service.PlaceOrder(order);
// Assert — verify the expected outcome
Assert.Equal(OrderStatus.Confirmed, result.Status);
}
}
// Common mistake: Arrange inside Assert section
// [Fact]
// public void BadLayout()
// {
// var service = new OrderService(); // scattered setup
// var result = service.PlaceOrder(new Order { Sku = "X" });
// var expected = OrderStatus.Confirmed; // computed in assert block
// Assert.Equal(expected, result.Status);
// }
When a test fails, the three-section structure tells you immediately whether setup failed (Arrange), the wrong method was called (Act), or the result was wrong (Assert).
Rule of thumb: If your Act section has more than one line, you are probably testing two things — split it.
Test names should communicate what is being tested, under what condition, and what the expected outcome is — without needing to read the body.
// Pattern 1 — MethodName_Scenario_ExpectedResult (most common):
public void CalculateDiscount_WhenOrderOver100_Returns10Percent()
public void Login_WithInvalidPassword_ThrowsUnauthorizedException()
public void GetUser_WhenUserDoesNotExist_ReturnsNull()
// Pattern 2 — Given_When_Then (BDD style, popular with NUnit/Gherkin):
public void Given_OrderOver100_When_DiscountCalculated_Then_Returns10Percent()
// Pattern 3 — natural language (xUnit with DisplayName):
[Fact(DisplayName = "Discount is 10% when order total exceeds $100")]
public void Discount_Applied_For_Large_Orders() { }
// Test class naming — mirrors the class under test:
public class PriceCalculatorTests { } // testing PriceCalculator
public class OrderServiceTests { } // testing OrderService
// File placement — same namespace structure in a separate test project:
// src/MyApp/Services/OrderService.cs
// tests/MyApp.Tests/Services/OrderServiceTests.cs
A well-named test acts as living documentation: the test report should read like a specification of the system's expected behavior.
Rule of thumb: If you need to read the test body to understand what it tests, the name is not descriptive enough.
xUnit uses [Theory] with [InlineData], [MemberData], or [ClassData]
to run the same test logic against multiple inputs without code duplication.
public class DiscountCalculatorTests
{
// [InlineData] — simplest: literals in the attribute
[Theory]
[InlineData(50, 50)] // below threshold → no discount
[InlineData(100, 100)] // exactly at threshold → no discount
[InlineData(150, 135)] // above threshold → 10% off
[InlineData(200, 180)]
public void CalculateDiscount_ReturnsExpectedPrice(
decimal input, decimal expected)
{
var calc = new DiscountCalculator();
var result = calc.Calculate(input);
Assert.Equal(expected, result);
}
// [MemberData] — use when datasets are complex or reused
public static IEnumerable<object[]> InvalidOrders => new[]
{
new object[] { null, "order" },
new object[] { new Order { Qty = 0 }, "qty" },
new object[] { new Order { Sku = "" }, "sku" },
};
[Theory]
[MemberData(nameof(InvalidOrders))]
public void PlaceOrder_WithInvalidInput_ThrowsArgumentException(
Order order, string paramName)
{
var svc = new OrderService();
var ex = Assert.Throws<ArgumentException>(() => svc.PlaceOrder(order));
Assert.Contains(paramName, ex.ParamName);
}
}
[ClassData] is for when the dataset logic belongs in its own class (useful
for complex generation or when the data source is shared across test classes).
Rule of thumb: Use [InlineData] for simple literals, [MemberData] when
the dataset is large or shared, and [ClassData] when the dataset needs its
own setup logic.
Test isolation means each test runs independently — no shared mutable state, no ordering dependency, no side effects that bleed between tests.
// Problem: shared static state breaks isolation
public class BadTests
{
private static List<string> _log = new(); // shared across all tests!
[Fact] public void Test1() { _log.Add("a"); Assert.Single(_log); }
[Fact] public void Test2() { _log.Add("b"); Assert.Single(_log); } // may fail!
}
// Fix 1: xUnit creates a new instance per test — use instance fields
public class GoodTests
{
private readonly List<string> _log = new(); // fresh per test instance
[Fact] public void Test1() { _log.Add("a"); Assert.Single(_log); }
[Fact] public void Test2() { _log.Add("b"); Assert.Single(_log); }
}
// Fix 2: shared expensive resources use IClassFixture (created once, shared read-only)
public class DatabaseFixture : IDisposable
{
public SqliteConnection Db { get; } = new("Data Source=:memory:");
public DatabaseFixture() { Db.Open(); /* seed schema */ }
public void Dispose() { Db.Close(); }
}
// IClassFixture<T> — one fixture instance shared across all tests in the class
public class QueryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
public QueryTests(DatabaseFixture fixture) => _fixture = fixture;
[Fact] public void Query_ReturnsResults() { /* uses _fixture.Db */ }
}
For shared state across multiple test classes, use ICollectionFixture<T>
with [Collection("name")].
Rule of thumb: Mutate instance fields freely — xUnit's per-instance model
keeps them isolated. Reserve IClassFixture only for expensive setup (in-memory
databases, servers) that is too slow to rebuild per test.
xUnit provides Assert.Throws<T> and Assert.ThrowsAsync<T> to verify that
a specific exception type is thrown. You can also inspect the exception instance.
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_WithNullOrder_ThrowsArgumentNullException()
{
var service = new OrderService();
// Assert.Throws returns the exception so you can inspect it
var ex = Assert.Throws<ArgumentNullException>(
() => service.PlaceOrder(null));
Assert.Equal("order", ex.ParamName); // verify the parameter name
}
[Fact]
public async Task PlaceOrderAsync_WhenOutOfStock_ThrowsInvalidOperationException()
{
var mockInventory = new Mock<IInventoryService>();
mockInventory.Setup(i => i.IsInStock(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(false);
var service = new OrderService(mockInventory.Object);
// Async exception testing:
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.PlaceOrderAsync(new Order { Sku = "X", Quantity = 1 }));
Assert.Contains("out of stock", ex.Message, StringComparison.OrdinalIgnoreCase);
}
// Common mistake — this does NOT test the exception; it catches and swallows it:
// [Fact]
// public void WrongWay()
// {
// try { service.PlaceOrder(null); }
// catch (ArgumentNullException) { } // test passes even if no exception thrown!
// }
}
Record.Exception is an alternative that captures the exception without
asserting type — useful when you want to assert multiple properties of the same
exception without re-calling the method.
Rule of thumb: Always use Assert.Throws/Assert.ThrowsAsync, never bare
try-catch in tests — the bare form cannot distinguish "threw nothing" from "threw
the right exception."
Test doubles are objects that stand in for real dependencies. The five types differ in how much behavior they provide.
// 1. Dummy — passed but never used (fills a required parameter)
var dummyLogger = new Mock<ILogger>().Object;
var service = new OrderService(dummyLogger, realRepository);
// 2. Stub — returns canned answers to method calls
var stubInventory = new Mock<IInventoryService>();
stubInventory.Setup(i => i.IsInStock("SKU-1", 2)).Returns(true);
// No verification — we just want a fixed answer
// 3. Mock — verifies interactions (was a method called?)
var mockEmailer = new Mock<IEmailService>();
service.PlaceOrder(order);
mockEmailer.Verify(e => e.SendConfirmation(order.Email), Times.Once);
// Fails if SendConfirmation was never called
// 4. Spy — records calls for later inspection (less common with Moq)
var calls = new List<string>();
mockEmailer.Setup(e => e.SendConfirmation(It.IsAny<string>()))
.Callback<string>(email => calls.Add(email));
// 5. Fake — a working but simplified implementation
// An in-memory repository is the classic fake:
public class FakeOrderRepository : IOrderRepository
{
private readonly List<Order> _store = new();
public void Add(Order o) => _store.Add(o);
public Order? Get(int id) => _store.FirstOrDefault(o => o.Id == id);
}
Fakes are usually hand-written; stubs, mocks, and spies are usually created with a mocking library like Moq or NSubstitute.
Rule of thumb: Use stubs when you need controlled return values, mocks when you need to verify behavior, and fakes when the interaction is too complex to stub realistically.
Testability is a design quality, not a testing afterthought. The key practices all reduce coupling and surface dependencies explicitly.
// Hard to test — hidden dependency, static call, can't inject a fake:
public class ReportService
{
public string GenerateReport()
{
var data = Database.Query("SELECT ..."); // static, untestable
return FormatData(data);
}
}
// Testable — dependency injected, interface-typed, fakeable:
public class ReportService
{
private readonly IDataRepository _repo;
public ReportService(IDataRepository repo) => _repo = repo;
public string GenerateReport()
{
var data = _repo.Query("SELECT ...");
return FormatData(data);
}
}
// Other testability enablers:
// 1. Small, focused classes — SRP means less setup per test
// 2. Avoid static methods and singletons — they become test pollution
// 3. Avoid new-ing dependencies inside methods — use DI instead
// 4. Return values rather than mutating parameters — easier to assert
// 5. Make side-effecting methods take an interface (IEmailSender, IClock)
// Testable time — inject IClock so tests can control "now":
public interface IClock { DateTime UtcNow { get; } }
public class SystemClock : IClock { public DateTime UtcNow => DateTime.UtcNow; }
// In tests:
var fakeClock = new Mock<IClock>();
fakeClock.Setup(c => c.UtcNow).Returns(new DateTime(2026, 1, 1));
Rule of thumb: If you cannot instantiate a class in a test without spinning up a real database or network, the class is not designed for testability — inject its dependencies instead.
Code coverage measures what percentage of production code is executed by tests. It is a useful signal but not a goal in itself — 100% coverage does not mean the code is correct.
# Measure coverage with coverlet (installed via NuGet in test project):
dotnet add package coverlet.collector
# Run tests and collect coverage:
dotnet test --collect:"XPlat Code Coverage"
# Produces a coverage.cobertura.xml in TestResults/
# Generate an HTML report with ReportGenerator:
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coverage-report"
// ExcludeFromCodeCoverage — exclude generated or trivial code from metrics:
[ExcludeFromCodeCoverage]
public class AutoGeneratedDto { /* property getters */ }
// Branch coverage vs line coverage:
public string Classify(int n)
{
if (n > 0) return "positive"; // line 1
if (n < 0) return "negative"; // line 2
return "zero"; // line 3
// 100% line coverage needs 3 tests (one per return)
// 100% branch coverage also needs to test n==0 reaching line 3
}
Aim for high coverage of business logic and edge cases; do not chase coverage in generated code, configuration bootstrapping, or trivial property accessors.
Rule of thumb: 70–80% line coverage on core business logic is a healthy target. Coverage below 50% is a warning sign; 100% is usually not worth the maintenance cost unless safety-critical.
xUnit deliberately avoids [SetUp]/[TearDown] attributes in favour of the
standard C# lifecycle: constructor for setup and IDisposable.Dispose
for teardown. A new instance is created per test.
public class FileProcessorTests : IDisposable
{
private readonly string _tempFile;
private readonly FileProcessor _processor;
// Constructor = [SetUp]: runs before EACH test
public FileProcessorTests()
{
_tempFile = Path.GetTempFileName();
_processor = new FileProcessor();
File.WriteAllText(_tempFile, "test data"); // prepare test fixture
}
[Fact]
public void Process_ValidFile_ReturnsLineCount()
{
var count = _processor.CountLines(_tempFile);
Assert.Equal(1, count);
}
[Fact]
public void Process_EmptyFile_ReturnsZero()
{
File.WriteAllText(_tempFile, "");
var count = _processor.CountLines(_tempFile);
Assert.Equal(0, count);
}
// IDisposable.Dispose = [TearDown]: runs after EACH test
public void Dispose()
{
if (File.Exists(_tempFile))
File.Delete(_tempFile); // clean up regardless of pass/fail
}
}
// For async teardown, implement IAsyncLifetime instead:
public class AsyncSetupTests : IAsyncLifetime
{
public async Task InitializeAsync() { /* async setup */ }
public async Task DisposeAsync() { /* async teardown */ }
[Fact] public void Test() { }
}
Rule of thumb: Implement IDisposable in every test class that acquires
unmanaged resources (files, sockets, database connections). This ensures cleanup
even when a test throws.
FluentAssertions is a NuGet library that provides a natural-language API
for assertions. It produces richer failure messages and reads closer to spoken
English than the positional Assert.Equal(expected, actual) style.
// Installation:
// dotnet add package FluentAssertions
// xUnit built-in — argument order matters and errors are terse:
Assert.Equal(expected: 42, actual: result); // "Expected: 42\nActual: 43"
Assert.True(list.Contains("x")); // "Assert.True() Failure: Expected: True, Actual: False"
// FluentAssertions — reads as a sentence, errors name the violated condition:
result.Should().Be(42);
list.Should().Contain("x");
list.Should().HaveCount(3).And.NotContain("y");
// Collection assertions:
orders.Should().AllSatisfy(o => o.Total.Should().BePositive());
orders.Should().BeInAscendingOrder(o => o.PlacedAt);
// Exception assertions (alternative to Assert.Throws):
Action act = () => service.PlaceOrder(null);
act.Should().Throw<ArgumentNullException>()
.WithParameterName("order")
.WithMessage("*cannot be null*");
// Async:
Func<Task> asyncAct = () => service.PlaceOrderAsync(null);
await asyncAct.Should().ThrowAsync<ArgumentNullException>();
The failure message for list.Should().Contain("x") is:
Expected list {"a", "b"} to contain "x".
That's immediately actionable without reading the test body.
Rule of thumb: Use FluentAssertions in any project where test failures need to be quickly diagnosed by the whole team — the self-describing messages cut debugging time significantly.
Large test classes become hard to navigate. Three common techniques keep them clean: nested classes, shared builder helpers, and test base classes.
// 1. Nested classes — group tests by method or scenario:
public class OrderServiceTests
{
public class PlaceOrder
{
[Fact] public void WhenNull_Throws() { }
[Fact] public void WhenOutOfStock_Throws() { }
[Fact] public void WhenValid_ReturnsConfirmed() { }
}
public class CancelOrder
{
[Fact] public void WhenAlreadyCancelled_Throws() { }
[Fact] public void WhenPending_SetsStatusCancelled() { }
}
}
// xUnit discovers nested public classes automatically
// 2. Object Mother / Builder — remove Arrange duplication:
public static class OrderBuilder
{
public static Order Valid() => new Order
{ Sku = "SKU-1", Quantity = 1, CustomerId = Guid.NewGuid() };
public static Order WithQuantity(int qty)
=> Valid() with { Quantity = qty };
}
// In tests:
var order = OrderBuilder.WithQuantity(5);
// 3. Abstract base class — share setup across multiple test classes:
public abstract class OrderServiceTestBase
{
protected readonly Mock<IInventoryService> MockInventory = new();
protected readonly OrderService Service;
protected OrderServiceTestBase()
=> Service = new OrderService(MockInventory.Object);
}
public class PlaceOrderTests : OrderServiceTestBase
{
[Fact] public void Valid_ReturnsConfirmed()
{
MockInventory.Setup(i => i.IsInStock(It.IsAny<string>(), 1)).Returns(true);
var result = Service.PlaceOrder(OrderBuilder.Valid());
Assert.Equal(OrderStatus.Confirmed, result.Status);
}
}
Rule of thumb: Reach for nested classes first — they add structure with zero new files. Extract a builder when three or more tests share the same Arrange block.
xUnit supports async Task test methods natively. You await the method
under test directly in the test body — no .Result or .Wait(), which
would deadlock or swallow exceptions.
public class OrderServiceAsyncTests
{
[Fact]
public async Task PlaceOrderAsync_WhenValid_ReturnsConfirmedOrder()
{
// Arrange
var mockInventory = new Mock<IInventoryService>();
mockInventory
.Setup(i => i.IsInStockAsync("SKU-1", 2))
.ReturnsAsync(true);
var service = new OrderService(mockInventory.Object);
var order = new Order { Sku = "SKU-1", Quantity = 2 };
// Act — await directly; xUnit handles the async state machine
var result = await service.PlaceOrderAsync(order);
// Assert
Assert.Equal(OrderStatus.Confirmed, result.Status);
}
// Testing async exceptions:
[Fact]
public async Task PlaceOrderAsync_WhenOutOfStock_ThrowsInvalidOperationException()
{
var mockInventory = new Mock<IInventoryService>();
mockInventory
.Setup(i => i.IsInStockAsync(It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(false);
var service = new OrderService(mockInventory.Object);
// Assert.ThrowsAsync — await it or the exception goes unobserved:
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.PlaceOrderAsync(new Order { Sku = "X", Quantity = 1 }));
}
// Bad: .Result deadlocks in some synchronization contexts
// and converts exceptions to AggregateException:
// var result = service.PlaceOrderAsync(order).Result; // Never do this in tests
}
Mark the test method async Task (not async void) — async void tests
complete before the awaited work finishes, making them silently unreliable.
Rule of thumb: Always return Task from async tests and await every
async call. An async void test that throws will crash the test runner
process rather than fail the test gracefully.
Private methods are implementation details — they should be tested indirectly through the public API. Testing them directly couples tests to internal structure, making refactoring painful without changing behavior.
// Bad: exposing a private method just to test it directly
public class InvoiceService
{
// Made internal only to allow testing — wrong motivation:
internal decimal ApplyLateFee(decimal amount, int daysLate)
=> amount + (daysLate > 30 ? 50m : 0m);
}
// Good: test through the public method that delegates to the private one
public class InvoiceServiceTests
{
[Theory]
[InlineData(29, 100m, 100m)] // not late enough — no fee
[InlineData(30, 100m, 100m)] // exactly 30 days — no fee
[InlineData(31, 100m, 150m)] // over 30 days — $50 late fee applied
public void FinalizeInvoice_AppliesLateFeeWhenOverdue(
int daysLate, decimal subtotal, decimal expected)
{
var service = new InvoiceService();
var invoice = new Invoice { Subtotal = subtotal, DaysLate = daysLate };
var result = service.FinalizeInvoice(invoice); // public method
Assert.Equal(expected, result.Total);
}
}
// When a private method is genuinely complex and hard to reach:
// Option 1 — extract it to a separate class with a public method
// Option 2 — use [InternalsVisibleTo] + internal access (sparingly)
// Option 3 — reflect on it via PrivateObject (legacy MSTest; fragile)
// The need to test a private method directly is usually a signal that
// the method belongs in its own class with a clear public contract.
If a private method is so complex it needs its own test, extract it into a collaborating class with a public interface.
Rule of thumb: Test behavior, not implementation. If you cannot reach a private method through the public API with reasonable effort, the class is probably doing too much — split it.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.