Skip to main content

05.1 - Unit Testing with xUnit

Recap

In 04.2 - Files & Persistence you put the game state behind IGameRepository and IConfigRepository and wrote the JSON implementation for A3. Your engine (GameBrain) has rules, move validation and win detection from A2. So far you have verified all of that by playing the game in the console — which is slow, boring and forgets everything the next time you change a line. This lecture replaces that with a Tests project that runs in seconds and is part of every defense from D1 on.

By the end of this lecture you should be able to:

  • Add an xUnit test project to the solution and reference GameEngine and DAL.Json from it.
  • Write [Fact] and [Theory] tests in Arrange-Act-Assert form with descriptive names.
  • Build a board from a text picture and test win detection, invalid moves and draws — including your custom-rule extensions.
  • Write a JSON save/load round-trip test for A3.
  • Run all tests, one class or one test from the CLI and from Rider, and explain what you deliberately do not test.
Demo code

Lecture demos: csharp-2026-fall

Why test

  • Until you execute a line of code, you don't know whether that line works at all.
  • Your solution grows every assignment (A2 → A6). Without tests, every change to GameBrain is a chance to silently break win detection — and you will only notice during the defense.
  • A failing test tells you where it broke. A wrong result on screen tells you only that it broke.
  • The rubric: game-rule tests from A2, round-trip tests in A3, contract tests in A4, AI tests in A5. Testing is a dimension at every defense, and the TA will ask you to explain what your tests cover and why.

Testing is kept deliberately light in this course: test what breaks. Win detection in every direction, each custom rule, move validation, draw, save/load. Not the menu, not the console rendering.

The test project

One project, Tests, at the solution root next to the others.

dotnet new xunit -n Tests
dotnet sln add Tests
dotnet add Tests reference GameEngine
dotnet add Tests reference DAL.Json

The generated Tests.csproj already contains the xUnit packages, the test SDK and a coverage collector. Keep whatever the template produced (package names differ slightly between SDK versions — the attributes and Assert API used below are the same). What you add are the project references and the global using:

<Project Sdk="Microsoft.NET.Sdk">

<!-- PropertyGroup (net10.0, Nullable, ImplicitUsings, IsPackable=false) as generated -->
<!-- package references as generated by the template: xunit, xunit.runner.visualstudio,
Microsoft.NET.Test.Sdk, coverlet.collector -->

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\GameEngine\GameEngine.csproj" />
<ProjectReference Include="..\DAL.Json\DAL.Json.csproj" />
</ItemGroup>

</Project>

Delete the generated UnitTest1.cs. Directory.Build.props at the solution root already turns nullable warnings into errors for this project too — good, tests are code.

Engine API used in this lecture

The samples assume this shape; adapt the names to your engine.

public enum EGamePiece { Empty, X, O }

public record GameConfiguration(string Name, int BoardWidth, int BoardHeight, int WinLength,
bool IsCylinder = false); // IsCylinder is the Connect Four extension — yours will differ

public class GameState
{
public static GameState NewGame(GameConfiguration config); // fresh id, empty board, X to move
// Id, Board, NextMoveBy, Config, Moves, CreatedAtUtc — see lecture 04.2
}

public class GameBrain
{
public GameBrain(GameState state) { /* ... */ }
public GameState State { get; }
public List<(int Row, int Col)> GetLegalMoves();
public void MakeMove(int row, int col); // throws InvalidMoveException
public EGamePiece CheckWin(); // Empty = nobody has won yet
public bool IsDraw();
}

Anatomy of a test

A unit test is a public method in a public class, marked with [Fact]. Three phases — Arrange, Act, Assert:

namespace Tests;

public class GameBrainTests
{
[Fact]
public void MakeMove_OnEmptyCell_SwitchesPlayer()
{
// Arrange
var config = new GameConfiguration("Test 3x3", 3, 3, 3);
var brain = new GameBrain(GameState.NewGame(config));

// Act
brain.MakeMove(1, 1);

// Assert
Assert.Equal(EGamePiece.X, brain.State.Board[1][1]);
Assert.Equal(EGamePiece.O, brain.State.NextMoveBy);
Assert.Single(brain.State.Moves);
}
}

xUnit creates a new instance of the test class for every test method. The constructor is your shared Arrange step, IDisposable.Dispose your cleanup; tests never see each other's state.

Naming

Method_Scenario_ExpectedResult
  • CheckWin_FourInRow_ReturnsX
  • MakeMove_OnOccupiedCell_ThrowsInvalidMoveException
  • GetLegalMoves_OnFullBoard_ReturnsEmpty
  • Save_ThenGet_ReturnsEqualState

