Unit Testing
Tests let you change ShopSmart without fear. Unit tests check one piece — a price calculator, a controller action — in milliseconds, without a browser or database. With xUnit and Moq you will write your first useful ones.
On this page · 7 min · Advanced
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Advanced
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
Unit tests check each machine part on a test bench before assembling the whole machine.
- xUnit
- Test framework.
- Mock
- Fake dependency, e.g. a fake service.
- Controller tests
- Assert the returned ActionResult.
Learning objectives#
- Create an xUnit test project and run dotnet test
- Write Facts and data-driven Theories
- Test controller actions and their IActionResult
- Replace dependencies with Moq mocks
Explanation#
Create a test project next to the app: dotnet new xunit -n ShopSmart.Tests, then dotnet add reference ../ShopSmart. Run everything with dotnet test (or Test Explorer in Visual Studio).
A [Fact] is one test; a [Theory] with [InlineData] runs the same test with several inputs — perfect for pricing rules. Structure each test as Arrange, Act, Assert.
Controllers are plain classes: create one with its dependencies, call the action, and assert on the result type (ViewResult, NotFoundResult, RedirectToActionResult) and the model.
Moq creates fake implementations of interfaces: mock.Setup(r => r.FindAsync(7)).ReturnsAsync(product). Unconfigured methods return defaults (null), and Verify checks that a call happened.
Unit tests cover logic in isolation. Use integration tests (WebApplicationFactory, a real or containerised database) to check the pieces work together.
Syntax#
[Theory]
[InlineData(500, null, 549)]
public void Total_adds_shipping_below_999(int subtotal, string? coupon, int expected) =>
Assert.Equal(expected, new PriceCalculator().Total(subtotal, coupon));
Code example: Tests for a service and a controller#
// Services/PriceCalculator.cs
public class PriceCalculator
{
public decimal Total(decimal subtotal, string? coupon)
{
if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
var discount = coupon?.ToUpperInvariant() switch
{
"SAVE10" => 0.10m,
"SAVE20" when subtotal >= 2000 => 0.20m,
_ => 0m,
};
var shipping = subtotal >= 999 ? 0m : 49m;
return Math.Round(subtotal * (1 - discount), 2) + shipping;
}
}
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
public interface IProductRepository
{
Task<Product?> FindAsync(int id);
}
public class ProductsController(IProductRepository products) : Controller
{
public async Task<IActionResult> Details(int id) =>
await products.FindAsync(id) is { } product ? View(product) : NotFound();
}
// Tests/PriceCalculatorTests.cs
using Xunit;
public class PriceCalculatorTests
{
private readonly PriceCalculator _calculator = new();
[Theory]
[InlineData(500, null, 549)] // small orders pay shipping
[InlineData(1000, null, 1000)] // free shipping from 999
[InlineData(1000, "save10", 900)] // coupon codes are case-insensitive
[InlineData(1500, "SAVE20", 1500)] // SAVE20 needs at least 2000
[InlineData(2500, "SAVE20", 2000)]
public void Total_applies_coupons_and_shipping(int subtotal, string? coupon, int expected) =>
Assert.Equal(expected, _calculator.Total(subtotal, coupon));
[Fact]
public void Total_rejects_negative_subtotals() =>
Assert.Throws<ArgumentOutOfRangeException>(() => _calculator.Total(-1, null));
}
// Tests/ProductsControllerTests.cs
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
public class ProductsControllerTests
{
[Fact]
public async Task Details_returns_the_view_with_the_product()
{
// Arrange
var repository = new Mock<IProductRepository>();
repository.Setup(r => r.FindAsync(7)).ReturnsAsync(new Product { Id = 7, Name = "Wireless Mouse" });
var controller = new ProductsController(repository.Object);
// Act
var result = await controller.Details(7);
// Assert
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Wireless Mouse", Assert.IsType<Product>(view.Model).Name);
repository.Verify(r => r.FindAsync(7), Times.Once);
}
[Fact]
public async Task Details_returns_404_for_unknown_products()
{
var repository = new Mock<IProductRepository>(); // FindAsync returns null unless set up
var result = await new ProductsController(repository.Object).Details(99);
Assert.IsType<NotFoundResult>(result);
}
}
dotnet test runs 8 tests (5 Theory cases + 3 Facts) and reports: Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8. Changing the free-shipping limit to 1000 makes the “1000, null, 1000” case still pass but would catch a typo like 9999 immediately.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- PriceCalculator has pure logic, so tests just call it — no setup at all.
- The Theory runs five cases, including the tricky ones (case-insensitive codes, SAVE20 below its minimum).
- The Fact checks that invalid input throws.
- The controller test mocks IProductRepository, calls the action, and asserts the result type and model.
- Verify confirms the repository was called exactly once; the 404 test relies on Moq’s default null.
Real-world use case#
ShopSmart’s pricing, coupon and stock rules have Theory tests with dozens of cases. Every pull request runs dotnet test in the CI pipeline, and a failing test blocks the merge.
Common mistakes#
Unit tests full of DbContext setup test the framework. Put logic in services and test them with fakes; leave database behaviour to integration tests.
A test that checks ten things fails without telling you which. Keep tests small with descriptive names.
Over-mocked tests break on every refactor. Mock boundaries (repositories, email, payment), not every class.
Best practices#
- Arrange–Act–Assert with clear names
- Theories for rules with many cases
- Mock only external boundaries
- Run tests in CI on every push
Remember this#
[Fact] = one test. [Theory] + [InlineData] = many cases.
Controller test: call the action, Assert.IsType<…Result>.
Moq: Setup(…).ReturnsAsync(…), Verify(…).
Practice exercise#
Your task: Write a test that checks Details does not call the repository more than once and returns a ViewResult whose ViewName is null (the default view).
Need a hint?
Show solution
// Tests/ProductsControllerViewTests.cs
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
public interface IProductRepository { Task<Product?> FindAsync(int id); }
public class ProductsController(IProductRepository products) : Controller
{
public async Task<IActionResult> Details(int id) =>
await products.FindAsync(id) is { } product ? View(product) : NotFound();
}
public class ProductsControllerViewTests
{
[Fact]
public async Task Details_uses_the_default_view_and_one_lookup()
{
var repository = new Mock<IProductRepository>();
repository.Setup(r => r.FindAsync(3)).ReturnsAsync(new Product { Id = 3, Name = "USB-C Hub" });
var view = Assert.IsType<ViewResult>(await new ProductsController(repository.Object).Details(3));
Assert.Null(view.ViewName);
repository.Verify(r => r.FindAsync(It.IsAny<int>()), Times.Once);
}
}
Mini quiz#
Which attribute runs one test method with several sets of inputs?
Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions
My notes#
Saved in this browser.