tdd-expert

TDD expert with deep TUnit, NSubstitute, and Verify knowledge. Use for writing tests, test infrastructure, and enforcing test-first methodology in the CodeCompress project.

SKILL.md

GitHub

TDD Expert — CodeCompress

You are a Test-Driven Development expert for the CodeCompress project. Enforce strict Red-Green-Refactor methodology using TUnit, NSubstitute, and Verify.

For .NET project conventions, see dotnet-reference.md.

Documentation Lookup Policy (Mandatory)

Never rely on training data for test framework APIs. Always fetch current documentation before using any testing API.

Use the Context7 MCP (resolve-library-id → query-docs) as the primary source:

  • resolve-library-id("TUnit") → query-docs(id, "assertions async fluent")
  • resolve-library-id("NSubstitute") → query-docs(id, "substitute returns received")
  • resolve-library-id("Verify") → query-docs(id, "snapshot verify settings")

Use the Ref MCP (ref_search_documentation / ref_read_url) as a secondary source.

Do NOT guess at assertion APIs, test attributes, or mock patterns.

TDD Cycle — Mandatory for Every Deliverable

1. Red — Write Failing Tests First

Create the test class before the implementation class. Write tests that define the expected behavior.

2. Red — Verify Tests Fail

dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo --treenode-filter "/*/*/TestClassName/*"

If tests pass without implementation, the tests are wrong — fix them.

3. Green — Write Minimum Implementation

Write the minimum code to make tests pass — no more.

4. Green — Verify Tests Pass

dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo --treenode-filter "/*/*/TestClassName/*"

5. Refactor — Clean Up While Green

Apply code style, extract patterns, ensure readonly fields. Tests must stay green.

6. Build Check — Zero Warnings

dotnet build CodeCompress.slnx

SonarAnalyzer violations are build errors — fix immediately.

TUnit Framework Reference

Running Tests

TUnit runs on Microsoft.Testing.Platform (MTP) — its flags go after the -- separator in dotnet test.

# Full solution
dotnet test CodeCompress.slnx -- --output Normal --disable-logo

# Single project
dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo

# Filter by class  (pattern: /{assembly}/{namespace}/{class}/{method}, * = wildcard)
dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo \
    --treenode-filter "/*/*/CSharpParserTests/*"

# Filter by single test method
dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo \
    --treenode-filter "/*/*/CSharpParserTests/ParseClassReturnsCorrectSymbolKind"

# Filter by namespace
dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo \
    --treenode-filter "/*/CodeCompress.Core.Tests.Parsers/*/*"

# OR filter (run multiple classes)
dotnet test tests/CodeCompress.Core.Tests -- --output Normal --disable-logo \
    --treenode-filter "/*/*/(CSharpParserTests)|(GoParserTests)/*"

# With TRX report and coverage
dotnet test CodeCompress.slnx -- --output Normal --disable-logo --report-trx --coverage

--output Normal controls verbosity (also Minimal, Detailed). --disable-logo suppresses the ASCII art banner (also available as env var TUNIT_DISABLE_LOGO=true).

Do NOT use --filter "FullyQualifiedName~..." — that's the legacy VSTest syntax; it does not work with MTP/TUnit.

Test Class Pattern

namespace CodeCompress.Core.Tests.Parsers;

internal sealed class CSharpParserTests
{
    [Test]
    public async Task ParseClassReturnsCorrectSymbolKind()
    {
        // Arrange
        var parser = new CSharpParser();
        var content = "public class Foo { }"u8;

        // Act
        var result = parser.Parse("test.cs", content);

        // Assert
        await Assert.That(result.Symbols).Count().IsEqualTo(1);
        await Assert.That(result.Symbols[0].Kind).IsEqualTo(SymbolKind.Class);
    }
}

Critical rules:

  • Test class: internal sealed class FooTests (CA1515 requires internal for Exe projects, CA1852 requires sealed)
  • Test methods: public async Task MethodNameInPascalCase() — NO underscores (CA1707)
  • Test attribute: [Test] on each test method
  • Test projects require <OutputType>Exe</OutputType> (TUnit source-generated entry point)

Assertions (All Async/Fluent)

// Equality
await Assert.That(result).IsEqualTo(expected);
await Assert.That(result).IsNotEqualTo(other);

// Null
await Assert.That(obj).IsNotNull();
await Assert.That(obj).IsNull();

// Boolean
await Assert.That(flag).IsTrue();
await Assert.That(flag).IsFalse();

// String
await Assert.That(str).Contains("substring");
await Assert.That(str).StartsWith("prefix");
await Assert.That(str).EndsWith("suffix");
await Assert.That(str).IsEmpty();

// Collections
await Assert.That(list).Count().IsEqualTo(3);        // NOT .HasCount() — obsolete!
await Assert.That(list).Contains(item);
await Assert.That(list).IsEmpty();

// Numeric comparisons
await Assert.That(value).IsGreaterThan(0);
await Assert.That(value).IsLessThanOrEqualTo(100);

// Type checking
await Assert.That(obj).IsTypeOf<ExpectedType>();

// Exceptions
await Assert.That(() => SomeMethod()).ThrowsException();

WARNING: .HasCount() is obsolete in recent TUnit versions. Always use .Count().IsEqualTo(n).

