Unit testing in .NET Core with NUnit and Moq: a practical guide

Modern .NET Core applications move fast, and the teams building them need feedback they can trust. NUnit paired with Moq remains one of the most reliable combinations for .NET Core unit testing because it gives developers a clear, expressive syntax for arranging tests, exercising production code, and asserting behaviour. Whether you are working on a banking integration in Sydney, a logistics platform in Melbourne, or a government portal in Canberra, these two libraries scale gracefully from a handful of tests to thousands.

In Australia, where APRA-regulated entities and organisations handling personal data under the Privacy Act 1988 have to demonstrate rigour, disciplined unit testing is more than a developer nicety. It is evidence that the code meets operational risk expectations. This walkthrough shows how to set up NUnit and Moq, write meaningful tests, mock external dependencies, and wire everything into a continuous integration pipeline that fits the way Australian delivery teams actually work.

Setting up the project structure

The first step is creating a test project alongside your main application. Open a terminal in your solution folder and add a new test project using the NUnit template from the .NET CLI: dotnet new nunit -n MyApp.Tests. The CLI scaffolds a project file, a sample test class, and the required NUnit and NUnit3TestAdapter packages.

After the project is created, add a project reference to the application under test, then install Moq with dotnet add package Moq. For .NET Core 3.1, .NET 5, and later versions, the package versions vary, so always pin to a version that matches your target framework. Many teams running on the latest LTS release, .NET 8, find that Moq 4.20 or higher works without friction.

A common pattern in Australian consulting teams is to mirror the source folder structure inside the test project. If your solution has Services/, Controllers/, and Repositories/ folders, the test project should follow the same hierarchy under Tests/. This makes it easier to onboard new engineers, particularly when the same engineers rotate between projects in Brisbane, Perth, and Adelaide.

Writing your first NUnit test

NUnit uses a familiar attribute-based syntax. Each test class is decorated with [TestFixture], and individual test methods carry the [Test] attribute. The classic arrange–act–assert pattern maps cleanly onto NUnit's setup and teardown hooks. Use [SetUp] to prepare shared state and [TearDown] for cleanup, keeping the body of each test focused on a single behaviour.

A simple example might test a tax calculation function. In Australia, the Goods and Services Tax rules under A New Tax System (Goods and Services Tax) Act 1999 require precise decimal handling, which makes it an ideal candidate for unit testing. The test asserts the expected output for a given input, including boundary cases like zero-rated supplies and exported services.

Keep test names descriptive. Rather than Test1, prefer Calculate_GstWith10PercentRate_ReturnsCorrectTax. The longer name reads awkwardly at first, but it pays off when a test fails on a CI dashboard and the team needs to know exactly what regressed without opening the code.

Mocking dependencies with Moq

Real applications rarely exist in isolation. A service that calculates GST might depend on a repository, a tax rate provider, and a logger. To test it in isolation, you need to substitute those dependencies with controllable fakes, and that is where Moq shines. The library uses dynamic proxy generation under the hood, so it works with any class or interface without requiring you to write hand-rolled stubs.

Set up a mock with var repo = new Mock<ITaxRateRepository>();, then arrange its behaviour: repo.Setup(r => r.GetCurrentRate()).Returns(0.10m);. Pass repo.Object into the system under test, exercise the method, and assert the outcome. The verification API also lets you assert that a dependency was called the expected number of times, which is useful for auditing write operations in regulated environments.

When teams work on consumer-facing platforms, mocking becomes essential for simulating third-party integrations. A recent French roulette engagement showed how Moq can stub payment gateway responses to validate risk-scoring logic without touching live systems, an approach that any team handling sensitive transactions will recognise.

Handling edge cases and exceptions

Strong test suites cover the unhappy path as well as the happy path. NUnit's [TestCase] attribute is perfect for parameterised tests, allowing you to feed multiple inputs through a single method. For example, a test that validates postcodes might accept [TestCase("2000", ExpectedResult = true)] for Sydney, [TestCase("3000", ExpectedResult = true)] for Melbourne, and [TestCase("ABCD", ExpectedResult = false)] to confirm the validation rejects malformed input.

For exception scenarios, NUnit offers [Test] combined with Assert.Throws<T>. This lets you assert that a specific exception type is raised when invalid input is supplied. In Australia, where the Notifiable Data Breaches scheme requires organisations to report eligible incidents, ensuring that PII-handling code throws and logs appropriately is a defensible control.

Moq also supports throwing exceptions from mocked methods through Setup(...).Throws<...>(). This is invaluable for simulating downstream failures, such as a database timeout or a third-party API returning 503, so you can verify that retry policies and circuit breakers behave as designed.

Organising tests for maintainability

A test suite that grows without structure quickly becomes a liability. Group related tests into folders and fixtures, and consider adopting a naming convention that encodes the system under test, the scenario, and the expected outcome. The convention [ClassName]_[Scenario]_[ExpectedResult] is widely used in Australian delivery teams because it produces sortable, greppable test names.

Shared setup logic belongs in [SetUp] methods or, for more complex cases, in object mother patterns or builder classes. Avoid static state between tests; NUnit runs tests in no guaranteed order, and shared state is a common source of flaky results. For integration-heavy tests, isolate them with a category attribute such as [Category("Integration")] so they can be filtered out of fast feedback loops.

Documentation matters too. A README.md in the test project root should explain how to run the suite locally, which environment variables are required, and which categories to exclude for quick runs. Teams in Melbourne and Sydney often run subsets of tests during local development to keep inner loop times under ten seconds.

Integrating with CI/CD pipelines

Unit tests deliver the most value when they run on every commit. Azure DevOps, GitHub Actions, and GitLab CI all support .NET Core workloads natively, and the standard recipe is dotnet restore, dotnet build, and dotnet test in sequence. Pin your SDK version in a global.json file so that pipeline runs match local development, avoiding the classic "works on my machine" problem.

Many Australian organisations using Microsoft tooling deploy through Azure, and the same applies to their CI infrastructure. A typical pipeline uploads test results as TRX files, which Azure DevOps renders natively, and publishes code coverage reports from coverlet or ReportGenerator. Treat any drop in coverage as a pull request blocker once the project reaches a stable baseline.

If your team is scaling rapidly and the testing estate is struggling to keep up with new features, the scaling teams guide explains how managed services can supplement in-house capacity without diluting engineering standards.

Measuring coverage and reporting results

Coverage is a useful signal but a poor goal on its own. Aim for meaningful coverage of business-critical paths rather than chasing a percentage. Tools like coverlet integrate with dotnet test and produce Cobertura or OpenCover reports that can be published as pipeline artefacts. For teams operating under APRA's CPS 234 information security requirements, coverage metrics also feed into control assurance evidence.

Track trends over time. A coverage dashboard that shows movement week over week is more informative than a single number. If a module drops below an agreed threshold, flag it in the next sprint review. Combining coverage with mutation testing, using Stryker.NET for .NET, gives a sharper view of test quality, since mutation testing reveals tests that pass without actually exercising the code.

Finally, surface results where the team already works. Slack or Microsoft Teams integrations that post build outcomes reduce the time between a failing test and a fix, and they keep quality visible to product owners who may not open Visual Studio every day. A regular test health review, scheduled at the same cadence as your sprint demo, keeps the conversation about quality front of mind for everyone involved.

If you would like help embedding these practices into your delivery rhythm, or want an independent quality assessment across a .NET Core estate, contact the team at nFocus to scope a discovery engagement tailored to your delivery cadence and risk profile.