Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fe6d8dd
feat(testing): add module test harness
thomhurst Jul 30, 2026
d3af968
fix(testing): honor runtime contracts
thomhurst Jul 31, 2026
1776028
style(filesystem): simplify null checks
thomhurst Jul 31, 2026
f29473a
refactor(testing): simplify file opening
thomhurst Jul 31, 2026
4d39375
fix(testing): match filesystem contracts
thomhurst Jul 31, 2026
0e1258b
fix(testing): virtualize complete file context
thomhurst Jul 31, 2026
bd4a33e
fix(testing): close runtime parity gaps
thomhurst Jul 31, 2026
d1fd25d
fix(testing): reject writes to open files
thomhurst Jul 31, 2026
4599871
fix(testing): match runtime semantics
thomhurst Jul 31, 2026
0a6b816
fix(testing): close runtime parity gaps
thomhurst Jul 31, 2026
71a9361
fix(testing): reject moves of open files
thomhurst Jul 31, 2026
858b516
fix(zip): preserve existing destinations
thomhurst Jul 31, 2026
4ce822e
fix(testing): match filesystem byte semantics
thomhurst Jul 31, 2026
4950051
fix(testing): match filesystem text semantics
thomhurst Jul 31, 2026
3af7068
refactor(testing): simplify harness paths
thomhurst Jul 31, 2026
0d7b724
fix(testing): preflight directory handles
thomhurst Jul 31, 2026
0ec199d
fix(files): preserve path boundaries
thomhurst Aug 2, 2026
98c70d2
fix(testing): preserve file path semantics
thomhurst Aug 2, 2026
0ab8fce
fix(testing): handle invalid existence paths
thomhurst Aug 2, 2026
661bee0
fix(testing): preserve stream exceptions
thomhurst Aug 2, 2026
e7524cc
fix(testing): reject directory-form files
thomhurst Aug 2, 2026
5e1ae8c
fix(testing): preserve effective filesystem
thomhurst Aug 2, 2026
f496672
fix(testing): cancel line enumeration
thomhurst Aug 2, 2026
c11a465
fix(testing): match filesystem path semantics
thomhurst Aug 2, 2026
ae08d46
fix(testing): close filesystem parity gaps
thomhurst Aug 2, 2026
c30c716
fix(testing): preserve temp path separator
thomhurst Aug 2, 2026
2781271
fix(testing): preserve partial line writes
thomhurst Aug 2, 2026
bfafd72
fix(testing): preserve command invocation order
thomhurst Aug 2, 2026
d367bbb
Merge remote-tracking branch 'origin/main' into issue-3538-testing-ha…
thomhurst Aug 2, 2026
cb48f4e
test(analyzers): preserve expected formatting
thomhurst Aug 2, 2026
af021d9
docs(testing): document harness outcomes
thomhurst Aug 2, 2026
cc1e6d8
fix: harden module test isolation
thomhurst Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions ModularPipelines.All.sln
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Yq.UnitTes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Templates", "src\ModularPipelines.Templates\ModularPipelines.Templates.csproj", "{89576148-F56C-463D-BCEB-6C3C9E75550B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Testing", "src\ModularPipelines.Testing\ModularPipelines.Testing.csproj", "{0150323C-1B27-42F8-918D-D646F0B1ED77}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModularPipelines.Testing.UnitTests", "test\ModularPipelines.Testing.UnitTests\ModularPipelines.Testing.UnitTests.csproj", "{A4899287-D537-4F8C-98EC-C3E9CB7F069E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1665,6 +1669,30 @@ Global
{89576148-F56C-463D-BCEB-6C3C9E75550B}.Release|x64.Build.0 = Release|Any CPU
{89576148-F56C-463D-BCEB-6C3C9E75550B}.Release|x86.ActiveCfg = Release|Any CPU
{89576148-F56C-463D-BCEB-6C3C9E75550B}.Release|x86.Build.0 = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|x64.ActiveCfg = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|x64.Build.0 = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|x86.ActiveCfg = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Debug|x86.Build.0 = Debug|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|Any CPU.Build.0 = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|x64.ActiveCfg = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|x64.Build.0 = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|x86.ActiveCfg = Release|Any CPU
{0150323C-1B27-42F8-918D-D646F0B1ED77}.Release|x86.Build.0 = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|x64.Build.0 = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Debug|x86.Build.0 = Debug|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|Any CPU.Build.0 = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|x64.ActiveCfg = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|x64.Build.0 = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|x86.ActiveCfg = Release|Any CPU
{A4899287-D537-4F8C-98EC-C3E9CB7F069E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1788,6 +1816,8 @@ Global
{6D367646-6CD9-47F8-8C74-5124FE36F61A} = {F213898F-1E32-48F1-AB8C-83D2BD01A93B}
{40294462-4153-4DC3-9C6D-4E997BF83309} = {F213898F-1E32-48F1-AB8C-83D2BD01A93B}
{89576148-F56C-463D-BCEB-6C3C9E75550B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0150323C-1B27-42F8-918D-D646F0B1ED77} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A4899287-D537-4F8C-98EC-C3E9CB7F069E} = {F213898F-1E32-48F1-AB8C-83D2BD01A93B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A5905A5D-B4E1-4A7A-9279-0283D86A9F7F}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ ModularPipelines has strongly-typed wrappers for the tools you already use:
| ModularPipelines.SonarScanner | Helpers for interacting with SonarScanner CLI for SonarQube and SonarCloud. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.SonarScanner.svg)](https://www.nuget.org/packages/ModularPipelines.SonarScanner/) |
| ModularPipelines.TeamCity | Helpers for interacting with TeamCity build agents. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.TeamCity.svg)](https://www.nuget.org/packages/ModularPipelines.TeamCity/) |
| ModularPipelines.Templates | Templates for creating realistic ModularPipelines build, test, and publish pipelines. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.Templates.svg)](https://www.nuget.org/packages/ModularPipelines.Templates/) |
| ModularPipelines.Testing | Supported test harness for isolated ModularPipelines module tests. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.Testing.svg)](https://www.nuget.org/packages/ModularPipelines.Testing/) |
| ModularPipelines.Terraform | Helpers for interacting with Terraform CLI. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.Terraform.svg)](https://www.nuget.org/packages/ModularPipelines.Terraform/) |
| ModularPipelines.Trivy | Helpers for interacting with Trivy security scanner CLI. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.Trivy.svg)](https://www.nuget.org/packages/ModularPipelines.Trivy/) |
| ModularPipelines.WinGet | Helpers for interacting with the Windows Package Manager. | [![nuget](https://img.shields.io/nuget/v/ModularPipelines.WinGet.svg)](https://www.nuget.org/packages/ModularPipelines.WinGet/) |
Expand Down
182 changes: 116 additions & 66 deletions docs/docs/how-to/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,101 +3,151 @@ title: Testing
sidebar_position: 9
---

## Testing with Mocked File System
Install `ModularPipelines.Testing` to execute one module without starting the full
pipeline scheduler:

ModularPipelines supports mocking file system operations for unit testing. All file I/O goes through `IFileSystemProvider`, which can be replaced with a mock implementation.
```bash
dotnet add package ModularPipelines.Testing
```

The test harness uses the normal module execution pipeline, so skip conditions,
timeouts, retries, and direct module hooks behave as they do in a pipeline. It
provides test-safe defaults:

### Why Mock the File System?
- external commands are intercepted and return a successful result;
- file and directory operations use an isolated in-memory filesystem;
- progress, logos, dependency chains, and result printing are disabled;
- module failures are returned for assertions instead of escaping from the harness.

- **Speed**: Tests run faster without actual disk I/O
- **Isolation**: Tests don't depend on file system state
- **Predictability**: No flaky tests due to file permissions or disk space
- **CI-friendly**: Works in any environment without file system setup
## Execute a module

### Example: Mocking File Reads
Specify the module and result types for strongly typed value access:

```csharp
using Moq;
using ModularPipelines;
using ModularPipelines.Enums;
using ModularPipelines.FileSystem;
using ModularPipelines.Extensions;
using ModularPipelines.Testing;

[Test]
public async Task MyModule_ReadsConfigFile()
public async Task Build_returns_the_artifact()
{
// Create a mock provider
var mockProvider = new Mock<IFileSystemProvider>();
mockProvider.Setup(p => p.ReadAllTextAsync(
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync("{\"setting\": \"value\"}");
var run = await ModuleTester.For<BuildModule, BuildArtifact>()
.ExecuteAsync();

// Run pipeline with mock
using var builder = Pipeline.CreateBuilder(args);
await Assert.That(run.Value!.Name).IsEqualTo("application.zip");
await Assert.That(run.Exception).IsNull();
}
```

builder.ConfigurePipelineOptions(options => options with
{
ThrowOnPipelineFailure = false,
});
If only the module type is convenient, use the type-erased overload. `Value` is
then `object?`, while `Result` still contains the full module metadata:

builder.Services.AddSingleton<IFileSystemProvider>(mockProvider.Object);
builder.AddModule<MyModule>();
```csharp
var run = await ModuleTester.For<BuildModule>().ExecuteAsync();

var result = await builder.ExecutePipelineAsync();
var artifact = (BuildArtifact)run.Value!;
```

// Assert results
Assert.That(result.Status, Is.EqualTo(Status.Successful));
}
## Seed dependency results

Register a dependency result without executing that dependency:

```csharp
var restoredPackages = CommandResult.Ok("Restore succeeded.");

var run = await ModuleTester.For<BuildModule, BuildArtifact>()
.WithDependencyResult<RestoreModule, CommandResult>(restoredPackages)
.ExecuteAsync();
```

### Example: Verifying File Writes
The dependency module is registered normally, then its successful result is
completed before the target module starts. Calls such as
`await context.GetModule<RestoreModule>()` therefore receive the seeded value.
If a required dependency has no seeded result, `ExecuteAsync` fails immediately
and names the missing dependency instead of waiting for the module timeout.

## Intercept and inspect commands

Commands never start real processes unless you explicitly replace the test
harness behavior. The default interceptor returns `CommandResult.Ok()`.

Provide a handler when a module needs command output:

```csharp
[Test]
public async Task MyModule_WritesOutputFile()
{
var mockProvider = new Mock<IFileSystemProvider>();
var run = await ModuleTester.For<BuildModule, BuildArtifact>()
.InterceptCommands(invocation =>
{
if (invocation.CommandLine.Tool == "dotnet")
{
return CommandResult.Ok("Build succeeded.");
}

return CommandResult.Ok();
})
.ExecuteAsync();

await Assert.That(run.Commands).Count().IsEqualTo(1);
await Assert.That(run.Commands[0].CommandLine.Arguments)
.IsEquivalentTo(["build", "--configuration", "Release"]);
```

using var builder = Pipeline.CreateBuilder(args);
Each `RecordedCommand` contains the parsed `CommandInvocation` and the simulated
`CommandResult`. This avoids assertions against a quoted display string.
Intercepted nonzero exit codes follow `CommandExecutionOptions` normally and
throw `CommandException` when `ThrowOnNonZeroExitCode` is enabled.

builder.Services.AddSingleton<IFileSystemProvider>(mockProvider.Object);
builder.AddModule<OutputModule>();
`ICommandInterceptor` is also a public framework seam. Register an implementation
in a normal pipeline when command interception is needed outside
`ModularPipelines.Testing`. Return `null` to let the next interceptor or the real
process executor handle the command.

await builder.ExecutePipelineAsync();
## Use the in-memory filesystem

// Verify the write occurred with expected content
mockProvider.Verify(p => p.WriteAllTextAsync(
It.Is<string>(path => path.Contains("output")),
It.Is<string>(content => content.Contains("result")),
It.IsAny<CancellationToken>()));
}
Files obtained through `context.Files` automatically use the harness filesystem:

```csharp
var run = await ModuleTester.For<ManifestModule, string>()
.ExecuteAsync();

var manifest = await run.FileSystem.ReadAllTextAsync("/output/manifest.json");
```

### Important Notes
`InMemoryFileSystemProvider` implements `IFileSystemProvider`, including file and
directory creation, reads, writes, streams, copies, moves, deletion, enumeration,
and path helpers. You can also construct and register it directly in other tests.
Physical metadata such as attributes, timestamps, and file length is not part of
`IFileSystemProvider`; accessing it through an in-memory-backed `File` or `Folder`
throws `NotSupportedException` rather than reading the real filesystem.

- **Always use `context.Files`**: Files created via `context.Files.GetFile()` will use the injected provider. Files created directly via `new File("path")` use the real file system.
Code under test must obtain `File` and `Folder` instances from `context.Files`.
Direct construction such as `new File("path")` intentionally uses the physical
`SystemFileSystemProvider`.

- **Provider Registration**: The mock provider must be registered before the pipeline runs. Using `services.AddSingleton<IFileSystemProvider>()` overrides the default `SystemFileSystemProvider`.
## Register constructor services

- **Mock ALL methods your code uses**: The mock provider only intercepts methods you explicitly set up. If your module calls `ReadAllTextAsync`, `FileExists`, and `Combine`, you must mock all three. Unmocked methods may throw or return default values depending on your mocking framework.
Use `WithService` for module constructor dependencies:

- **Implicit operators bypass mocking**: Implicit conversions like `File file = "/path/to/file"` create instances using the default `SystemFileSystemProvider`, not your mock. For full testability, always use `context.Files.GetFile()`.
```csharp
var settings = new BuildSettings { Configuration = "Release" };

var run = await ModuleTester.For<BuildModule, BuildArtifact>()
.WithService(settings)
.ExecuteAsync();
```

## Assert skipped and failed runs

- **Static methods are not mockable**: Methods like `File.GetNewTemporaryFilePath()` and `Folder.CreateTemporaryFolder()` use the real file system. Design your modules to receive paths via constructor or use `context.Files.CreateTemporaryFolder()` instead.
The harness configures `ThrowOnPipelineFailure = false`, so failed modules are
returned for assertions. Successful runs expose `Status.Successful`.

- **Mocking Path Operations**: If your code uses path operations, mock them too:
```csharp
mockProvider.Setup(p => p.Combine(It.IsAny<string[]>()))
.Returns((string[] paths) => Path.Combine(paths));
```
The run object exposes safe outcome properties:

### What Gets Mocked
```csharp
var skipped = await ModuleTester.For<OptionalModule, string>().ExecuteAsync();
await Assert.That(skipped.SkipDecision!.Reason).IsEqualTo("Feature disabled");

var failed = await ModuleTester.For<FailingModule, string>().ExecuteAsync();
await Assert.That(failed.Exception).IsTypeOf<InvalidOperationException>();
await Assert.That(failed.Result).IsTypeOf<ModuleResult<string>.Failure>();
```

The `IFileSystemProvider` interface covers:
- File reads: `ReadAllTextAsync`, `ReadLinesAsync`, `ReadAllBytesAsync`
- File writes: `WriteAllTextAsync`, `WriteAllBytesAsync`, `WriteAllLinesAsync`, `AppendAllTextAsync`
- File management: `DeleteFile`, `CopyFile`, `MoveFile`, `FileExists`
- Directory operations: `CreateDirectory`, `DeleteDirectory`, `MoveDirectory`, `DirectoryExists`
- Enumeration: `EnumerateFiles`, `EnumerateDirectories`
- Path utilities: `GetTempPath`, `GetRandomFileName`, `Combine`, `GetRelativePath`
Use `Result` when assertions need timing, status, or the discriminated result
variant. Use `Value`, `Exception`, and `SkipDecision` for concise safe access.
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,11 @@ public async Task UseAsync(IModuleContext context)
using ModularPipelines.Attributes;

namespace Example;

public class Dependency : Module<string>
{
protected override Task<string?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
protected override Task<string?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
=> Task.FromResult<string?>(null);
}

[DependsOn<Dependency>]
Expand All @@ -308,7 +310,8 @@ public async Task UseAsync(IModuleContext context)
}
}

protected override Task<string?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken) => Task.FromResult<string?>(null);
protected override Task<string?> ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
=> Task.FromResult<string?>(null);
}
""".ReplaceLineEndings("\n");
var expected = VerifyCS.Diagnostic(MissingDependsOnAttributeAnalyzer.DiagnosticId)
Expand Down
1 change: 1 addition & 0 deletions src/ModularPipelines.Build/Modules/FindProjectsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ protected override ModuleConfiguration Configure() => ModuleConfiguration.Create
Sourcy.DotNet.Projects.ModularPipelines_SonarScanner,
Sourcy.DotNet.Projects.ModularPipelines_TeamCity,
Sourcy.DotNet.Projects.ModularPipelines_Templates,
Sourcy.DotNet.Projects.ModularPipelines_Testing,
Sourcy.DotNet.Projects.ModularPipelines_Terraform,
Sourcy.DotNet.Projects.ModularPipelines_Trivy,
Sourcy.DotNet.Projects.ModularPipelines_WinGet,
Expand Down
Loading
Loading