A failing test name should tell you what broke before you read a line of code. Spaces are not allowed, underscores are fine, length is not a problem.

What makes a good test

  • Deterministic — no Random, no DateTime.Now, no dependency on the order tests run in.
  • Isolated — one behaviour per test, no shared mutable state between tests.
  • Fast — milliseconds. Your whole suite should run in a couple of seconds.
  • Readable — the test is documentation of your rules. Someone reading CheckWin_FourInRow_ReturnsX learns your game.

Assert

The Assert class covers almost everything you need:

AssertionUse
Assert.Equal(expected, actual)values, records, collections (element by element)
Assert.NotEqual, Assert.Same, Assert.NotSamevalue vs reference identity
Assert.True(cond), Assert.False(cond)booleans — add a message: Assert.True(x, "why")
Assert.Null, Assert.NotNullnullable results
Assert.Contains(item, collection), Assert.DoesNotContainmembership; also Assert.Contains("sub", "string")
Assert.Empty, Assert.Single, Assert.All(collection, item => ...)collection shape
Assert.InRange(value, low, high)numeric bounds
Assert.Throws<TException>(() => ...)the exact exception type; ThrowsAny allows derived types

Order matters: it is Assert.Equal(expected, actual). Swap them and the failure message lies to you.

[Fact]
public void MakeMove_OnOccupiedCell_ThrowsInvalidMoveException()
{
var brain = new GameBrain(GameState.NewGame(new GameConfiguration("T", 3, 3, 3)));
brain.MakeMove(0, 0);

var ex = Assert.Throws<InvalidMoveException>(() => brain.MakeMove(0, 0));

Assert.Equal(0, ex.Row);
Assert.Equal(0, ex.Col);
}

There is no "assert that nothing was thrown" — a test that reaches its end without an exception has passed. If your MakeMove returns a result object instead of throwing (both designs are discussed in 05.2 - Exceptions, Debugging, Code Quality), assert on result.IsValid instead.

Theory: one test, many inputs

[Fact] tests one invariant. [Theory] runs the same method once per data row.

[Theory]
[InlineData(3, 3, 3)]
[InlineData(7, 6, 4)]
[InlineData(9, 7, 5)]
public void NewGame_HasWidthTimesHeightLegalMoves(int width, int height, int winLength)
{
var brain = new GameBrain(GameState.NewGame(new GameConfiguration("T", width, height, winLength)));

Assert.Equal(width * height, brain.GetLegalMoves().Count);
}

[InlineData] takes compile-time constants only. For anything richer — objects, records, lists — use [MemberData] pointing at a static property or method that yields object[] rows:

public static IEnumerable<object[]> InvalidConfigurations =>
[
[new GameConfiguration("", 3, 3, 3)], // empty name
[new GameConfiguration("Tiny", 2, 2, 3)], // win length longer than board
[new GameConfiguration("Neg", -1, 5, 3)], // negative width
];

[Theory]
[MemberData(nameof(InvalidConfigurations))]
public void Validate_InvalidConfiguration_ReturnsErrors(GameConfiguration config)
{
var errors = GameConfigurationValidator.Validate(config);

Assert.NotEmpty(errors);
}

Each row shows up as a separate test in the runner, so you see exactly which configuration failed.

Building a board from a picture

Typing board[2][1] = EGamePiece.X twelve times per test hides the scenario. A tiny helper that reads a text picture makes every board test readable at a glance:

namespace Tests;

public static class TestBoards
{
/// Rows separated by newline, 'X' / 'O' pieces, anything else is empty.
public static GameState FromPicture(string picture, GameConfiguration config,
EGamePiece nextMoveBy = EGamePiece.X)
{
var rows = picture.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

var board = new EGamePiece[rows.Length][];
for (var r = 0; r < rows.Length; r++)
{
board[r] = rows[r].Select(c => c switch
{
'X' => EGamePiece.X,
'O' => EGamePiece.O,
_ => EGamePiece.Empty
}).ToArray();
}

var state = GameState.NewGame(config);
state.Board = board;
state.NextMoveBy = nextMoveBy;
return state;
}
}

Now a whole scenario fits on one line: "X..\n.X.\n..X".

Win detection in all four directions

