Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/notify-docs-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Dispatch extension-documentation-updated-event to Testably/Testably.Site
uses: peter-evans/repository-dispatch@v3
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.SITE_DISPATCH_TOKEN }}
repository: Testably/Testably.Site
Expand Down
10 changes: 10 additions & 0 deletions Docs/pages/00-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: Migration
sidebar_position: 8
---

[![Nuget](https://img.shields.io/nuget/v/Testably.Abstractions.Migration?label=Testably.Abstractions.Migration&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions.Migration)

A Roslyn analyzer and code-fix provider that migrates `System.IO.Abstractions` testing usage to `Testably.Abstractions.Testing`.

{README}
108 changes: 101 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,116 @@ construct it can migrate; the accompanying code fix rewrites the call site.

## Installation

Install the NuGet package into the project you want to migrate:
The migration package is a one-shot development tool — install it, migrate, then uninstall. It ships only
the analyzer and code fixer, not runtime code, and is marked as a `DevelopmentDependency` so it never
flows transitively to consumers of your test project.

Because the package does not pull `Testably.Abstractions.Testing` transitively, **you must reference it
yourself** in the project being migrated. Otherwise the rewritten call sites would compile while the
migration package is installed but stop compiling the moment you remove it.

```shell
dotnet add package Testably.Abstractions.Testing
dotnet add package Testably.Abstractions.Migration
```

The package only needs to be referenced while you are migrating — it ships the analyzer and code fixer,
not runtime code. Once `System.IO.Abstractions.TestingHelpers` is gone from a project you can remove the
reference again.
## Recommended workflow

1. **Reference the target library.** Add `Testably.Abstractions.Testing` to the project you want to
migrate (see above). Existing `System.IO.Abstractions.TestingHelpers` usage keeps compiling
side-by-side.
2. **Install the migration package.** Adds the analyzer. Every supported construct is reported as
warning `TestablyM001`.
3. **Apply the code fix.** Use your IDE (Visual Studio, Rider, VS Code with C# Dev Kit) to fix
diagnostics one by one, or run `dotnet format analyzers` to apply every available fix in bulk.
4. **Address manual-review diagnostics.** Some patterns have no safe automatic rewrite (see
[Manual review](#manual-review)). The analyzer reports them so they are discoverable; you migrate
each call site by hand.
5. **Remove `System.IO.Abstractions.TestingHelpers`.** Once the analyzer is quiet, drop the dependency.
6. **Uninstall the migration package.** It has served its purpose and only adds analyzer overhead
from here on.

```shell
dotnet remove package System.IO.Abstractions.TestingHelpers
dotnet remove package Testably.Abstractions.Migration
```

## How it works

After installing the package, every supported construct is reported as a warning. Apply the relevant
code fix from your IDE (Visual Studio, Rider, VS Code with C# Dev Kit) or via
`dotnet format analyzers` to rewrite the call site.
The analyzer emits a single diagnostic id, `TestablyM001`. Each call site carries a `pattern` property
in `Diagnostic.Properties` that tells the code-fix provider which rewrite to perform. Patterns without
an automatic rewrite still get a `TestablyM001` warning so you can locate them — the code fix just
declines to register an action.

| Diagnostic | Source library | Code fix title |
|----------------|------------------------|-------------------------------------------------------------|
| `TestablyM001` | System.IO.Abstractions | *Migrate System.IO.Abstractions MockFileSystem to Testably* |

## Supported migrations

### `MockFileSystem` constructors

| TestableIO | Testably |
|-----------------------------------------------------------|-------------------------------------------------------------------|
| `new MockFileSystem()` | `new MockFileSystem()` |
| `new MockFileSystem(IDictionary<string, MockFileData>)` | `new MockFileSystem()` followed by per-entry `Initialize…` calls |
| `new MockFileSystem(MockFileSystemOptions)` | `new MockFileSystem(o => o…)` with mapped option setters |
| `new MockFileSystem(IDictionary<…>, MockFileSystemOptions)` | combined dict expansion + options lambda |

### `IMockFileDataAccessor` methods on `MockFileSystem`

| TestableIO | Testably |
|---------------------------------------------|---------------------------------------------------------|
| `fs.AddFile(path, mockFileData)` | `fs.Initialize().With…(…)` (chain mapped from contents) |
| `fs.AddEmptyFile(path)` | `fs.File.Create(path).Dispose()` |
| `fs.AddDirectory(path)` | `fs.Directory.CreateDirectory(path)` |
| `fs.RemoveFile(path)` | `fs.File.Delete(path)` |
| `fs.MoveDirectory(source, dest)` | `fs.Directory.Move(source, dest)` |
| `fs.FileExists(path)` | `fs.File.Exists(path)` |
| `fs.AddDrive(name, mockDriveData)` | `fs.WithDrive(name, d => d.Set…(…))` (mapped setters) |
| `fs.AddFilesFromEmbeddedNamespace(path, assembly, prefix)` | `fs.InitializeEmbeddedResourcesFromAssembly(path, assembly, relativePath: …)` (when the assembly arg resolves statically and the prefix starts with the assembly name) |

### `MockFileData` property access

Reads of `MockFileData` properties (e.g. `fs.GetFile(path).LastWriteTime`) are routed to the
matching Testably file-system call (e.g. `fs.File.GetLastWriteTime(path)`). Writes
(e.g. `fs.GetFile(path).LastWriteTime = value`) become `fs.File.SetLastWriteTime(path, value)`. The
fixer only handles the one-shot `fs.GetFile(path).Prop` shape; property access through a captured
reference is left for manual review (see below).

## Manual review

These call sites are flagged with `TestablyM001` but have no automatic rewrite, either because
`Testably.Abstractions` has no equivalent surface or because a safe rewrite would require flow
analysis the analyzer does not perform. Address each one by hand.

| Pattern | Why manual |
|------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| `MockFileData.AccessControl` | Windows `FileSecurity` has no Testably equivalent. |
| `MockFileData.AllowedFileShare` | File-share locking has no Testably equivalent. |
| `MockFileData.UnixMode` | Unix file permissions have no Testably equivalent. |
| `new MockFileVersionInfo(...)` | File-version metadata has no Testably equivalent. |
| Subclassing `MockFileSystem` / `MockFileData` | Inheritance contract differs in Testably. |
| `new MockFileData(MockFileData template)` | Copy-clone semantics differ; no Testably equivalent. |
| Captured-reference `MockFileData` property access | `var data = fs.GetFile(path); data.Prop = …` cannot be rewritten without local flow analysis. |
| `fs.AllPaths` / `AllFiles` / `AllDirectories` / `AllDrives` | Testably has no enumeration properties; the natural replacements need a root or drive scope the analyzer cannot infer. |
| `fs.MockTime(Func<DateTime>)` | TestableIO calls the delegate per timestamp request; Testably installs a fixed-then-mutable `MockTimeSystem` at construction. No observably-equivalent automatic rewrite for arbitrary delegates. |
| `fs.AddFileFromEmbeddedResource(...)` | Testably exposes only a bulk `InitializeEmbeddedResourcesFromAssembly` with path-style matching; the single-file mapping is not safe to automate. |

## Suppressing the diagnostic

If you choose not to migrate a particular call site, suppress `TestablyM001` per usage with the
standard mechanisms:

```csharp
#pragma warning disable TestablyM001
var fs = new MockFileSystem();
#pragma warning restore TestablyM001
```

or via an `.editorconfig` entry scoped to the file/folder:

```ini
[**/Legacy/**.cs]
dotnet_diagnostic.TestablyM001.severity = none
```
2 changes: 1 addition & 1 deletion Source/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<Copyright>Copyright (c) 2026 - $([System.DateTime]::Now.ToString('yyyy')) Valentin Breuß</Copyright>
<RepositoryUrl>https://github.com/Testably/Testably.Abstractions.Migration.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageProjectUrl>https://docs.testably.org/Testably.Abstractions/Migration</PackageProjectUrl>
<PackageProjectUrl>https://docs.testably.org/Abstractions/Migration</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageIcon>Docs/logo.png</PackageIcon>
<PackageReadmeFile>Docs/README.md</PackageReadmeFile>
Expand Down
6 changes: 5 additions & 1 deletion Source/Testably.Abstractions.Migration.Analyzers/Rules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
{
private const string UsageCategory = "Usage";

private const string DocsBaseUrl =
"https://docs.testably.org/Abstractions/Migration";

Check warning on line 13 in Source/Testably.Abstractions.Migration.Analyzers/Rules.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code not to use hardcoded absolute paths or URIs.

See more on https://sonarcloud.io/project/issues?id=Testably_Testably.Abstractions.Migration&issues=AZ4rcrfuw9TY7I-gmAbl&open=AZ4rcrfuw9TY7I-gmAbl&pullRequest=18

/// <summary>
/// Migration rule for <c>System.IO.Abstractions.TestingHelpers</c> usage. Flags any usage of
/// <c>new MockFileSystem(...)</c>, <c>new MockFileData(...)</c> or the <c>IMockFileDataAccessor</c>
Expand All @@ -29,6 +32,7 @@
severity,
true,
new LocalizableResourceString(diagnosticId + "Description", Resources.ResourceManager,
typeof(Resources))
typeof(Resources)),
helpLinkUri: DocsBaseUrl
);
}
Loading