Migrate the test suite from xUnit + FluentAssertions to NUnit

All 44 tests, same names, same coverage — verified by diffing the runner's test
list before and after.

The load-bearing change is fixture lifecycle. xUnit builds a new test-class
instance per test, so a `readonly TestDatabase _db = new()` field gave every
test its own database. NUnit reuses one instance for the whole class, so those
field initialisers and constructors would have shared a single database across a
class and let tests read each other's rows. Setup moved into [SetUp]/[TearDown]
via a new ServiceTestFixture base class, which also collapses the per-class
service wiring that was duplicated five times.

Assertions are now Assert.That with the constraint model:

  Should().Be(x)              -> Is.EqualTo(x)
  Should().BeNull()           -> Is.Null
  Should().HaveCount(n)       -> Has.Count.EqualTo(n)
  Should().Equal(a, b)        -> Is.EqualTo(new[] { a, b })
  Should().BeEquivalentTo(..) -> Is.EquivalentTo(..)
  Should().Contain("x")       -> Does.Contain("x")
  Should().OnlyHaveUniqueItems() -> Is.Unique
  ThrowAsync<T>().WithMessage("*m*")
      -> Throws.TypeOf<T>().With.Message.Contains("m")

FluentAssertions' `.Which` chains became plain indexed asserts, grouped in
Assert.Multiple so a failure reports every broken expectation in the case rather
than stopping at the first.

Because a framework migration can quietly produce vacuously-passing tests,
spot-checked four conversions by mutation — breaking the code under an async
Assert.Multiple block, a sync one, a Throws constraint, and a collection
ordering assert. All four failed as they should, confirming the assertions are
live and that NUnit bound the async lambdas to AsyncTestDelegate rather than
silently accepting them as async void.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 8394843255
commit 30e0c6926e
9 changed files with 510 additions and 434 deletions
+67 -73
View File
@@ -1,9 +1,8 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NovelSoftware.Application.Agent;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
using NovelSoftware.Domain;
namespace NovelSoftware.Tests;
@@ -12,94 +11,91 @@ namespace NovelSoftware.Tests;
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
/// </summary>
public class ListingTests : IDisposable
[TestFixture]
public class ListingTests : ServiceTestFixture
{
private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects;
private readonly ChapterService _chapters;
private readonly SceneService _scenes;
private readonly CharacterService _characters;
public ListingTests()
{
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context);
_chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
}
[Fact]
[Test]
public async Task Projects_are_listed_most_recently_updated_first()
{
var older = await _projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await _projects.CreateAsync(new CreateProjectRequest("Newer Book"));
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
// Touching the older project should float it to the top.
await _projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
var listed = await _projects.ListAsync();
var listed = await Projects.ListAsync();
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book");
listed.Select(p => p.Id).Should().Contain(newer.Id);
Assert.Multiple(() =>
{
Assert.That(listed.Select(p => p.Title), Is.EqualTo(new[] { "Older Book", "Newer Book" }));
Assert.That(listed.Select(p => p.Id), Does.Contain(newer.Id));
});
}
[Fact]
[Test]
public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
{
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
await _scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var summary = (await _projects.ListAsync()).Single();
var summary = (await Projects.ListAsync()).Single();
summary.CharacterCount.Should().Be(2);
summary.ChapterCount.Should().Be(2);
summary.WordCount.Should().Be(5);
Assert.Multiple(() =>
{
Assert.That(summary.CharacterCount, Is.EqualTo(2));
Assert.That(summary.ChapterCount, Is.EqualTo(2));
Assert.That(summary.WordCount, Is.EqualTo(5));
});
}
[Fact]
[Test]
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
{
await _projects.CreateAsync(new CreateProjectRequest("Empty"));
await Projects.CreateAsync(new CreateProjectRequest("Empty"));
var summary = (await _projects.ListAsync()).Single();
var summary = (await Projects.ListAsync()).Single();
summary.WordCount.Should().Be(0);
summary.ChapterCount.Should().Be(0);
Assert.Multiple(() =>
{
Assert.That(summary.WordCount, Is.EqualTo(0));
Assert.That(summary.ChapterCount, Is.EqualTo(0));
});
}
[Fact]
[Test]
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
{
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var second = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
var first = await _chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
await _scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var listed = await _chapters.ListAsync(project.Id);
var listed = await Chapters.ListAsync(project.Id);
listed.Select(c => c.Title).Should().Equal("First", "Second");
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2);
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3);
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0);
Assert.Multiple(() =>
{
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
});
}
[Fact]
[Test]
public async Task Conversations_are_listed_most_recently_updated_first()
{
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var agent = new NovelAgentService(
_db.Context,
Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(_projects, _characters, _chapters, new BeatService(_db.Context, _tags), _scenes, _tags),
new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags),
Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance);
@@ -108,26 +104,24 @@ public class ListingTests : IDisposable
var listed = await agent.ListConversationsAsync(project.Id);
listed.Should().HaveCount(2);
listed[0].Title.Should().Be("Second question.");
listed[0].MessageCount.Should().Be(2);
Assert.Multiple(() =>
{
Assert.That(listed, Has.Count.EqualTo(2));
Assert.That(listed[0].Title, Is.EqualTo("Second question."));
Assert.That(listed[0].MessageCount, Is.EqualTo(2));
});
}
[Fact]
[Test]
public async Task Characters_are_listed_by_role_then_name()
{
var project = await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
"Zeno", NovelSoftware.Domain.CharacterRole.Supporting));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting));
var listed = await _characters.ListAsync(project.Id);
var listed = await Characters.ListAsync(project.Id);
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno");
Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" }));
}
public void Dispose() => _db.Dispose();
}