Testing Patterns and Infrastructure
Technical Stack
- NUnit 4 test framework
- NSubstitute for mocking
- AutoFixture for test data generation
- AwesomeAssertions for fluent assertions (NOT FluentAssertions)
Test Pyramid
- E2E: Critical user workflows against real services (Testcontainers)
- Integration: Complete workflows with mocked externals (Git, HTTP, filesystem)
- Unit: Edge cases integration cannot reach
Naming
- Classes:
{Component}Testor{Component}IntegrationTest - Methods: Underscore-separated behavior (
Load_many_iterations_of_config) - Pattern:
internal sealed class
Integration Test Setup
internal sealed class MyFeatureIntegrationTest : CliIntegrationFixture
{
protected override void RegisterStubsAndMocks(ContainerBuilder builder)
{
// Register custom mocks here
}
}
Mock externals only: Git (LibGit2Sharp), HTTP APIs, filesystem (MockFileSystem).
AutoFixture Attributes
[AutoMockData]: Basic DI with mocks[InlineAutoMockData(params)]: Parameterized tests[Frozen]or[Frozen(Matching.ImplementedInterfaces)]: Shared mock instances[CustomizeWith(typeof(T))]: Custom configuration[AutoMockData(typeof(TestClass), nameof(Method))]: DI container integration
NSubstitute Patterns
Arrange (setting up dependencies):
dependency.Method().Returns(value);
dependency.Property.ReturnsNull();
dependency.Method(default!).ReturnsForAnyArgs(value);
dependency.Method().Returns([item1, item2]);
Verify.That<T>(x => x.Property.Should().Be(expected));
Last resort interaction verification (when no observable outcome exists):
mock.ReceivedWithAnyArgs().SetStatus(default, default);
Argument matching (returns setup and received verification): Prefer
ReceivedWithAnyArgs()/ReturnsForAnyArgs() with default over Arg.Any<T>():
// Good
mock.ReceivedWithAnyArgs().SetStatus(default, default);
mock.Method(default!, default!).ReturnsForAnyArgs(value);
// Bad
mock.Received().SetStatus(Arg.Any<Status>(), Arg.Any<int?>());
mock.Method(Arg.Any<string>(), Arg.Any<int>()).ReturnsForAnyArgs(value);
AwesomeAssertions
Preferred:
result.Should().BeEquivalentTo(expected);
result.Select(x => x.Property).Should().BeEquivalentTo(expected);
act.Should().Throw<ExceptionType>().WithMessage("pattern");
collection.Should().HaveCount(n).And.Contain(item);
dict.Should().ContainKey(key).WhoseValue.Should().Be(expected);
Anti-patterns:
dict!["key"]!- useContainKey().WhoseValueinsteadHaveCount()+BeEquivalentTo()- redundant; equivalence checks count- Multiple assertions instead of
.Andchaining
Utilities
IntegrationTestFixture: Core library integration testsCliIntegrationFixture: CLI integration with composition rootVerify.That<T>(): NSubstitute matcher with assertionsTestableLogger: Capture log messagesNUnitAnsiConsole: Console output verificationMockFileSystem: Filesystem testing (avoid absolute paths)- Factory classes:
NewCf,NewConfig,NewQualitySize,NewPlannedCf, etc.
Test Factory Helpers (New* Classes)
One New{DomainType} class per domain type (e.g. NewQualityDefinition, NewCustomFormat). No
grab-bag classes mixing unrelated types.
Rules:
- Methods return one type. Use overloads or optional parameters for variants, not separate classes.
- Accept only test-relevant parameters with sensible defaults. This shields tests from model changes.
- Location:
Core.TestLibraryfor types in Core;Cli.Tests/Reusablefor types in Cli. Hard constraint from project dependency direction (see REC-90). - Overhaul existing helpers when their types are touched. No legacy pattern preservation.
When to skip: If a domain type is trivial (few properties, no required fields, natural
defaults), direct construction in tests is acceptable. Include a brief justification when skipping.
Filesystem Paths
Avoid absolute paths in MockFileSystem (platform-incompatible):
// Good
Fs.CurrentDirectory().SubDirectory("a", "b").File("c.json")
// Bad
"/absolute/path/file.json"
Running Tests
# Unit and integration tests
dotnet test -v q
# Specific test project
dotnet test -v q tests/Recyclarr.Cli.Tests/
# Single test by name
dotnet test -v q --filter "FullyQualifiedName~TestMethodName"
# E2E tests (requires Docker services)
./scripts/Run-E2ETests.ps1
Coverage Analysis
Use coverage analysis to identify gaps before writing tests.
# Run tests + query uncovered lines (one-shot, preferred)
./scripts/coverage.py --run uncovered Platform Migration
# Run separately only when making multiple queries
./scripts/coverage.py --run
./scripts/coverage.py uncovered Platform
./scripts/coverage.py files Migration
# Find N files with lowest coverage
./scripts/coverage.py --run lowest 10
Patterns are substring matches (case-insensitive), not globs. Multiple patterns match files containing ANY pattern. Examples:
Platformmatchessrc/Recyclarr.Core/Platform/AppPaths.csPlatform Migrationmatches files containing “Platform” OR “Migration”
Output format: path:pct:covered/total[:uncovered_lines]
CRITICAL: --run must succeed before querying. Investigate failures - coverage data is invalid on
failure. Run coverage BEFORE writing tests to understand gaps.
End-to-End Tests
E2E tests run the full Recyclarr CLI against containerized Sonarr/Radarr instances. Tests verify that sync operations produce expected state in the services.
Running E2E Tests
MANDATORY: Use ./scripts/Run-E2ETests.ps1 - never run dotnet test directly for E2E tests.
The script outputs a log file path; use rg to search logs without rerunning tests.
Resource Provider Strategy
The test uses multiple resource providers to verify different loading mechanisms:
Official Trash Guides (Pinned SHA)
- name: trash-guides-pinned
type: trash-guides
clone_url: https://github.com/TRaSH-Guides/Guides.git
reference: <pinned-sha>
replace_default: true
Purpose: Baseline data that tests real-world compatibility.
Use for: Stable CFs that exist in official guides (e.g., Bad Dual Groups, Obfuscated).
Why pinned: Prevents upstream changes from breaking tests unexpectedly.
Local Custom Format Providers
- name: sonarr-cfs-local
type: custom-formats
service: sonarr
path: <local-path>
Purpose: Tests type: custom-formats provider behavior specifically.
Use for: CFs that need controlled structure or don’t exist in official guides.
Trash Guides Override
- name: radarr-override
type: trash-guides
path: <local-path>
Purpose: Tests override/layering behavior (higher precedence than official guides).
Use for:
- Quality profiles with known structure for testing inheritance
- CF groups with controlled members for testing group behavior
- CFs that override official guide CFs (e.g., HybridOverride)
Fixture Directory Structure
Fixtures/
recyclarr.yml # Test configuration
settings.yml # Resource provider definitions
custom-formats-sonarr/ # type: custom-formats provider (Sonarr)
custom-formats-radarr/ # type: custom-formats provider (Radarr)
trash-guides-override/ # type: trash-guides provider (override layer)
metadata.json # Defines paths for each resource type
docs/
Radarr/
cf/ # Custom formats
cf-groups/ # CF groups
quality-profiles/ # Quality profiles
Sonarr/
cf/
cf-groups/
quality-profiles/
When to Use Each Provider Type
Use Official Guides When
- Testing sync of real-world CFs that are stable
- Testing compatibility with actual guide data structures
- The specific CF content doesn’t matter, just that syncing works
Use Local Fixtures When
- Testing specific inheritance/override behavior
- Testing resources that don’t exist in official guides
- Testing provider-specific loading behavior
- You need controlled, predictable resource structure
Trash ID Conventions
e2e00000000000000000000000000001- E2E test Radarr quality profilee2e00000000000000000000000000002- E2E test Sonarr quality profilee2e00000000000000000000000000003- E2E test Sonarr guide-only profilee2e00000000000000000000000000010- E2E test Sonarr CF groupe2e00000000000000000000000000011- E2E test Radarr CF groupcf000000000000000000000000000001throughcf000000000000000000000000000008- Local test CFs
Convention: Local test trash IDs use a cf prefix so YAML treats them as strings without
quoting. Never use all-numeric trash IDs in fixtures.
Adding New Test Cases
- For new CFs: Add JSON to appropriate
custom-formats-*ortrash-guides-override/docs/*/cf/ - For new QPs: Add JSON to
trash-guides-override/docs/*/quality-profiles/ - For new CF groups: Add JSON to
trash-guides-override/docs/*/cf-groups/ - Update metadata.json if adding new resource type paths
- Update recyclarr.yml to reference the new trash_ids
- Update test assertions in
RecyclarrSyncTests.cs
metadata.json Structure
The metadata.json file tells Recyclarr where to find each resource type:
{
"json_paths": {
"radarr": {
"custom_formats": ["docs/Radarr/cf"],
"qualities": [],
"naming": [],
"custom_format_groups": ["docs/Radarr/cf-groups"],
"quality_profiles": ["docs/Radarr/quality-profiles"]
},
"sonarr": { "..." }
}
}
Important: Paths must not contain spaces. Use cf instead of Custom Formats.