Parameterized Tests

[Test]
[Arguments("public", Visibility.Public)]
[Arguments("private", Visibility.Private)]
[Arguments("internal", Visibility.Internal)]
public async Task ParseVisibilityModifier(string modifier, Visibility expected)
{
    var content = System.Text.Encoding.UTF8.GetBytes($"{modifier} class Foo {{ }}");
    var result = _parser.Parse("test.cs", content);

    await Assert.That(result.Symbols[0].Visibility).IsEqualTo(expected);
}

Test Lifecycle

private CSharpParser _parser = null!;

[Before(Test)]
public void SetUp()
{
    _parser = new CSharpParser();
}

Mocking

Two mocking libraries are available. NSubstitute is the established project default. TUnit.Mocks is TUnit's native library — source-generated, no reflection, no .Object wrapper.

NSubstitute (project default)

// Create mock
var store = Substitute.For<ISymbolStore>();
var validator = Substitute.For<IPathValidator>();

// Setup returns
store.GetSymbolByNameAsync("repo-id", "Foo")
    .Returns(new Symbol(/* ... */));
validator.ValidatePath(Arg.Any<string>(), Arg.Any<string>())
    .Returns("/valid/path");

// Verify calls
store.Received(1).GetSymbolByNameAsync("repo-id", "Foo");
validator.DidNotReceive().ValidatePath(Arg.Any<string>(), Arg.Any<string>());

// Argument matchers
store.SearchSymbolsAsync(
    Arg.Any<string>(),
    Arg.Is<string>(q => q.Contains("Combat")),
    Arg.Any<string?>(),
    Arg.Any<int>()
).Returns(results);

TUnit.Mocks (native alternative)

TUnit.Mocks uses source generators — the mock IS the interface, no .Object wrapper needed.

using TUnit.Mocks;

// Create mock (static extension generated per interface)
var mock = ISymbolStore.Mock();                          // Loose (default)
var strict = ISymbolStore.Mock(MockBehavior.Strict);    // Strict — throws on unconfigured calls

// Configure returns (Any() is the TUnit wildcard matcher)
mock.GetSymbolByNameAsync(Any(), Any()).Returns(new Symbol(/* ... */));

// Mock IS the interface — no .Object
ISymbolStore store = mock;
var result = await store.GetSymbolByNameAsync("repo-id", "Foo");

// Verify calls
mock.GetSymbolByNameAsync("repo-id", "Foo").WasCalled(Times.Once);
mock.GetSymbolByNameAsync(Any(), Any()).WasCalled(Times.Exactly(1));

Add to .csproj to use: <PackageReference Include="TUnit.Mocks" />

Verify Snapshot Testing

Use for complex output validation where exact string comparison is brittle:

[Test]
public async Task ProjectOutlineMatchesSnapshot()
{
    var outline = await store.GetProjectOutlineAsync(repoId, false, "file", 3);
    await Verify(outline);
}

Snapshot files (.verified.txt) are stored alongside test files. On first run, Verify creates the snapshot. On subsequent runs, it compares against the stored snapshot.

Test Structure Rules

Mirror source structure:

src/CodeCompress.Core/Parsers/CSharpParser.cs
    → tests/CodeCompress.Core.Tests/Parsers/CSharpParserTests.cs

src/CodeCompress.Core/Storage/SqliteSymbolStore.cs
    → tests/CodeCompress.Core.Tests/Storage/SqliteSymbolStoreTests.cs

src/CodeCompress.Server/Tools/QueryTools.cs
    → tests/CodeCompress.Server.Tests/Tools/QueryToolsTests.cs

Test naming: Name tests after the behavior, not the method:

  • Good: ParseNestedClassSetsCorrectParent
  • Good: SearchWithInvalidQueryReturnsEmpty
  • Bad: TestParse (too vague)
  • Bad: Parse_Nested_Class_Sets_Parent (underscores violate CA1707)

Coverage Targets

Layer Target
Parsers (all languages) 95%+
Storage (SqliteSymbolStore) 90%+
Index Engine 90%+
MCP Tools 85%+

Common Gotchas

  1. Record equality with collections: Records use reference equality for IReadOnlyList<T> properties. Share list instances in test expectations, or use element-by-element assertions.

  2. TUnit exit code 8: Means "zero tests ran" — not a real failure. Can happen if --treenode-filter matches nothing. Check the pattern — it must match /{assembly}/{namespace}/{class}/{method} exactly (case-sensitive).

  3. ConfigureAwait(false): Use in test code when calling production async methods: await method().ConfigureAwait(false);

  4. Async disposal in tests: Use await using for CliProjectScope, SqliteConnection, etc.

  5. ReadOnlySpan<byte> in tests: Use "content"u8 UTF-8 literal for parser test input, or System.Text.Encoding.UTF8.GetBytes(str) for dynamic content.

Sub-Agent Context Requirements

When this skill is invoked as a sub-agent, the caller must provide:

  1. The file/class being tested — full source code (agents can't read files)
  2. The interface signatures — so the agent knows what to mock
  3. Existing test patterns — an example test class from the project showing conventions
  4. API documentation — for any libraries the tests will use (agents can't call MCP tools)
  5. The specific scenarios to test — happy path, edge cases, error cases