Unit Testing with Moq
Testing a service in isolation by mocking every repository it depends on (see Repository Pattern) — xUnit for the test runner, Moq for the fakes. The service under test never touches a real database.
Test class shape: one mock field per dependency
Every constructor dependency gets a Mock<T> field, created in the test class's own constructor (xUnit calls it fresh before every test, so there's no shared state to reset between tests). The system under test is built once from .Object on each mock.
public class EloServiceTests
{
private readonly Mock<ICategoryRepository> _categoryRepositoryMock;
private readonly Mock<IRatingRepository> _ratingRepositoryMock;
private readonly Mock<IMatchRepository> _matchRepositoryMock;
private readonly Mock<IMatchParticipantRepository> _participantRepositoryMock;
private readonly Mock<IUserRepository> _userRepositoryMock;
private readonly LeaderboardService _leaderboardService;
private readonly EloService _sut; // "system under test"
public EloServiceTests()
{
_categoryRepositoryMock = new Mock<ICategoryRepository>();
_ratingRepositoryMock = new Mock<IRatingRepository>();
_matchRepositoryMock = new Mock<IMatchRepository>();
_participantRepositoryMock = new Mock<IMatchParticipantRepository>();
_userRepositoryMock = new Mock<IUserRepository>();
// LeaderboardService (see Elo Rating System) has no interface and is a
// lightweight in-memory cache, so a real instance is used rather than a mock —
// nothing to fake here.
_leaderboardService = new LeaderboardService();
_sut = new EloService(
_categoryRepositoryMock.Object,
_ratingRepositoryMock.Object,
_matchRepositoryMock.Object,
_participantRepositoryMock.Object,
_userRepositoryMock.Object,
_leaderboardService);
}
private static Rating CreateRating(Guid userId, Guid categoryId, double currentRating, int matchCount)
=> new() { UserId = userId, CategoryId = categoryId, CurrentRating = currentRating, MatchCount = matchCount, PeakRating = currentRating };
}
That comment matters as a rule, not just a note: mock the things that would otherwise hit a database, the network, or the clock — not every collaborator on principle. A pure, dependency-free helper class is cheaper and more honest to use for real. A small private factory method (CreateRating above) for the awkward-to-construct types keeps every test's setup short and focused on what actually varies between tests.
Naming convention: MethodName_Scenario_ExpectedResult
A failing test name alone should tell you what broke without opening the file — RecordMatchAsync_CategoryNotFound_ThrowsInvalidOperationException says exactly what was called, under what condition, and what should've happened.
Arrange / Act / Assert, separated by blank lines
No // Arrange / // Act / // Assert comments needed if the three blocks are visually separated — sets up mocks and data, calls the method under test once, then asserts.
[Fact]
public async Task RecordMatchAsync_IndividualMatch_UpdatesBothPlayersAndReturnsResults()
{
var category = new Category { Id = Guid.NewGuid(), Name = "Chess" };
var userA = new User { Id = Guid.NewGuid(), FullName = "Alice" };
var userB = new User { Id = Guid.NewGuid(), FullName = "Bob" };
var ratingA = CreateRating(userA.Id, category.Id, 1200, 5);
var ratingB = CreateRating(userB.Id, category.Id, 1200, 5);
var request = new RecordMatchRequest
{
CategoryId = category.Id,
MatchType = "Individual",
Participants = new List<MatchParticipantInput>
{
new() { UserId = userA.Id, Placement = 1 },
new() { UserId = userB.Id, Placement = 2 },
},
};
_categoryRepositoryMock.Setup(repo => repo.GetByIdAsync(category.Id)).ReturnsAsync(category);
_ratingRepositoryMock.Setup(repo => repo.GetOrCreateRatingAsync(userA.Id, category.Id)).ReturnsAsync(ratingA);
_ratingRepositoryMock.Setup(repo => repo.GetOrCreateRatingAsync(userB.Id, category.Id)).ReturnsAsync(ratingB);
_userRepositoryMock.Setup(repo => repo.GetByIdAsync(userA.Id)).ReturnsAsync(userA);
_userRepositoryMock.Setup(repo => repo.GetByIdAsync(userB.Id)).ReturnsAsync(userB);
var result = await _sut.RecordMatchAsync(request);
Assert.Equal(2, result.Results.Count);
Assert.True(result.Results.Single(r => r.UserId == userA.Id).RatingChange > 0);
Assert.True(result.Results.Single(r => r.UserId == userB.Id).RatingChange < 0);
_ratingRepositoryMock.Verify(repo => repo.UpdateAsync(It.IsAny<Rating>()), Times.Exactly(2));
_matchRepositoryMock.Verify(repo => repo.UpdateAsync(It.Is<Match>(m => m.IsFinalized)), Times.Once);
}
Setting up return values — including "not found"
// happy path
_categoryRepositoryMock.Setup(repo => repo.GetByIdAsync(category.Id)).ReturnsAsync(category);
// not-found path — return null explicitly rather than leaving it unset.
// an unset mock method returns null anyway, but setting it up on purpose
// documents that "not found" is the scenario this test is exercising.
_categoryRepositoryMock.Setup(repo => repo.GetByIdAsync(missingId)).ReturnsAsync((Category?)null);
Verify — did the right thing get called, the right number of times?
It.IsAny<T>() when you don't care about the argument's exact value, It.Is<T>(predicate) when you do. Times.Exactly(n) / Times.Once / Times.Never catch bugs a return-value assertion alone would miss — a team match with 4 participants should update exactly 4 ratings, not 2 or 8.
[Fact]
public async Task RecordMatchAsync_TeamMatch_UpdatesEveryMemberOnBothTeams()
{
// ... arrange two teams of two players each (see Elo Rating System for ProcessTeamMatchAsync) ...
var result = await _sut.RecordMatchAsync(request);
Assert.Equal(4, result.Results.Count);
Assert.All(result.Results.Where(r => r.TeamId == teamOneId), r => Assert.True(r.RatingChange > 0));
Assert.All(result.Results.Where(r => r.TeamId == teamTwoId), r => Assert.True(r.RatingChange < 0));
_ratingRepositoryMock.Verify(repo => repo.UpdateAsync(It.IsAny<Rating>()), Times.Exactly(4));
_participantRepositoryMock.Verify(repo => repo.AddAsync(It.IsAny<MatchParticipant>()), Times.Exactly(4));
}
Callback — capturing what was actually passed
When a plain Verify predicate gets awkward (checking several fields at once, or asserting on something the return value doesn't expose), capture the argument into a local variable and assert on it directly afterward.
[Fact]
public async Task CreateCategoryAsync_ValidRequest_Persists()
{
var request = new CreateCategoryRequest { Name = "Chess", DisplayOrder = 1 };
Category? captured = null;
_categoryRepositoryMock
.Setup(repo => repo.AddAsync(It.IsAny<Category>()))
.Callback<Category>(c => captured = c);
await _sut.CreateCategoryAsync(request);
Assert.NotNull(captured);
Assert.Equal("Chess", captured!.Name);
Assert.True(captured.IsActive); // asserts a default the request itself never set
_categoryRepositoryMock.Verify(repo => repo.AddAsync(It.Is<Category>(c => c.Name == "Chess")), Times.Once);
}
Testing that the right exception is thrown
[Fact]
public async Task RecordMatchAsync_CategoryNotFound_ThrowsInvalidOperationException()
{
var request = new RecordMatchRequest { CategoryId = Guid.NewGuid(), Participants = [] };
_categoryRepositoryMock
.Setup(repo => repo.GetByIdAsync(request.CategoryId))
.ReturnsAsync((Category?)null);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.RecordMatchAsync(request));
}
[Fact]
public async Task RecordMatchAsync_TeamMatchWithOneTeam_ThrowsInvalidOperationException()
{
// a team match needs at least 2 teams to have anyone to play against —
// this guards the same invariant RecordMatchAsync's team path checks
var request = new RecordMatchRequest
{
CategoryId = Guid.NewGuid(),
MatchType = "team",
Participants = new List<MatchParticipantInput>
{
new() { UserId = Guid.NewGuid(), Placement = 1, TeamId = Guid.NewGuid() },
},
};
_categoryRepositoryMock.Setup(repo => repo.GetByIdAsync(request.CategoryId))
.ReturnsAsync(new Category { Id = request.CategoryId });
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.RecordMatchAsync(request));
}
Bonus: [Theory] for pure functions
A stateless calculation (no repositories, nothing to mock at all) doesn't need [Fact] per case — [Theory] with [InlineData] runs the same assertion against a table of inputs, so adding a new edge case is one line instead of a whole new test method. This is exactly the shape for testing the EloCalculationService methods — GetKFactor's tier boundaries, score validity ranges, and so on.
[Theory]
[InlineData(0.0, true)]
[InlineData(0.5, true)]
[InlineData(1.0, true)]
[InlineData(-0.1, false)]
[InlineData(1.1, false)]
public void IsValidActualScore_VariousValues_ReturnsExpected(double score, bool expected)
{
var result = EloCalculationService.IsValidActualScore(score);
Assert.Equal(expected, result);
}
// boundary cases get their own [Fact]s even next to a [Theory] on the same
// method — GetKFactor's tier cutoffs (9 vs 10, 29 vs 30) are exactly where
// an off-by-one bug would hide, so each boundary is named explicitly
[Fact]
public void GetKFactor_BoundaryAtNine_Returns40()
{
var result = EloCalculationService.GetKFactor(9);
Assert.Equal(40, result);
}
[Fact]
public void GetKFactor_BoundaryAtTen_Returns20()
{
var result = EloCalculationService.GetKFactor(10);
Assert.Equal(20, result);
}
[Fact]
public void CalculateNewRating_ExceedsMax_ClampedToMax()
{
var result = EloCalculationService.CalculateNewRating(1995, 1.0, 0.0, matchCount: 5, maxRating: 2000);
Assert.Equal(2000, result);
}
Rule of thumb for which attribute to reach for:[Fact]when the scenario needs its own realistic setup (mocked repositories, a request object with several fields) or tests a specific named boundary;[Theory]when you're really testing the same input→output relationship across a range of values — valid/invalid ranges, edge cases at zero or the max. Mixing them in the same file, as above, is normal — a service usually has both kinds of logic.