Unit Testing
By the end of this lesson you'll be able to write unit tests that pin down a method's behaviour , structure every test with Arrange-Act-Assert , name tests so failures read like sentences, and drive many cases from one method with [Theory] and [InlineData] — the safety net that lets you change code without fear.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
A car factory doesn't bolt a fuel pump into a car and then go for a drive to find out if it works. It clamps the pump onto a test bench , feeds it a known input, and measures the output — in isolation , away from the engine, the wiring, and the road. If the pump fails, you know instantly it's the pump, not the gearbox. A unit test is that bench: it takes one small piece of your code, feeds it a known input, and checks the output, with everything else stripped away. When a test goes red you know exactly which part broke — long before the whole car (your app) is on the motorway.
The three big .NET test frameworks all do the same job — mark a method as a test, run it, and assert on results. The vocabulary differs, but the ideas transfer one-to-one. This lesson uses xUnit (the modern default for new projects), with the NUnit and MSTest equivalents shown alongside so you can read any codebase.
One quirk to note: in xUnit's Assert.Equal the expected value comes first; NUnit and MSTest also put expected first in AreEqual . Getting the order right is what makes the failure message read correctly.
1. Why Test, and the Arrange-Act-Assert Shape
A unit test is automated code that runs one small piece of your program — a single method, the "unit" — with a known input and checks it produces the expected output. You write them so a computer re-verifies your logic in seconds every time you change something, instead of you clicking through the app by hand. Every good test follows the same three-part shape: Arrange the inputs, Act by calling the one method under test, then Assert the result is what you expect. Name the test Method_Scenario_Expected so a red result tells the whole story without opening the file. Read this worked example — it hand-rolls a tiny AssertEqual so you see exactly what an assertion does — then run it.
Your turn. The program below is almost complete — finish the Act and Assert lines so the test of Add passes. Fill in the two ___ blanks using the hints, then run it.
2. Testing Edge Cases
The "happy path" (normal inputs) is the easy half. Bugs hide in the edge cases — zero, empty, negative, the maximum, the boundary where behaviour changes. A good test suite has one test per interesting edge, each with its own clear name, so when an edge breaks you see precisely which one. Here the discount function must never return a negative price; your job is to test the edge where the discount is bigger than the price itself.
3. Real xUnit: [Fact] , [Theory] & Assertions
In a real project you don't hand-roll AssertEqual — a framework gives you it and a test runner. In xUnit, [Fact] marks a method as a single test with no inputs. [Theory] with one or more [InlineData(...)] rows runs the same test body once per row, so five cases live in one method instead of five copy-pasted ones. You run them all with dotnet test . The example below is exactly what you'd commit — read how each test maps onto the Arrange-Act-Assert shape you just practised.
4. Choosing the Right Assertion
An assertion is the line that decides pass or fail. Always reach for the most specific one: Assert.Equal(8, result) tells you "Expected 8, Actual 7" when it fails, whereas Assert.True(result == 8) only tells you "false" — useless for debugging. xUnit has a rich set: Equal / NotEqual , True / False , Null / NotNull , Contains , StartsWith , Throws<T> , and All for collections. Note floating-point: never assert exact equality on a double — pass a precision instead.
Tests must not depend on each other or on running in a particular order. If test B only passes because test A ran first and left some data behind, you have a flaky suite that fails randomly. xUnit defends you here: it creates a brand-new instance of the test class for every single test , so any fields you set in the constructor are fresh each time — there is no shared mutable state by default.
A handy checklist for a healthy test is the FIRST acronym: F ast (milliseconds, so you run them constantly), I solated (no dependence on order or other tests), R epeatable (same result every run, no clocks or random without control), S elf-validating (it asserts pass/fail, you don't eyeball output), and T imely (written alongside the code, not months later).
Code coverage measures the percentage of your code lines (or branches) that ran while the tests executed. You generate it with dotnet test --collect:"XPlat Code Coverage" and view the report to spot untested branches — especially the else paths and exception cases that are easy to forget.
The trap: coverage tells you which lines ran , not whether they were checked . A test with no asserts can hit 100% coverage and verify nothing. Treat coverage as a flashlight for finding untested code, never as a target to game. Aim for the important paths covered with meaningful asserts, not a vanity number.
The smallest piece you can test in isolation — usually a single method, sometimes a single class. If a test needs a real database, network, or file system to pass, it's an integration test, not a unit test.
Q: xUnit, NUnit, or MSTest — which should I use?
For a new project, xUnit is the modern default and what most .NET open source uses. NUnit is mature and feature-rich; MSTest ships with Visual Studio. The concepts are identical, so learning one teaches you all three — see the table above.
Q: How many tests do I need? Is 100% coverage the goal?
Cover the behaviours that matter: the happy path plus each meaningful edge (zero, empty, negative, boundary, error). 100% coverage with weak asserts is worse than 80% with strong ones — coverage finds untested code, it doesn't prove correctness.
No — test them indirectly through the public methods that call them. Private methods are implementation detail; testing them directly couples your tests to internals and breaks them on every refactor.
Q: Why did my test pass without checking anything?
A test only fails if an Assert fails or the code throws. If you forgot the assertion, the test "passes" by doing nothing useful. Every test needs at least one assertion that could fail.
No blanks this time — just a brief and an outline. Test the Classify function across several cases, printing PASS/FAIL for each and a final tally. This is the Arrange-Act-Assert loop a real runner does for you, written by hand so the mechanics are clear. Run it and check your output against the expected lines in the comments.
Practice quiz
What is a "unit" in unit testing?
- The whole application running end to end
- A database table
- The smallest piece you can test in isolation, usually a single method
- A deployment environment
Answer: The smallest piece you can test in isolation, usually a single method. A unit is the smallest testable piece — typically one method or class — tested in isolation from external systems.
What do the three steps of the Arrange-Act-Assert pattern do, in order?
- Set up the inputs, call the method under test, check the result
- Assert the result, act on it, arrange the inputs
- Mock everything, run it, log the output
- Compile, run, deploy
Answer: Set up the inputs, call the method under test, check the result. Arrange sets up the inputs, Act calls the one method under test, and Assert verifies the result is what you expect.
In xUnit, which attribute marks a single test method with no inputs?
[Fact] marks one test with no parameters in xUnit. NUnit uses [Test] and MSTest uses [TestMethod].
Which xUnit combination runs the same test body once per row of data?
[Theory] paired with one or more [InlineData(...)] rows runs the same test logic once per row, avoiding copy-paste.
In xUnit's Assert.Equal(expected, actual), which argument comes first?
- The expected value
- The actual value
- The test name
- It does not matter for the message
Answer: The expected value. The expected value comes first in xUnit's Assert.Equal. Getting the order right makes the failure message read correctly.
Why prefer Assert.Equal(8, result) over Assert.True(result == 8)?
- It runs faster
- Assert.True is deprecated
- Assert.Equal gives a clearer failure message showing expected vs actual
- They behave identically in every way
Answer: Assert.Equal gives a clearer failure message showing expected vs actual. Assert.Equal reports "Expected: 8, Actual: 7" on failure, whereas Assert.True only reports "false", which is useless for debugging.
How does xUnit keep tests isolated from each other by default?
- It runs tests alphabetically
- It creates a brand-new instance of the test class for every test
- It shares one static instance across all tests
- It disables all fields
Answer: It creates a brand-new instance of the test class for every test. xUnit constructs a fresh instance of the test class per test, so constructor-initialised fields are reset and there is no shared mutable state by default.
Which xUnit assertion verifies that calling a method throws a specific exception?
- Assert.Equal
- Assert.NotNull
- Assert.Contains
- Assert.Throws<T>
Answer: Assert.Throws<T>. Assert.Throws<T>(() => ...) asserts the delegate throws exception type T and returns the exception so you can inspect its message.
Why should you avoid Assert.Equal(0.3, 0.1 + 0.2) on doubles?
- Doubles cannot be compared at all
- Binary floating-point rounding makes 0.1 + 0.2 not exactly 0.3
- 0.3 is not a valid double
- Assert.Equal does not accept doubles
Answer: Binary floating-point rounding makes 0.1 + 0.2 not exactly 0.3. 0.1 + 0.2 is 0.30000000000000004 in binary floating point, so exact equality fails. Use the overload with a precision argument instead.
What does the FIRST acronym describe for healthy unit tests?
- Fixed, Indexed, Reusable, Static, Threaded
- Fast, Integrated, Random, Slow, Typed
- Fast, Isolated, Repeatable, Self-validating, Timely
- Functional, Internal, Remote, Shared, Tested
Answer: Fast, Isolated, Repeatable, Self-validating, Timely. FIRST stands for Fast, Isolated, Repeatable, Self-validating, and Timely — the qualities of a good unit test.