Why unit testing matters in .NET interviews
Interviewers care deeply about testing because it reveals how a developer thinks about code quality, maintainability, and collaboration. A candidate who can write clear, isolated unit tests signals that they write production code that is also easier to reason about. This article walks through everything .NET interviewers commonly probe: framework choice, structure, parameterisation, isolation, and the design practices that make testing possible in the first place.
Choosing a test framework: xUnit, NUnit, or MSTest
All three frameworks run under the same .NET test runner (dotnet test) and are
supported by every major IDE, but they differ in philosophy and syntax.
xUnit is the modern default for new projects. Its most important feature is that it creates a new instance per test — not per class. That means instance fields are fresh for every test with zero effort, eliminating the most common source of test pollution.
NUnit is older and more feature-rich. It reuses the same instance per class
and relies on [SetUp]/[TearDown] for reset. Its [TestCase] attribute is
slightly more compact than xUnit's [InlineData] for simple parameterised tests.
MSTest is Microsoft's first-party framework, shipped inside Visual Studio.
It works identically at the conceptual level but has more boilerplate (every
class needs [TestClass]).
// xUnit — instance per test, no [SetUp]:
public class CalculatorTests
{
private readonly Calculator _calc = new(); // fresh every test
[Fact]
public void Add_TwoPositives_ReturnsSum() =>
Assert.Equal(5, _calc.Add(2, 3));
[Theory]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_VariousInputs_ReturnsExpectedSum(int a, int b, int expected) =>
Assert.Equal(expected, _calc.Add(a, b));
}
For new projects, start with xUnit. It enforces better isolation patterns by design and has the most active ecosystem.
The Arrange-Act-Assert pattern
Every well-structured test has exactly three phases:
- Arrange — prepare inputs and dependencies
- Act — call the one thing being tested
- Assert — verify the expected outcome
[Fact]
public void PlaceOrder_WhenStockAvailable_ReturnsConfirmedOrder()
{
// Arrange
var mockInventory = new Mock<IInventoryService>();
mockInventory.Setup(i => i.IsInStock("SKU-42", 2)).Returns(true);
var service = new OrderService(mockInventory.Object);
// Act
var result = service.PlaceOrder(new Order { Sku = "SKU-42", Quantity = 2 });
// Assert
Assert.Equal(OrderStatus.Confirmed, result.Status);
}
If your Act section has more than one statement, you are probably testing two things at once — split the test.
Test naming conventions
A test name should communicate what is being tested, under what condition, and what the expected outcome is. The most common pattern in .NET:
MethodName_Scenario_ExpectedResult
public void Login_WithInvalidPassword_ThrowsUnauthorizedException() { }
public void GetUser_WhenUserDoesNotExist_ReturnsNull() { }
public void CalculateDiscount_WhenOrderOver100_Returns10Percent() { }
A well-named test reads like a specification. If the test report reads like a bullet-point list of the system's requirements, you have named tests well.
Data-driven tests with Theory
[Theory] eliminates copy-paste by running the same test logic against multiple
inputs. xUnit supports three data sources:
// Inline — simplest; data in the attribute:
[Theory]
[InlineData(50, 50)]
[InlineData(150, 135)] // 10% off
public void Calculate_ReturnsExpectedPrice(decimal input, decimal expected)
=> Assert.Equal(expected, new PriceCalc().Calculate(input));
// MemberData — for complex or reusable datasets:
public static IEnumerable<object[]> EdgeCases => new[]
{
new object[] { 0m, 0m },
new object[] { 0.01m, 0.01m },
};
[Theory]
[MemberData(nameof(EdgeCases))]
public void Calculate_EdgeCases(decimal input, decimal expected) { }
Use [InlineData] for simple literals and [MemberData] when the dataset is
large, shared across multiple test methods, or needs its own construction logic.
Test isolation and IClassFixture
xUnit's per-instance model handles basic isolation automatically. For expensive
resources (database connections, in-memory databases) that are too slow to create
per test, use IClassFixture<T>:
public class DatabaseFixture : IDisposable
{
public SqliteConnection Db { get; } = new("Data Source=:memory:");
public DatabaseFixture() { Db.Open(); /* create schema */ }
public void Dispose() { Db.Close(); }
}
// One fixture instance shared across all tests in the class:
public class QueryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fx;
public QueryTests(DatabaseFixture fx) => _fx = fx;
[Fact] public void Query_ReturnsRows() { /* uses _fx.Db */ }
}
Use ICollectionFixture<T> with [Collection("name")] when you need the fixture
shared across multiple test classes.
Constructor and IDisposable: xUnit's lifecycle
xUnit deliberately omits [SetUp]/[TearDown]. Instead, use the standard C#
lifecycle:
- Constructor — setup before each test
- IDisposable.Dispose — cleanup after each test
public class FileTests : IDisposable
{
private readonly string _tempPath = Path.GetTempFileName();
// Constructor runs before each test:
public FileTests() => File.WriteAllText(_tempPath, "data");
[Fact]
public void Read_ReturnsContent()
=> Assert.Equal("data", File.ReadAllText(_tempPath));
// Dispose runs after each test, even on failure:
public void Dispose() => File.Delete(_tempPath);
}
For async setup/teardown, implement IAsyncLifetime instead.
FluentAssertions: richer failure messages
The built-in Assert.Equal(expected, actual) produces terse failure messages and
requires remembering argument order. FluentAssertions solves both:
// Built-in: "Expected: 42 / Actual: 43" — you have to find the test to know what failed
Assert.Equal(42, result);
// FluentAssertions: "Expected result to be 42, but found 43"
result.Should().Be(42);
// Collections:
orders.Should().HaveCount(3)
.And.AllSatisfy(o => o.Total.Should().BePositive());
// Exceptions:
Action act = () => service.PlaceOrder(null!);
act.Should().Throw<ArgumentNullException>().WithParameterName("order");
The self-describing messages make test failures diagnose themselves — essential when CI reports failures that someone else has to triage.
Designing testable code
The most important skill tested indirectly by unit testing questions is whether a candidate writes code that can be tested. The key principle: make dependencies explicit and injectable.
// Not testable — hidden dependency, static call:
public class ReportService
{
public string Generate() => FormatData(Database.Query("SELECT ...")); // untestable
}
// Testable — dependency injected as an interface:
public class ReportService
{
private readonly IDataRepository _repo;
public ReportService(IDataRepository repo) => _repo = repo;
public string Generate() => FormatData(_repo.Query("SELECT ..."));
}
Other testability principles:
- Avoid
new-ing dependencies inside methods — inject them - Prefer returning values over mutating parameters
- Extract time (
IClock), randomness (IRandom), and external I/O behind interfaces - Keep classes small — a class with ten dependencies is nearly impossible to unit test cleanly
Organising large test suites
As test counts grow, organisation matters:
// Nested classes group tests by method:
public class OrderServiceTests
{
public class PlaceOrder
{
[Fact] public void WhenNull_Throws() { }
[Fact] public void WhenOutOfStock_ReturnsRejected() { }
}
public class CancelOrder
{
[Fact] public void WhenPending_Succeeds() { }
}
}
// Object Mother removes Arrange duplication:
public static class OrderBuilder
{
public static Order Valid() => new() { Sku = "SKU-1", Quantity = 1 };
public static Order WithQuantity(int q) => Valid() with { Quantity = q };
}
When three or more tests share the same Arrange block, extract a builder. Start with nested classes before creating new files.
Rule of thumb: Write one assertion per test, name tests like specification bullet points, and inject every external dependency through an interface. If you cannot instantiate a class in a test without a database or network, the class is not designed for testing.