Patterns for writing unit tests with NUnit that hold up over time
Software teams in Sydney and Melbourne routinely ship C# systems that run Australian home loan applications, ATO integrations, and the back-end plumbing behind myGov. A fair slice of that work still lives inside Visual Studio, and a fair slice of it leans on NUnit for the unit test layer. The framework has been the default for managed code long enough that patterns surrounding it have stabilised, even if individual shops still argue about specifics. The goal here is to walk through patterns that remain useful long after the original author has moved on to another project.
NUnit does not force a particular style of writing tests the way some newer frameworks do. It provides attributes, a runner, and a handful of helper classes, then steps out of the way. That freedom is welcome once a team learns the conventions, because tests need to remain readable, isolated, and fast. Treating them as production code rather than as a chore that follows development usually pays for itself within a few sprints.
Getting NUnit into your project without the usual friction
The first decision is whether to wire NUnit by hand or rely on what Visual Studio gives you. The clean path remains a separate class library whose only job is to hold test classes, with a reference added to nunit.framework.dll. The test project should target the same .NET version as the production assembly, otherwise P-Invoke calls and generics can behave in unexpected ways on the build agent. When the two versions do drift apart, the symptoms tend to be subtle: a method that compiles fine but throws a MissingMethodException on the test runner, or a generic constraint that resolves differently in the IDE.
It is worth creating a small folder structure inside the test assembly so a developer can find a test by feature rather than by file. Some teams in Brisbane have adopted a convention where each test class lives in a file named after the class it covers, with the suffix Tests, then grouped under namespaces that mirror the production assembly. Reading the folder tree often tells a new hire exactly which behaviour is covered without needing to ask around. The trade-off is that renaming a production class cascades through the test tree, but that cost is worth paying for navigability in a codebase that will outlive the original author.
Once the project compiles, attaching the GUI runner to the test assembly is usually a matter of pointing nunit.exe at the produced DLL. Headless environments such as a build farm call nunit-console.exe instead and parse the XML output. Both produce the same result, and choosing between them is largely a question of whether someone wants to click through the green and red bars interactively or let a script do the work. Keep a small batch file or NAnt target around for the headless case even on developer machines, because it makes reproducing build-agent failures much easier.
Structuring fixtures and choosing names that survive contact with code
A test fixture in NUnit is simply a class decorated with [TestFixture]. The class does not need to inherit from anything, which keeps the design clean and avoids the trap of dragging framework code into the production assembly by accident. Inside the fixture, each public method marked with [Test] becomes a discoverable test case. A useful habit is to treat the fixture as a small example of how the production class is meant to be used. When that rule holds, a reviewer can read the test code as living documentation and skip the prose in a wiki that drifted out of date two refactors ago.
Naming is where many test suites go off the rails. Long concatenated names such as Test_Calculate_With_Overdue_Balance_And_Discount_Returns_Correct_Amount get unreadable fast. A clearer pattern is to split the test method name by underscore or capitalisation into three parts: the method under test, the scenario, and the expectation. C# groups in Perth have favoured capitalised substrings, others rely on underscores, and consistency matters far more than the style. The bigger rule is that the failure message should let a developer identify the missing branch without opening the source file.
[SetUp] and [TearDown] give the fixture a small, controlled lifecycle. [SetUp] runs before each test and is the right place to reset collaborators and seed data. [TearDown] cleans up afterwards, particularly when the test writes to a temp file, opens a database connection, or holds on to a singleton. Used sparingly, the pair prevents the copy-paste pattern that quietly accumulates inside fixtures until every test looks the same and a change to one breaks three others.
Assertions that say something useful when they fail
Most NUnit assertions accept an optional message string, and that optional argument is the most under-used part of the framework. A bare Assert.AreEqual(expected, actual) prints the two values when the test fails, which is fine for primitive types and useless for everything else. Wrapping the call with a short sentence describing the business rule catches the next developer out at two in the morning trying to figure out what went wrong. For a banking calculation in the style of an ANZ retail product, a message such as "interest should be compounded daily for savings balances" reads better in a CI log than "Expected 12.34 but was 12.35".
Constraint-based assertions expand the vocabulary considerably. Assert.That(value, Is.GreaterThan(threshold)) produces an error that names the rule and shows the offending value, which is often enough on its own without a custom message. The constraint object syntax encourages thinking about expectations as data rather than as a sequence of nested calls. That shift tends to make tests shorter, which in turn makes them easier to trust when a refactor lands in the production code under the watchful eye of a code review tool.
A common mistake is the all-purpose Assert.IsTrue(condition) call. It checks something, but the failure log only says "Expected true, was false", which leaves the reader guessing. Replacing those calls with the closest named constraint, or splitting a test into two so each one asserts a single property, raises the bar on what a test is actually proving. Catching that pattern early avoids a tests directory full of green ticks that suddenly turn red together when a routine change goes wrong.
Keeping tests honest with isolation and small fakes
Unit tests only stay unit-sized when they do not touch the file system, the registry, a database, or the network. NUnit does not enforce that discipline, so the team has to. A common pattern in Australian .NET shops is to introduce a thin abstraction over the troublesome dependency, then provide a lightweight fake in the test assembly. The fake can live next to the fixture that uses it, or in a small project called something like Acme.Fakes, depending on how widely it ends up being shared. Atlassian's internal teams have spoken publicly about the value of small, hand-written fakes, and the approach translates comfortably to the kind of internal banking systems that Australian developers maintain every day.
Mocking frameworks shifted how many teams wrote isolation code during the period when NUnit became entrenched. NMock, Rhino.Mocks, and TypeMock all had something to offer, and a small investment in one of them tends to pay off across a whole suite. The key is to set up the expectations in [SetUp] only when they are truly shared, and to keep them inside the test when the scenario is unusual. Long mock setups that repeat across tests signal that the test should be split or that a real collaborator is leaking through the abstraction. A short setup is almost always easier to read than a long one.
Threading is another place where tests quietly stop being unit tests. A timer that fires on a background thread, or a BeginInvoke that posts to a UI dispatcher, will sometimes pass and sometimes fail in ways that have nothing to do with the code under test. NUnit offers [Timeout] and [RequiresThread] attributes that catch some of these cases, but the cleaner answer is to keep the test single-threaded by default. When concurrency genuinely matters, it earns its own dedicated fixture with a name that warns the next reader what they are walking into.
Running NUnit as part of a daily build pipeline
Once a test suite grows beyond a few dozen cases, running it by hand stops being realistic. A build script that calls nunit-console.exe and reads the XML output is often the first step. On Windows the script sits naturally inside an MSBuild project, while cross-platform teams tend to wrap the call in NAnt or, by the end of the period, a custom batch file the build engineer maintains. Reporting the count of passing, failing, and skipped tests in the build email gives the team a quick sense of whether the day went well.
Continuous integration adds another layer of value. A server such as CruiseControl.NET, which had strong adoption in Australian development teams during this period, watches the source tree, kicks off a build, runs the tests, and surfaces the result on a dashboard visible to the whole team. A red build lights up immediately, and the person who committed last gets the appropriate amount of attention. Linking the NUnit output back to the commit makes that conversation much shorter and far less accusatory, which improves both morale and the quality of the eventual fix.
Coverage closes the loop. NCover, or the profiling that shipped with Visual Studio Team System, can record which lines each test exercises and produce a report. Coverage numbers taken in isolation are misleading, but the trend line over weeks tells a useful story. A sudden drop usually means a refactor that bypassed an old test, while a steady climb suggests the suite is being extended deliberately. Combining that view with the build results, kept simple and honest, is what most Australian .NET teams in this period ended up settling on as a workable daily rhythm.
| Attribute / call | Where it fits | Notes for daily use |
|---|---|---|
[TestFixture] |
Class declaration | Marks a class as a discoverable fixture; no base class required. |
[Test] |
Method declaration | One logical assertion per method reads more cleanly than several. |
[SetUp], [TearDown] |
Methods inside a fixture | Reset collaborators and dispose unmanaged resources per test. |
[TestCase] |
Method declaration | Parameterise the same logic across multiple inputs and expected outputs. |
Assert.That(actual, Is.*) |
Inside a test method | Constraint model produces meaningful failure messages automatically. |
[Ignore("reason")] |
Method declaration | Skips a test with a written justification; do not leave these around indefinitely. |
[ExpectedException] |
Method declaration | Use sparingly; type-specific assertions usually communicate more clearly. |