public class WinDetectionTests
{
private static readonly GameConfiguration Cfg = new("Test 3x3", 3, 3, 3);

[Theory]
[InlineData("XXX\n...\n...", "horizontal")]
[InlineData("X..\nX..\nX..", "vertical")]
[InlineData("X..\n.X.\n..X", "diagonal")]
[InlineData("..X\n.X.\nX..", "anti-diagonal")]
public void CheckWin_ThreeInLine_ReturnsX(string picture, string direction)
{
var brain = new GameBrain(TestBoards.FromPicture(picture, Cfg));

Assert.True(brain.CheckWin() == EGamePiece.X, $"{direction} line was not detected");
}

[Theory]
[InlineData("XX.\n...\n...")] // too short
[InlineData("XXO\n...\n...")] // interrupted
[InlineData("XX.\n.X.\n..O")] // not a line
public void CheckWin_NoLine_ReturnsEmpty(string picture)
{
var brain = new GameBrain(TestBoards.FromPicture(picture, Cfg));

Assert.Equal(EGamePiece.Empty, brain.CheckWin());
}

[Fact]
public void CheckWin_LineAtBottomRightEdge_ReturnsO()
{
// edges are where off-by-one errors live
var brain = new GameBrain(TestBoards.FromPicture("...\n...\nOOO", Cfg));

Assert.Equal(EGamePiece.O, brain.CheckWin());
}
}

Note the last test: always test the edges — last row, last column, a line that ends exactly at the border. A < instead of <= in a loop is invisible on a 7×6 board until the winning line touches the wall.

Custom-rule extensions

Every mandatory extension gets its own tests. The Connect Four cylinder rule, for example — a line that wraps around the side edge counts only when the configuration says so:

[Theory]
[InlineData(true, EGamePiece.X)] // cylinder: cols 4,0,1 are neighbours
[InlineData(false, EGamePiece.Empty)] // rectangle: they are not
public void CheckWin_LineAcrossSideEdge_DependsOnCylinder(bool isCylinder, EGamePiece expected)
{
var cfg = new GameConfiguration("Wrap", BoardWidth: 5, BoardHeight: 2, WinLength: 3, IsCylinder: isCylinder);
var brain = new GameBrain(TestBoards.FromPicture(".....\nXX..X", cfg));

Assert.Equal(expected, brain.CheckWin());
}

Same idea for the others: Gomoku's overline toggle (XXXXXX is a win in free-style, not in standard), Tic-Tac-Two's unlock threshold (grid move is illegal before N pieces), Reversi's walls (flanking cannot pass through a blocked cell), Morris's flying threshold. One test per rule, one per "the rule is off".

[Fact]
public void GetLegalMoves_NeverContainsOccupiedCells()
{
var state = TestBoards.FromPicture("X.O\n.X.\nO..", new GameConfiguration("T", 3, 3, 3));
var brain = new GameBrain(state);

var moves = brain.GetLegalMoves();

Assert.Equal(5, moves.Count);
Assert.All(moves, m => Assert.Equal(EGamePiece.Empty, state.Board[m.Row][m.Col]));
Assert.DoesNotContain((1, 1), moves);
}

[Theory]
[InlineData(-1, 0)]
[InlineData(0, 3)]
public void MakeMove_OutsideBoard_Throws(int row, int col)
{
var brain = new GameBrain(GameState.NewGame(new GameConfiguration("T", 3, 3, 3)));

Assert.Throws<InvalidMoveException>(() => brain.MakeMove(row, col));
}

Draw

[Fact]
public void IsDraw_FullBoardWithoutWinner_ReturnsTrue()
{
var brain = new GameBrain(TestBoards.FromPicture("XOX\nXOO\nOXX", new GameConfiguration("T", 3, 3, 3)));

Assert.Equal(EGamePiece.Empty, brain.CheckWin());
Assert.True(brain.IsDraw());
Assert.Empty(brain.GetLegalMoves());
}

Games with passes (Reversi) or capture phases (Morris) have their own definition of draw and "no legal move" — write the test that matches your rules.

JSON round-trip test for A3

The repository test is the one most likely to catch a real bug: enums serialized as numbers, a jagged array that came back as null, a List<(int Row, int Col)> that silently serialized to empty objects because System.Text.Json ignores tuple fields unless IncludeFields = true. Give the repository a folder in its constructor — never hardcode the user home inside the class — and point it at a temporary directory in tests:

public class JsonGameRepositoryTests : IDisposable
{
private readonly string _dir =
Path.Combine(Path.GetTempPath(), "icd0008-tests", Guid.NewGuid().ToString("N"));

private readonly JsonGameRepository _repo;

public JsonGameRepositoryTests()
{
_repo = new JsonGameRepository(_dir);
}

[Fact]
public void Save_ThenGet_ReturnsEqualState()
{
var config = new GameConfiguration("Round trip", 3, 3, 3);
var state = TestBoards.FromPicture("X..\n.O.\n...", config, nextMoveBy: EGamePiece.X);
state.Moves.Add((0, 0));
state.Moves.Add((1, 1));

_repo.Save(state);
var loaded = _repo.Get(state.Id);

Assert.Equal(state.Id, loaded.Id);
Assert.Equal(state.Config, loaded.Config); // record equality
Assert.Equal(state.NextMoveBy, loaded.NextMoveBy);
Assert.Equal(state.Moves, loaded.Moves);
Assert.Equal(state.Board, loaded.Board); // nested collections compare element by element
}

[Fact]
public void Get_UnknownId_Throws()
{
Assert.ThrowsAny<Exception>(() => _repo.Get(Guid.NewGuid()));
}

public void Dispose()
{
if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true);
}
}

