testing
Testing patterns, infrastructure, and fixtures for Recyclarr. Covers unit, integration, and E2E tests, coverage analysis scripts, debugging test failures, and test utilities.
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
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
Integration-First TDD Workflow
- Write a failing integration test for the happy path (red)
- Implement until it passes (green)
- Check coverage (see Coverage Analysis section); add integration tests for uncovered edge cases
- Use unit tests only when integration tests cannot reach specific code paths
What NOT to Test
- Console output, log messages, UI formatting
- Auto-properties, DTOs, simple data containers
- Implementation details that could change without affecting behavior
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));
Assert on observable outcomes, not mock interactions. Verify the result, side effect, or state
change rather than asserting a method was called. Tests that assert Received() are coupled to
implementation; they break when internals are refactored even if behavior is correct.
// Good: assert on the outcome
result.Should().BeEquivalentTo(expected);
fileSystem.AllFiles.Should().Contain(expectedPath);
// Last resort: verify interaction only when there is no observable outcome
mock.ReceivedWithAnyArgs().SetStatus(default, default);
If Received() feels like the only option, challenge the design first. Needing mock
verification often signals a testability problem (void method hiding a meaningful result, missing
return value, side effect with no observable state change). Flag this to the user as a potential
design improvement even if it is outside the current scope of work; do not silently accommodate
untestable designs.
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().WhoseValueinstead -
HaveCount()+BeEquivalentTo()- redundant; equivalence checks count - Multiple assertions instead of
.Andchaining
Utilities
-
IntegrationTestFixture: Core library integration tests -
CliIntegrationFixture: CLI integration with composition root -
Verify.That<T>(): NSubstitute matcher with assertions -
TestableLogger: Capture log messages -
NUnitAnsiConsole: Console output verification -
MockFileSystem: 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"
Debugging Test Failures
Gather evidence before changing code. Avoid guess-and-check cycles.
- Read assertion output carefully - Diff output often reveals the issue immediately
- Add adhoc logs - Trace execution in tests or production code; remove when done
- Compare with passing tests - Diff similar working tests to spot differences
- Add intermediate assertions - Verify state at each step to pinpoint divergence
- Simplify to minimal reproduction - Strip test down, add back until failure
- Write adhoc granular tests - Isolate suspected areas; remove when done
-
Check test isolation - Run alone (
--filter) vs. suite to detect state leakage
Test Framing
Tests serve as documentation. Choose framing based on what the test documents:
- Positive tests (expected behavior): Lead with what SHOULD happen, then verify absence of unintended side effects
- Negative tests (error conditions): Assert the error/rejection IS raised; essential for validating error paths
Both are equally important. The distinction is about clarity, not preference.
Anti-Patterns
- Over-mocking or mocking business logic
- Tests coupled to implementation details
- Duplicate coverage for same logical paths
- Production code added solely for testing
- Unexplained magic constants
Running Tests
# Unit and integration tests
dotnet test -v m
# Specific test project
dotnet test -v m tests/Recyclarr.Cli.Tests/
# Single test by name
dotnet test -v m --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.cs -
Platform 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 profile -
e2e00000000000000000000000000002- E2E test Sonarr quality profile -
e2e00000000000000000000000000003- E2E test Sonarr guide-only profile -
e2e00000000000000000000000000010- E2E test Sonarr CF group -
e2e00000000000000000000000000011- E2E test Radarr CF group -
cf000000000000000000000000000001throughcf000000000000000000000000000008- 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.
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-03-04
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!