Add List (two saves → two entries) and Delete (save, delete, Get throws) in the same style. These four tests — save/get, list, delete, unknown id — run unchanged against the EF Core repository in A4. That is the "contract test" the syllabus talks about; you are writing it now.

Running tests

CLI

dotnet test                                                   # whole solution
dotnet test --filter "FullyQualifiedName~WinDetectionTests" # one class
dotnet test --filter "Name=CheckWin_ThreeInLine_ReturnsX" # one method (all theory rows)
dotnet test --logger "console;verbosity=normal" # see each test name

dotnet test must pass from a fresh clone of your repository — the TA will run exactly this.

Rider

  • Unit Tests tool window (View → Tool Windows → Unit Tests) shows the whole tree; run, debug or re-run failed ones from there.
  • Gutter icon next to a [Fact] / [Theory] / class → run or debug just that one. Debugging a single test is the fastest way into a rule bug — see the next lecture.
  • Right-click the solution → Run Unit Tests.
  • A red test shows expected vs actual side by side; double-click jumps to the assertion.

Code coverage

The template's coverlet.collector package lets dotnet test --collect:"XPlat Code Coverage" write a Cobertura XML file under TestResults; Rider's Unit Tests window has a Cover button that paints covered lines green in the editor. Use coverage to find untested rules, not to chase a percentage. 100 % coverage of GameBrain with zero tests on the cylinder rule is worth nothing.

What NOT to test

  • The menu library rendering and the console UI. Console.ReadLine loops are not unit-testable in a sane way, and the menu is demonstrated live at the defense anyway.
  • .NET itselfList<T>.Add works, JsonSerializer works. Test your options and your mapping, not the framework.
  • Private methods directly. Test through the public API; if a private method is so complex that it needs its own tests, it wants to be its own class.
  • Trivial properties — a getter/setter test is noise.
  • Randomness and time — if the AI picks a random move among equals, test "the chosen move is one of the winning moves", not "the chosen move is (2, 3)".
Where this goes next

Integration tests (a real web app in memory), mocking libraries (Moq, NSubstitute), assertion libraries (FluentAssertions / Shouldly) and browser tests with Playwright belong to the Web Applications with C# course next semester. In this course plain xUnit against your class libraries is all that is required.

At D1 the TA expects: win detection in every direction and at the edges, one test per extension (on and off), invalid moves, draw, JSON round-trips for state and configuration — and that you can answer "what does this test protect against?" for any of them. The full D1 checklist is in 05.2 - Exceptions, Debugging, Code Quality.

Self preparation QA

Be prepared to explain topics like these:

  1. What are the three phases of a unit test? — Arrange (build the objects), Act (call the one method under test), Assert (check the result); keep exactly one Act per test.
  2. What is the difference between [Fact] and [Theory]?[Fact] is a parameterless test of one invariant; [Theory] runs the same method for every [InlineData] / [MemberData] row and reports each row separately.
  3. Why does xUnit create a new instance of the test class for every test method? — Isolation: the constructor is the Arrange step and no test can leak state into another, so tests can run in any order or in parallel.
  4. Why test the last row and the last column explicitly? — Off-by-one errors in loop bounds only show up when a line touches the board edge; tests in the middle of the board pass with buggy code.
  5. How do you test that an invalid move is rejected?Assert.Throws<InvalidMoveException>(() => brain.MakeMove(r, c)) if the engine throws, or assert on the returned MoveResult if it returns one; additionally check that GetLegalMoves() does not contain the cell.
  6. What does a JSON round-trip test protect against? — Serialization options drifting: enums as numbers, tuples serialized as empty objects, jagged arrays coming back null, ids or timestamps changing between save and load.
  7. Why should the repository take its folder as a constructor parameter? — So tests can point it at a temporary directory and clean up in Dispose, instead of writing into the real user home folder.
  8. What do you deliberately not unit test in this course, and why? — Console UI and the menu (not unit-testable, demonstrated live), framework code, private methods and trivial properties; the value is in rules, validation and persistence.