diff --git a/Docs/Nuget/Interface.md b/Docs/Nuget/Interface.md index 80611c317..e420b64db 100644 --- a/Docs/Nuget/Interface.md +++ b/Docs/Nuget/Interface.md @@ -16,5 +16,5 @@ dotnet add package Testably.Abstractions.Interface > Most users install [`Testably.Abstractions`](https://www.nuget.org/packages/Testably.Abstractions) (production) and [`Testably.Abstractions.Testing`](https://www.nuget.org/packages/Testably.Abstractions.Testing) (tests) instead - both pull these interfaces in transitively. - `IFileSystem` mirrors `System.IO` (`File`, `Directory`, `FileInfo`, `DirectoryInfo`, `FileStream`, `Path`, `DriveInfo`, `FileSystemWatcher`, `FileVersionInfo`). Constructors are exposed as factory methods (e.g. `fileSystem.FileInfo.New(path)`). Lives in the `System.IO.Abstractions` namespace, so it is source-compatible with [`TestableIO.System.IO.Abstractions`](https://github.com/TestableIO/System.IO.Abstractions). -- `ITimeSystem` covers `DateTime`, `Stopwatch`, `Task.Delay`, `Thread.Sleep`, `Timer` and (on supported targets) `PeriodicTimer`. +- `ITimeSystem` covers `DateTime`, `DateTimeOffset`, `Stopwatch`, `Task.Delay`, `Thread.Sleep`, `Timer`, `TimeZoneInfo` and (on .NET 8 or later) `PeriodicTimer`. - `IRandomSystem` covers `Random` (with a thread-safe `Shared` instance on all targets) and `Guid`. diff --git a/Docs/Nuget/MemoryMappedFiles.md b/Docs/Nuget/MemoryMappedFiles.md index cbbb25fc5..0ecb07a98 100644 --- a/Docs/Nuget/MemoryMappedFiles.md +++ b/Docs/Nuget/MemoryMappedFiles.md @@ -11,6 +11,8 @@ Memory-mapped file extensions for [`Testably.Abstractions`](https://www.nuget.or dotnet add package Testably.Abstractions.MemoryMappedFiles ``` +**Full documentation: [docs.testably.org/Abstractions/companion-libraries/memory-mapped-files](https://docs.testably.org/Abstractions/companion-libraries/memory-mapped-files)** + ```csharp IFileSystem fileSystem; // injected diff --git a/Docs/Nuget/Testing.md b/Docs/Nuget/Testing.md index 970c464f9..379b8d3df 100644 --- a/Docs/Nuget/Testing.md +++ b/Docs/Nuget/Testing.md @@ -17,7 +17,7 @@ The test suite runs every assertion against both the real and the mocked file sy - **MockFileSystem** - fluent `Initialize()` API, multiple drives with size limits, `FileSystemWatcher`, `SafeFileHandle`, file-version metadata and unix file modes. - **Cross-platform simulation** - run a Linux, macOS or Windows file system regardless of the host via `new MockFileSystem(o => o.SimulatingOperatingSystem(SimulationMode.Linux))`. -- **Intercept and Notify** - inject exceptions before a file-system or time-system operation completes, or react to them after the fact (including replay of events that fired before the subscription). +- **Intercept and Notify** - inject exceptions before a file-system operation completes, or react to it after the fact (including replay of events that fired before the subscription). The time system offers the same notifications for sleeps, delays and timer ticks. - **MockTimeSystem** - control `DateTime.Now`, advance time manually or via auto-advance, mock `Timer`/`PeriodicTimer` with persistent execution counters. - **MockRandomSystem** - seed `Random` and `Guid.NewGuid()` for reproducible tests. - **Statistics** - inspect how the system-under-test used each abstraction (`fileSystem.Statistics`). diff --git a/Docs/pages/docs/companion-libraries/access-control.mdx b/Docs/pages/docs/companion-libraries/access-control.mdx index f795680b5..3324a7837 100644 --- a/Docs/pages/docs/companion-libraries/access-control.mdx +++ b/Docs/pages/docs/companion-libraries/access-control.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 2 title: Access control +description: GetAccessControl and SetAccessControl on IFileSystem, so ACL code runs against the real and the mocked file system. --- # Testably.Abstractions.AccessControl @@ -39,7 +40,7 @@ FileSecurity fileAcl = fileSystem.File.GetAccessControl("secret.txt"); On `RealFileSystem` the calls forward to `System.IO.FileSystemAclExtensions`. On `MockFileSystem` the ACL object is stored as metadata via [`IFileSystemExtensibility`](../file-system/extensibility) and round-tripped - set it, read it back, and it will be the same object. -To simulate *enforcement* (denying a read or write based on the ACL), combine the package with [`MockFileSystem.WithAccessControlStrategy(...)`](../file-system/access-control). +The mock does not enforce the stored ACL. To simulate denied access, combine the package with [`MockFileSystem.WithAccessControlStrategy(...)`](../file-system/access-control). :::warning[Windows-only] All ACL APIs are marked `[SupportedOSPlatform("windows")]`. Calling them on Linux/macOS throws `PlatformNotSupportedException`. diff --git a/Docs/pages/docs/companion-libraries/compression.mdx b/Docs/pages/docs/companion-libraries/compression.mdx index 9dd49b548..ea0e50e65 100644 --- a/Docs/pages/docs/companion-libraries/compression.mdx +++ b/Docs/pages/docs/companion-libraries/compression.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 1 title: Compression +description: ZipFile and ZipArchive on IFileSystem, so zip code runs against the real and the mocked file system. --- # Testably.Abstractions.Compression @@ -15,7 +16,7 @@ dotnet add package Testably.Abstractions.Compression ## Why this package exists -`System.IO.Compression.ZipFile` operates on real `string` paths and `System.IO.Compression.ZipArchive` operates on `Stream`s - neither knows about `IFileSystem`. Without the companion package, bridging the two means writing your own glue: +`System.IO.Compression.ZipFile` operates on real `string` paths and `System.IO.Compression.ZipArchive` operates on `Stream`s - neither knows about `IFileSystem`. The package bridges the two: ```csharp // With Testably.Abstractions.Compression - one line: @@ -58,18 +59,9 @@ Extraction looks similar: open a `ZipArchive` in `Read` mode, loop the entries, The package replaces this glue with a one-line call. Beyond brevity, it also gives you stream/path overloads on both sides, async variants on .NET 10+, and an `IZipArchive` / `IZipArchiveEntry` wrapper so the abstraction extends through the archive itself rather than stopping at the zip boundary. -### Production uses the real BCL implementation - -The wrapper isn't just a uniform façade - at runtime it forwards every call to the matching `System.IO.Compression.ZipFile` / `ZipArchive` method when `IFileSystem` is a `RealFileSystem`, and to an in-memory equivalent only when it's a `MockFileSystem`: - -```csharp -// Inside ZipFileWrapper.CreateFromDirectory: -Execute.WhenRealFileSystem(FileSystem, - () => ZipFile.CreateFromDirectory(sourceDirectoryName, destination), // real → BCL - () => ZipUtilities.CreateFromDirectory(FileSystem, sourceDirectoryName, destination)); // mock → in-memory -``` - -That means production code keeps every native optimisation the BCL ships - kernel-level streaming, no extra `MemoryStream` allocation, the latest deflate tuning - while tests run against a deterministic in-memory implementation. You don't trade performance for testability. +:::info[Production uses the real implementation] +On `RealFileSystem`, every call forwards to the matching `System.IO.Compression` method, so production code keeps the performance of the base class library (BCL). Only `MockFileSystem` uses an in-memory implementation. +::: ## ZipFile diff --git a/Docs/pages/docs/companion-libraries/index.mdx b/Docs/pages/docs/companion-libraries/index.mdx index d7d74f674..7e4c8acf4 100644 --- a/Docs/pages/docs/companion-libraries/index.mdx +++ b/Docs/pages/docs/companion-libraries/index.mdx @@ -1,5 +1,6 @@ --- title: Companion libraries +description: Optional packages that add compression, access control and memory-mapped files to IFileSystem. --- # Companion libraries @@ -8,8 +9,8 @@ title: Companion libraries | Package | Adds to `IFileSystem` | |------------------------------------------|--------------------------------------------------------------------| -| `Testably.Abstractions.Compression` | `ZipFile` and `ZipArchive` extension properties | -| `Testably.Abstractions.AccessControl` | `GetAccessControl` / `SetAccessControl` on files and directories | -| `Testably.Abstractions.MemoryMappedFiles`| `MemoryMappedFile` extension property | +| [`Testably.Abstractions.Compression`](./compression) | `ZipFile` and `ZipArchive` extension properties | +| [`Testably.Abstractions.AccessControl`](./access-control) | `GetAccessControl` / `SetAccessControl` on files and directories | +| [`Testably.Abstractions.MemoryMappedFiles`](./memory-mapped-files)| `MemoryMappedFile` extension property | All packages target the `IFileSystem` interface, so they work transparently against either `RealFileSystem` or `MockFileSystem`. diff --git a/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx b/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx index 0449705ae..94b18901d 100644 --- a/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx +++ b/Docs/pages/docs/companion-libraries/memory-mapped-files.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 3 title: Memory-mapped files +description: MemoryMappedFile on IFileSystem, so memory-mapped file code can be tested against the MockFileSystem. --- # Testably.Abstractions.MemoryMappedFiles @@ -30,22 +31,9 @@ int value = accessor.ReadInt32(0); The same code runs unchanged against `RealFileSystem` and `MockFileSystem`. -### Production uses the real base class library (BCL) implementation - -The abstraction isn't just a uniform façade. When `IFileSystem` is a `RealFileSystem`, every call forwards to the underlying BCL `MemoryMappedFile`, so production keeps the native memory-mapping semantics unchanged. Only when `IFileSystem` is a `MockFileSystem` are the views built directly over the in-memory file bytes: - -```csharp -// Inside MemoryMappedFileFactory.CreateFromFile: -if (extensibility.TryGetWrappedInstance(out FileStream? realStream)) -{ - // real → BCL MemoryMappedFile over the real FileStream - return new MemoryMappedFileWrapper(FileSystem, - MemoryMappedFile.CreateFromFile(realStream, ...), backingStream); -} - -// mock → view over the in-memory file bytes -return new MemoryMappedFileMock(FileSystem, stream, capacity, access, ownsStream); -``` +:::info[Production uses the real implementation] +On `RealFileSystem`, every call forwards to the base class library (BCL) `MemoryMappedFile`, so production keeps the native memory-mapping semantics. Only on `MockFileSystem` are the views built over the in-memory file bytes. +::: ## Creating a memory-mapped file @@ -84,32 +72,38 @@ stream.Write([1, 2, 3, 4], 0, 4); `IMemoryMappedViewAccessor` mirrors `MemoryMappedViewAccessor`: the primitive `Read*`/`Write` overloads, the generic `Read`/`Write` for structs, `ReadArray`/`WriteArray`, plus `Capacity`, `CanRead`, `CanWrite`, `PointerOffset` and `Flush`. -`MemoryMappedFileSystemViewStream` derives from `UnmanagedMemoryStream` - the same base class as the real `MemoryMappedViewStream` - and re-declares the `Capacity` member on top of the stream members (the base is not backed by unmanaged memory), plus the `PointerOffset` member specific to `MemoryMappedViewStream`. +`MemoryMappedFileSystemViewStream` derives from `UnmanagedMemoryStream`, like the real `MemoryMappedViewStream`, and adds its `Capacity` and `PointerOffset` members. + +## Behaviour on the mock + +On `MockFileSystem` the reads and writes, the capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`, including: + +- **Default view access is `ReadWrite`** - creating a writable view over a read-only mapping throws `UnauthorizedAccessException`, exactly as the BCL does. +- **The file and its views have independent lifetimes** - a view stays usable after the `IMemoryMappedFile` is disposed. +- **`CopyOnWrite` views privatize pages on write** - their writes are neither persisted nor visible to other views, while pages they have not written keep reflecting changes made through other views. -## Not supported on the mock +### Not supported -Some parts of the BCL surface operate on operating-system shared memory rather than a file, so they have no meaningful in-memory equivalent. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`: +These parts of the BCL operate on operating-system shared memory rather than a file. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`: - `CreateNew` - `CreateOrOpen` *(Windows-only)* - `OpenExisting` *(Windows-only)* -- A non-null `mapName` in `CreateFromFile` (named mappings are operating-system shared-memory objects) - -The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, as well as the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all. Since no unmanaged memory backs a `MemoryMappedFileSystemViewStream`, the members that only exist on its `UnmanagedMemoryStream` base throw `ObjectDisposedException`: the unsafe `PositionPointer`, and `Capacity` when accessed through a reference typed as `UnmanagedMemoryStream` instead of `MemoryMappedFileSystemViewStream`. +- A non-null `mapName` in `CreateFromFile` (named mappings are shared-memory objects) -## Behaviour on the mock +The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, and the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all. The unsafe `PositionPointer` of a view stream throws `ObjectDisposedException`, because no unmanaged memory backs it. -On `MockFileSystem` the reads and writes, the capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`, including: +### Differences from the real file system -- **Default view access is `ReadWrite`** - creating a writable view over a read-only mapping throws `UnauthorizedAccessException`, exactly as the BCL does. -- **The file and its views have independent lifetimes** - a view stays usable after the `IMemoryMappedFile` is disposed. -- **`CopyOnWrite` views privatize pages on write** - writes are neither persisted to the underlying file nor visible to other views, while pages the view has not written keep reflecting later changes made through other views, matching the lazy page privatization (4096-byte granularity) of real copy-on-write views. +
+Details tied to operating-system memory mapping that the mock simplifies -Some details tied to operating-system memory mapping are intentionally simplified: +- **View size:** a view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the page size), and `PointerOffset` is always `0`. +- **Visibility of writes:** writes are immediately visible to all views of the same mapping, but reach the underlying file (and other streams of it) only through the backing stream, at the latest when a view is flushed or disposed. The real operating system makes them visible to other readers immediately. +- **Disposed backing stream:** when the stream passed to `CreateFromFile` with `leaveOpen: true` is disposed while views are open, operations on those views throw `ObjectDisposedException`. Real views keep working. +- **Truncation:** truncating the backing stream while views are open succeeds, and reads of the truncated range return zeros. The real operating system rejects the truncation. +- **Size:** the mock keeps the whole file in memory, so a memory-mapped file is limited to 2 GB. Growing it beyond that throws `NotSupportedException`. +- **Exceptions:** exception types and messages follow the Windows behaviour of the BCL, also on Linux and macOS and for every simulated operating system. +- **Struct size on .NET Framework:** `Read`/`Write`/`ReadArray`/`WriteArray` size structs by their managed layout, like .NET (Core). The .NET Framework uses the marshalled size, so the two disagree for structs such as one containing a `bool`. -- A view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the system page size), and `PointerOffset` is always `0`. -- The views of the mock read and write through the backing stream instead of mapped memory. Writes are immediately visible to all views of the same mapping, but reach the underlying file (and other open streams of it) only when a view is flushed or disposed, or earlier when the backing stream persists its pending writes on a reposition - the real operating system makes them visible to other readers immediately. For the same reason, when the stream passed to `CreateFromFile` with `leaveOpen: true` is disposed while views are still open, operations on those views throw `ObjectDisposedException` (disposing them stays safe), whereas real views keep operating on the mapped pages without the file handle. -- The mock does not track the open mappings of a file, so truncating the backing stream (for example via `SetLength` on a stream passed with `leaveOpen: true`) while views are open succeeds, and reads of the truncated range afterwards return zeros. The real operating system rejects such a truncation (on Windows with an `IOException` about an open user-mapped section). -- The mocked file system stores the complete file content in memory, which limits the size of a memory-mapped file to 2 GB. Growing a file beyond that throws a `NotSupportedException`, whereas the real file system creates sparse multi-gigabyte mappings. -- Exception types and messages of the mock follow the Windows behaviour of the BCL, regardless of the operating system the tests run on and of the simulated operating system of the `MockFileSystem`. On Linux and macOS the real file system can throw different exception types for the same invalid call (for example when creating a view with execute access or growing a read-only mapping). -- Structs in `Read`/`Write`/`ReadArray`/`WriteArray` are sized by their managed layout (`Unsafe.SizeOf`), matching the BCL on .NET (Core). The real `MemoryMappedViewAccessor` of the .NET Framework uses the marshalled size instead, so on .NET Framework the mock and the real accessor disagree for structs whose marshalled size differs from the managed one (for example a struct containing a `bool`: 1 byte managed, 4 bytes marshalled). +
diff --git a/Docs/pages/docs/file-system/access-control.mdx b/Docs/pages/docs/file-system/access-control.mdx index c40f404d9..b7e8a500f 100644 --- a/Docs/pages/docs/file-system/access-control.mdx +++ b/Docs/pages/docs/file-system/access-control.mdx @@ -1,21 +1,25 @@ --- sidebar_position: 7 -title: Access control (ACL) +title: Simulating access denial (ACL) +sidebar_label: Access denial (ACL) +description: Deny access to selected paths in the MockFileSystem to test how code handles missing Windows permissions. --- -# Access control (ACL) +# Simulating access denial (ACL) -To simulate Windows ACL-based access restrictions, `MockFileSystem` accepts an `IAccessControlStrategy` via `WithAccessControlStrategy(...)`. +To simulate Windows access control list (ACL) restrictions, register an `IAccessControlStrategy` with `WithAccessControlStrategy(...)`. -The default implementation takes a callback `(path, extensibility) => bool` - return `false` to deny access to that path. When access is denied, the next read/write operation throws `IOException` with the message `"Access to the path '{fullPath}' is denied."`, matching what the real Windows file system does. +The built-in `DefaultAccessControlStrategy` takes a callback `(fullPath, extensibility) => bool` - return `false` to deny access to that path. Operations on a denied path then throw `UnauthorizedAccessException` with the message `"Access to the path '{fullPath}' is denied."`, like the real Windows file system: ```csharp MockFileSystem fileSystem = new(); +// highlight-start fileSystem.WithAccessControlStrategy( new DefaultAccessControlStrategy((path, _) => !path.EndsWith("secret.txt"))); +// highlight-end -fileSystem.File.WriteAllText("secret.txt", "x"); // throws IOException fileSystem.File.WriteAllText("public.txt", "x"); // ok +fileSystem.File.WriteAllText("secret.txt", "x"); // throws UnauthorizedAccessException ``` :::warning[Windows-only] @@ -24,4 +28,4 @@ fileSystem.File.WriteAllText("public.txt", "x"); // ok ## Reading and writing ACL objects -For full `FileSecurity` / `DirectorySecurity` support (the actual `GetAccessControl` / `SetAccessControl` APIs), install the [`Testably.Abstractions.AccessControl`](../companion-libraries/access-control) companion package. It adds extension methods on `IFile`, `IFileInfo`, `IDirectory`, `IDirectoryInfo` and `FileSystemStream` that store ACL objects on the mock and round-trip them on the real file system. +This page is about *denying* access. To read and write `FileSecurity` / `DirectorySecurity` objects (the `GetAccessControl` / `SetAccessControl` APIs), install the [`Testably.Abstractions.AccessControl`](../companion-libraries/access-control) companion package. diff --git a/Docs/pages/docs/file-system/drives.mdx b/Docs/pages/docs/file-system/drives.mdx index 0d0a8b66b..11362dd75 100644 --- a/Docs/pages/docs/file-system/drives.mdx +++ b/Docs/pages/docs/file-system/drives.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 3 title: Drives +description: Add drives with size limits, UNC shares and removable drives to the MockFileSystem. --- # Drives @@ -40,35 +41,39 @@ When a drive runs out of space, every file operation that writes content throws ```csharp MockFileSystem fileSystem = new MockFileSystem() - .WithDrive("C:\\", d => d.SetTotalSize(100)); + .WithDrive(d => d.SetTotalSize(100)); IDriveInfo drive = fileSystem.GetDefaultDrive(); -drive.AvailableFreeSpace; // 100 +await Expect.That(drive.AvailableFreeSpace).IsEqualTo(100); fileSystem.File.WriteAllText("foo.txt", "This is a text with 29 bytes."); -drive.AvailableFreeSpace; // 71 +await Expect.That(drive.AvailableFreeSpace).IsEqualTo(71); fileSystem.File.AppendAllText("foo.txt", "Another 17 bytes."); -drive.AvailableFreeSpace; // 54 +await Expect.That(drive.AvailableFreeSpace).IsEqualTo(54); -fileSystem.File.WriteAllBytes("bar.bin", new byte[90]); // throws IOException -drive.AvailableFreeSpace; // still 54 - the failed write did not consume space +void WriteTooMuch() => fileSystem.File.WriteAllBytes("bar.bin", new byte[90]); +await Expect.That(WriteTooMuch).Throws(); +await Expect.That(drive.AvailableFreeSpace).IsEqualTo(54) + .Because("the failed write did not consume space"); fileSystem.File.Delete("foo.txt"); -drive.AvailableFreeSpace; // back to 100 +await Expect.That(drive.AvailableFreeSpace).IsEqualTo(100); ``` ## UNC drives -`WithUncDrive("//server")` registers a Universal Naming Convention path so reads and writes against `\\server\share\...` succeed in the mock: +`WithUncDrive("server")` registers a Universal Naming Convention (UNC) server, so paths like `\\server\share\...` resolve in the mock. Like a local drive, it starts empty, so create the share directory before writing into it: ```csharp MockFileSystem fileSystem = new(); -fileSystem.WithUncDrive(@"//build-server"); +fileSystem.WithUncDrive("build-server"); -fileSystem.File.WriteAllText(@"//build-server/share/foo.txt", "hi"); -IDriveInfo drive = fileSystem.DriveInfo.New(@"//build-server"); -drive.IsReady; // true +fileSystem.Directory.CreateDirectory("//build-server/share"); +fileSystem.File.WriteAllText("//build-server/share/foo.txt", "hi"); + +IDriveInfo drive = fileSystem.DriveInfo.New("//build-server"); +await Expect.That(drive.IsReady).IsTrue(); ``` UNC drives don't appear in `DriveInfo.GetDrives()` - they're addressable but not enumerated, matching the real behaviour. @@ -85,11 +90,11 @@ The callback passed to `WithDrive(...)` receives an `IStorageDrive` whose setter | Setter | What it changes | |--------------------------------|----------------------------------------------------------------------------------| -| `SetTotalSize(long)` | Total disk capacity (default reset value: a generous default size) | +| `SetTotalSize(long)` | Total disk capacity (defaults to 1 GB) | | `SetDriveFormat(string)` | The reported `DriveFormat` (e.g. `"NTFS"`, `"ext4"`) | | `SetDriveType(DriveType)` | The reported `DriveType` (Fixed, Removable, Network, …) | | `SetIsReady(bool)` | Whether the drive reports itself as ready - simulate a removable drive ejecting | -| `ChangeUsedBytes(long delta)` | Adjust used space by a delta. Throws `IOException` if free space goes negative | +| `ChangeUsedBytes(long delta)` | Mark space as used (or free) without writing files. Throws `IOException` if free space goes negative | ```csharp MockFileSystem fileSystem = new(); diff --git a/Docs/pages/docs/file-system/extensibility.mdx b/Docs/pages/docs/file-system/extensibility.mdx index cc270711b..842c1bd30 100644 --- a/Docs/pages/docs/file-system/extensibility.mdx +++ b/Docs/pages/docs/file-system/extensibility.mdx @@ -1,12 +1,17 @@ --- sidebar_position: 12 title: Extensibility +description: Attach custom metadata to entries of the MockFileSystem when writing strategies or extension methods. --- # Extensibility `IFileSystemExtensibility` is the hook that custom strategies (ACL, Unix file mode) use to attach extra data to the mock without bloating the public `IFileSystem` surface. +:::info +You only need `IFileSystemExtensibility` when you write a custom strategy or extension method. Day-to-day test code never touches it. +::: + ```csharp public interface IFileSystemExtensibility { @@ -40,5 +45,3 @@ public void OnSetUnixFileMode(string fullPath, extensibility.StoreMetadata("owner", CurrentUser); } ``` - -You only need to think about `IFileSystemExtensibility` when you are writing a custom strategy or extension method - day-to-day test code uses the higher-level APIs above and never touches it directly. diff --git a/Docs/pages/docs/file-system/file-system-watcher.mdx b/Docs/pages/docs/file-system/file-system-watcher.mdx index aa7d74f75..a7cc5401c 100644 --- a/Docs/pages/docs/file-system/file-system-watcher.mdx +++ b/Docs/pages/docs/file-system/file-system-watcher.mdx @@ -1,8 +1,12 @@ --- sidebar_position: 4 title: FileSystemWatcher +description: Use the mocked FileSystemWatcher, which raises the same OS-specific events as the real one. --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # FileSystemWatcher `MockFileSystem` ships a fully functional [`FileSystemWatcher`](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) replacement. It fires the same events as the real one - and crucially, it fires the **OS-specific** events that the real watcher fires on Linux, Windows and macOS. @@ -12,6 +16,7 @@ title: FileSystemWatcher ```csharp var fileSystem = new MockFileSystem(); fileSystem.Initialize().WithSubdirectory("watched"); +fileSystem.File.WriteAllText("watched/a.txt", "hi"); using var watcher = fileSystem.FileSystemWatcher.New("watched"); var deleted = new ManualResetEventSlim(); @@ -19,14 +24,14 @@ var deleted = new ManualResetEventSlim(); watcher.Deleted += (_, _) => deleted.Set(); watcher.EnableRaisingEvents = true; -fileSystem.Directory.Delete("watched"); +fileSystem.File.Delete("watched/a.txt"); await Expect.That(deleted.Wait(TimeSpan.FromSeconds(1))).IsTrue(); ``` ## Reacting to triggered events from a test -`MockFileSystem.Watcher.OnTriggered(...)` returns an awaitable callback that fires after every `IFileSystemWatcher` event. Use it to assert what a watcher saw without subscribing to the event yourself: +`MockFileSystem.Watcher.OnTriggered(...)` returns an [awaitable callback](./intercept-and-notify) that fires after every `IFileSystemWatcher` event. Use it to assert what a watcher saw without subscribing to the event yourself: ```csharp MockFileSystem fileSystem = new(); @@ -35,47 +40,84 @@ fileSystem.Initialize().WithSubdirectory("watched"); using var watcher = fileSystem.FileSystemWatcher.New("watched"); watcher.EnableRaisingEvents = true; -using var triggered = fileSystem.Watcher.OnTriggered( - change => { /* inspect ChangeDescription */ }); +// highlight-next-line +using var triggered = fileSystem.Watcher.OnTriggered(); fileSystem.File.WriteAllText("watched/a.txt", "hi"); -ChangeDescription[] events = triggered.Wait(count: 1); +WatcherChangeDescription[] events = triggered.Wait(count: 1); +``` + +## Which events are raised + +When an entry is moved, the events depend on where it comes from and where it goes, relative to the watched path: + +```text +outside/ ← Outside +watched/ ← the watched path +├── a.txt ← Inside +└── sub/ + ├── b.txt ← Nested + └── deeper/ + └── c.txt ← Deep ``` -## Event tables (per OS) - -The watcher fires different events depending on whether the source/target is **inside**, **nested** under, **deeply nested** under, or **outside** the watched path. The table below lists every combination for both values of `IncludeSubdirectories` - it is also the contract that the `MockFileSystem` watcher honours when simulating other operating systems. - -| Location | Description | -|----------|--------------------------------------------------------| -| `_` | Entry was created or deleted | -| Outside | Outside the watching path | -| Inside | Immediately inside the watching path | -| Nested | Nested under the watching path | -| Deep | Deeply nested under the watching path | - -Each cell is `IncludeSubdirectories = false / IncludeSubdirectories = true`. When both values match, only one is shown. `–` means no event is raised. - -| From → To | Linux | Windows | Mac | -|-------------------|---------------------------|-------------------------------------|---------------------------| -| `_` → Inside | `Created` | `Created` | `Created` | -| Inside → `_` | `Deleted` | `Deleted` | `Deleted` | -| `_` → Nested | – / `Created` | – / `Created` | – / `Created` | -| Nested → `_` | – / `Deleted` | – / `Deleted` | – / `Deleted` | -| `_` → Deep | – / `Created` | – / `Created` | – / `Created` | -| Deep → `_` | – / `Deleted` | – / `Deleted` | – / `Deleted` | -| Outside → Inside | `Created` | `Created` | `Created` | -| Inside → Inside | `Renamed` | `Renamed` | `Renamed` | -| Inside → Outside | `Deleted` | `Deleted` | `Deleted` | -| Outside → Nested | – / `Created` | – / `Created` | – / `Created` | -| Nested → Nested | – / `Renamed` | – / `Renamed` | – / `Renamed` | -| Nested → Outside | – / `Deleted` | – / `Deleted` | – / `Deleted` | -| Outside → Deep | – / `Created` | – / `Created` | – / `Created` | -| Deep → Deep | – / `Renamed` | – / `Renamed` | – / `Renamed` | -| Deep → Outside | – / `Deleted` | – / `Deleted` | – / `Deleted` | -| Inside → Nested | `Deleted` / `Renamed` | `Deleted` / `Deleted`+`Created` | `Renamed` | -| Inside → Deep | `Deleted` / `Renamed` | `Deleted` / `Deleted`+`Created` | `Renamed` | -| Nested → Inside | `Created` / `Renamed` | `Created` / `Deleted`+`Created` | `Created` / `Renamed` | -| Deep → Inside | `Created` / `Renamed` | `Created` / `Deleted`+`Created` | `Created` / `Renamed` | -| Nested → Deep | – / `Renamed` | – / `Deleted`+`Created` | – / `Renamed` | -| Deep → Nested | – / `Renamed` | – / `Deleted`+`Created` | – / `Renamed` | +`_` stands for "nothing", i.e. the entry is created (`_ → Inside`) or deleted (`Inside → _`), and `-` means no event is raised. Pick the value of `IncludeSubdirectories`: + + + + +| From → To | Linux | Windows | macOS | +|-------------------|-----------|-----------|-----------| +| `_` → Inside | `Created` | `Created` | `Created` | +| Inside → `_` | `Deleted` | `Deleted` | `Deleted` | +| `_` → Nested | - | - | - | +| Nested → `_` | - | - | - | +| `_` → Deep | - | - | - | +| Deep → `_` | - | - | - | +| Outside → Inside | `Created` | `Created` | `Created` | +| Inside → Inside | `Renamed` | `Renamed` | `Renamed` | +| Inside → Outside | `Deleted` | `Deleted` | `Deleted` | +| Outside → Nested | - | - | - | +| Nested → Nested | - | - | - | +| Nested → Outside | - | - | - | +| Outside → Deep | - | - | - | +| Deep → Deep | - | - | - | +| Deep → Outside | - | - | - | +| Inside → Nested | `Deleted` | `Deleted` | `Renamed` | +| Inside → Deep | `Deleted` | `Deleted` | `Renamed` | +| Nested → Inside | `Created` | `Created` | `Created` | +| Deep → Inside | `Created` | `Created` | `Created` | +| Nested → Deep | - | - | - | +| Deep → Nested | - | - | - | + + + + +| From → To | Linux | Windows | macOS | +|-------------------|-----------|-----------------------|-----------| +| `_` → Inside | `Created` | `Created` | `Created` | +| Inside → `_` | `Deleted` | `Deleted` | `Deleted` | +| `_` → Nested | `Created` | `Created` | `Created` | +| Nested → `_` | `Deleted` | `Deleted` | `Deleted` | +| `_` → Deep | `Created` | `Created` | `Created` | +| Deep → `_` | `Deleted` | `Deleted` | `Deleted` | +| Outside → Inside | `Created` | `Created` | `Created` | +| Inside → Inside | `Renamed` | `Renamed` | `Renamed` | +| Inside → Outside | `Deleted` | `Deleted` | `Deleted` | +| Outside → Nested | `Created` | `Created` | `Created` | +| Nested → Nested | `Renamed` | `Renamed` | `Renamed` | +| Nested → Outside | `Deleted` | `Deleted` | `Deleted` | +| Outside → Deep | `Created` | `Created` | `Created` | +| Deep → Deep | `Renamed` | `Renamed` | `Renamed` | +| Deep → Outside | `Deleted` | `Deleted` | `Deleted` | +| Inside → Nested | `Renamed` | `Deleted` + `Created` | `Renamed` | +| Inside → Deep | `Renamed` | `Deleted` + `Created` | `Renamed` | +| Nested → Inside | `Renamed` | `Deleted` + `Created` | `Renamed` | +| Deep → Inside | `Renamed` | `Deleted` + `Created` | `Renamed` | +| Nested → Deep | `Renamed` | `Deleted` + `Created` | `Renamed` | +| Deep → Nested | `Renamed` | `Deleted` + `Created` | `Renamed` | + + + + +The mock follows the host OS, or the OS chosen with [`SimulatingOperatingSystem`](./simulating-os). diff --git a/Docs/pages/docs/file-system/file-version-info.mdx b/Docs/pages/docs/file-system/file-version-info.mdx index 2d3ebd748..49cddbea4 100644 --- a/Docs/pages/docs/file-system/file-version-info.mdx +++ b/Docs/pages/docs/file-system/file-version-info.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 11 title: FileVersionInfo +description: Return deterministic FileVersionInfo values for files in the MockFileSystem. --- # FileVersionInfo diff --git a/Docs/pages/docs/file-system/index.mdx b/Docs/pages/docs/file-system/index.mdx index 6a4a5bf70..da3e374d5 100644 --- a/Docs/pages/docs/file-system/index.mdx +++ b/Docs/pages/docs/file-system/index.mdx @@ -1,7 +1,10 @@ --- title: File system +description: IFileSystem abstracts System.IO; MockFileSystem is its in-memory implementation for tests. --- +import DocCardList from '@theme/DocCardList'; + # IFileSystem `IFileSystem` abstracts away every I/O type from `System.IO` so you can inject and replace it. The interface is **API-compatible** with [TestableIO.System.IO.Abstractions](https://github.com/TestableIO/System.IO.Abstractions): production code written against one library works with the other. @@ -54,4 +57,6 @@ MockFileSystem fileSystem = new(o => o The mock also exposes `MockFileSystem.TimeSystem` and `MockFileSystem.RandomSystem` if you didn't wire them in yourself - use them to assert against the same instances the file system is using internally. -The remaining pages in this section cover how to seed it, simulate other operating systems, model drives, react to events, gather statistics, read and write at explicit file offsets and plug in custom strategies for ACLs, Unix file modes, safe handles and version info. +## In this section + + diff --git a/Docs/pages/docs/file-system/initialization.mdx b/Docs/pages/docs/file-system/initialization.mdx index 5363a22b1..c0ed4ed0b 100644 --- a/Docs/pages/docs/file-system/initialization.mdx +++ b/Docs/pages/docs/file-system/initialization.mdx @@ -1,18 +1,25 @@ --- sidebar_position: 1 title: Initialization +description: Seed a MockFileSystem declaratively, fluently, from a real directory or from embedded resources. --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # Initialization You can seed a `MockFileSystem` declaratively (with descriptions), fluently (with a builder), or by copying data in from outside the test. -The first two snippets create: +## Declarative or fluent + +Both styles below create: - a directory `foo/` containing a sub-directory `bar/` and an empty file `bar.txt` - a file `foo.txt` with content `"some file content"` -## Declarative + + ```csharp var fileSystem = new MockFileSystem(); @@ -32,9 +39,8 @@ fileSystem.Initialize().With( new FileDescription("config.json", "{ \"x\": 1 }") { IsReadOnly = true }); ``` -Pass a `byte[]` to seed binary content, and set `IsReadOnly = true` to create the file with the read-only attribute. - -## Fluent + + ```csharp var fileSystem = new MockFileSystem(); @@ -47,6 +53,9 @@ fileSystem.Initialize() Use `HasStringContent(string)` or `HasBytesContent(byte[])` on the file manipulator to set content. + + + ## Initializing inside a specific directory ```csharp diff --git a/Docs/pages/docs/file-system/intercept-and-notify.mdx b/Docs/pages/docs/file-system/intercept-and-notify.mdx index 133d7fec8..ec14a14b2 100644 --- a/Docs/pages/docs/file-system/intercept-and-notify.mdx +++ b/Docs/pages/docs/file-system/intercept-and-notify.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 5 title: Intercept & notify +description: Abort file system operations before they complete, or wait for them after they completed. --- # Intercept & notify @@ -11,13 +12,13 @@ title: Intercept & notify - **`Notify`** runs **after** an operation completes and supports awaiting the next event. - **`Watcher`** runs **after** the `IFileSystemWatcher` raised an event - see [FileSystemWatcher](./file-system-watcher#reacting-to-triggered-events-from-a-test). -All three return an `IAwaitableCallback` with the same shape: +All three return an `IAwaitableCallback` (with `T` being `ChangeDescription` for `Intercept` and `Notify`, and `WatcherChangeDescription` for `Watcher`): - `Dispose()` removes the subscription. - `Wait(int count = 1, TimeSpan? timeout = null)` blocks until `count` events arrived; defaults to a 30-second timeout. - `WaitAsync(...)` is the async counterpart and additionally accepts a `CancellationToken`. -This page describes that `count`/`timeout` shape once and just shows the registration calls below. +The time system's [notifications](../time-system/notifications) use the same `IAwaitableCallback`. ## Intercept - abort an operation @@ -43,19 +44,42 @@ MockFileSystem fileSystem = new(); using var notify = fileSystem.Notify .OnCreated(FileSystemTypes.File); -fileSystem.File.Create("some-file.txt"); +fileSystem.File.WriteAllText("some-file.txt", "x"); ChangeDescription[] events = notify.Wait(count: 1); -// events[0].Path == "some-file.txt" +await Expect.That(events[0].Name).IsEqualTo("some-file.txt"); ``` -The recommended pattern is: register the callback, do the work, then call `Wait` (or `WaitAsync` for an async test). +:::tip +Register the callback first, then do the work, then call `Wait` (or `WaitAsync` in an async test). This is deterministic, unlike polling with `Task.Delay`. +::: `OnChanged`, `OnCreated` and `OnDeleted` are all available, with the same `globPattern` / `predicate` filters as `Intercept`. +## Events that happened before subscribing + +`OnEventOrReplay` works like `OnEvent`, but also replays the matching changes that happened before the subscription, in their original order. That helps when the work is already done by the time the test can subscribe: + +```csharp +MockFileSystem fileSystem = new(); +fileSystem.File.WriteAllText("early.txt", "x"); + +using var notify = fileSystem.Notify.OnEventOrReplay( + predicate: c => c.ChangeType == WatcherChangeTypes.Created); + +ChangeDescription[] events = notify.Wait(count: 1); +await Expect.That(events[0].Name).IsEqualTo("early.txt"); +``` + +`fileSystem.Watcher.OnTriggeredOrReplay(...)` does the same for watcher events. The history is recorded by default; `new MockFileSystem(o => o.WithoutNotificationHistory())` turns it off, and both methods then throw `InvalidOperationException`. + ## Streaming events -On .NET 6+ you can stream the same callback as an async sequence: +:::note[.NET 6 or later] +`ToAsyncEnumerable` is only available on .NET 6 and later. +::: + +You can stream the same callback as an async sequence: ```csharp await foreach (ChangeDescription change in @@ -65,4 +89,4 @@ await foreach (ChangeDescription change in } ``` -A 30-second default timeout prevents the loop from hanging forever when no events arrive; pass an explicit `timeout` (`TimeSpan?` or `int` milliseconds) or a `CancellationToken` to override it. +The sequence ends without an exception after 30 seconds, so the loop cannot hang forever. Pass an explicit `timeout` (`TimeSpan?` or `int` milliseconds) or a `CancellationToken` to end it earlier or later. diff --git a/Docs/pages/docs/file-system/random-access.mdx b/Docs/pages/docs/file-system/random-access.mdx index c54906932..43bb9a41c 100644 --- a/Docs/pages/docs/file-system/random-access.mdx +++ b/Docs/pages/docs/file-system/random-access.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 10 title: RandomAccess +description: Read and write files at explicit offsets through a SafeFileHandle, on the real and the mocked file system. --- # RandomAccess @@ -44,12 +45,12 @@ Get the handle from [`IFile.OpenHandle`](./safe-file-handles), so the file syste - A write past the end grows the file and zero-fills the gap. - `SetLength` truncates, or grows with zeroes. - `GetLength` is metadata and works on a write-only handle. Reading a write-only handle, or writing a read-only one, throws `UnauthorizedAccessException`. -- On a handle opened with `FileMode.Append` the offset is still honoured — `RandomAccess` does not append for you. +- On a handle opened with `FileMode.Append` the offset is still honoured - `RandomAccess` does not append for you. - Operations on a disposed handle throw `ObjectDisposedException`. ## `FlushToDisk` against the mock -`FlushToDisk` asks the operating system to make prior writes durable. The `MockFileSystem` keeps its files in memory and has no write-back cache, so there is nothing to flush — but the call is still resolved against the handle and recorded, so a test can assert that a durability barrier was requested: +`FlushToDisk` asks the operating system to make prior writes durable. The `MockFileSystem` keeps its files in memory and has no write-back cache, so there is nothing to flush - but the call is still resolved against the handle and recorded, so a test can assert that a durability barrier was requested: ```csharp MockFileSystem fileSystem = new(); diff --git a/Docs/pages/docs/file-system/safe-file-handles.mdx b/Docs/pages/docs/file-system/safe-file-handles.mdx index af7c8213b..2150bbff6 100644 --- a/Docs/pages/docs/file-system/safe-file-handles.mdx +++ b/Docs/pages/docs/file-system/safe-file-handles.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 9 title: SafeFileHandle +description: Use SafeFileHandle overloads against the MockFileSystem, including handles that come from outside the mock. --- # SafeFileHandle @@ -22,13 +23,15 @@ await Expect.That(fileSystem.File.GetLastAccessTime(handle)) await Expect.That(fileSystem.RandomAccess.GetLength(handle)).IsEqualTo(12); ``` -The handle refers to the file it was opened on rather than to its name, so reads and writes keep working after the file is renamed, and do not start addressing whatever is later created under the original path. +Like a real handle, it refers to the file it was opened on rather than to its name, so it keeps working after the file is renamed. It also honours the `FileShare` and `FileOptions` (including `DeleteOnClose`) it was opened with, following the rules of the simulated operating system. -It also carries the file share and the `FileOptions` it was opened with. `FileOptions.DeleteOnClose` follows the platform: on Unix the name that was opened is unlinked as soon as that handle closes, whatever else still holds the file open, so a file renamed in the meantime survives and a replacement under the old name does not; on Windows the file is removed once nothing holds it open any more, streams included. +:::warning[Only for the abstraction] +A handle the `MockFileSystem` created is not a real OS handle, so passing it to `System.IO.RandomAccess` or to another process fails. Use `IFileSystem.RandomAccess` and the `IFile` overloads instead. +::: -Because `SafeFileHandle` is sealed, the mock cannot be notified when one is closed, so released share locks and `DeleteOnClose` take effect the next time the file system is asked whether a file exists or may be opened, rather than at the moment of disposal. A handle that is dropped without being disposed releases its share lock once it is garbage collected, as a real handle does when it is finalized. - -A handle the `MockFileSystem` created is not a real OS handle, so passing it to `System.IO.RandomAccess` or to another process fails. Use `IFileSystem.RandomAccess` and the `IFile` overloads instead, which is the point of the abstraction. +:::note +The mock cannot observe the moment a handle is disposed, so a released share lock or `DeleteOnClose` takes effect the next time the file system checks whether the file exists or may be opened. +::: ## Handles from elsewhere @@ -38,9 +41,12 @@ When a handle comes from somewhere the mock knows nothing about - platform invoc The default strategy (`NullSafeFileHandleStrategy`) is registered automatically and throws `NotSupportedException` for any handle - install a custom strategy as soon as your code under test reaches for a handle from outside. -## A complete reference implementation +## Example: mapping handles of the real file system + +The strategy below keeps an explicit map from `SafeFileHandle` to (real path, mock path). On every lookup it copies the real file's attributes and timestamps into the mock, so the test sees fresh data without having to write a fixture by hand. -This strategy keeps an explicit map from `SafeFileHandle` to (real path, mock path). On every lookup it copies the real file's attributes and timestamps into the mock, so the test sees fresh data without having to write a fixture by hand: +
+`CustomSafeFileHandleStrategy` ```csharp public class CustomSafeFileHandleStrategy : ISafeFileHandleStrategy @@ -86,6 +92,8 @@ public class CustomSafeFileHandleStrategy : ISafeFileHandleStrategy } ``` +
+ ### Wiring it into a test ```csharp diff --git a/Docs/pages/docs/file-system/simulating-os.mdx b/Docs/pages/docs/file-system/simulating-os.mdx index c142ef6a5..c3d5b8ed0 100644 --- a/Docs/pages/docs/file-system/simulating-os.mdx +++ b/Docs/pages/docs/file-system/simulating-os.mdx @@ -1,27 +1,52 @@ --- sidebar_position: 2 title: Simulating other operating systems +description: Run the MockFileSystem with Linux, macOS or Windows semantics, independent of the host. --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # Simulating other operating systems `MockFileSystem` can pretend to be a different operating system than the host it runs on. Pass a `SimulationMode` in the constructor: + + + ```csharp -var linuxFileSystem = new MockFileSystem(o => +var fileSystem = new MockFileSystem(o => o.SimulatingOperatingSystem(SimulationMode.Linux)); // case-sensitive, '/' separator, single root +``` -var windowsFileSystem = new MockFileSystem(o => + + + +```csharp +var fileSystem = new MockFileSystem(o => o.SimulatingOperatingSystem(SimulationMode.Windows)); -// case-insensitive, '\\' separator, drive letters +// case-insensitive, '\' separator, drive letters +``` -var macFileSystem = new MockFileSystem(o => + + + +```csharp +var fileSystem = new MockFileSystem(o => o.SimulatingOperatingSystem(SimulationMode.MacOS)); +// case-insensitive, '/' separator, single root ``` + + + Because the entire test suite runs against the **real** file system on Linux, macOS and Windows _and_ against each `SimulationMode` in CI, the simulated behaviour stays in lock-step with the genuine platform behaviour over time. +:::note[.NET 6 or later] +`SimulatingOperatingSystem` is only available on .NET 6 and later. On older targets, the mock always uses the host OS. +::: + ## Why simulate - Catch case-sensitivity bugs that only show up on Linux from a Windows dev machine. @@ -29,5 +54,3 @@ Because the entire test suite runs against the **real** file system on Linux, ma - Test drive-letter handling without spinning up a Windows job. If you only need the host's own behaviour, omit `SimulatingOperatingSystem` - `MockFileSystem` defaults to whichever OS it is running on. - -`SimulatingOperatingSystem` is only available when running on .NET 6 or newer. On earlier targets, the mock always uses the host OS. diff --git a/Docs/pages/docs/file-system/statistics.mdx b/Docs/pages/docs/file-system/statistics.mdx index 8cc22164d..99f7f5bde 100644 --- a/Docs/pages/docs/file-system/statistics.mdx +++ b/Docs/pages/docs/file-system/statistics.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 6 title: Statistics +description: Assert which file system methods and properties the code under test used. --- # Statistics @@ -20,22 +21,18 @@ await Expect.That(stats.File.Methods).Contains(m => m.Name == nameof(IFile.Write ## What is recorded -`IFileSystemStatistics` exposes one property per `IFileSystem` sub-property: - -| Property | Type | -|-----------------------|---------------------------------------------------------------------| -| `Directory` | `IStatistics` | -| `DirectoryInfo` | `IPathStatistics` | -| `DriveInfo` | `IPathStatistics` | -| `File` | `IStatistics` | -| `FileInfo` | `IPathStatistics` | -| `FileStream` | `IPathStatistics` | -| `FileSystemWatcher` | `IPathStatistics` | -| `FileVersionInfo` | `IPathStatistics` | -| `Path` | `IStatistics` | -| `RandomAccess` | `IStatistics` | - -`IStatistics` exposes `Methods` and `Properties` collections. Each entry carries the call's name, parameters and a stack-frame counter so you can correlate concurrent activity. `IPathStatistics` adds an indexer keyed by the path that the factory produced - `stats.FileInfo["a.txt"]` returns just the calls made on that specific `IFileInfo`. +`IFileSystemStatistics` exposes one property per `IFileSystem` sub-property (`Directory`, `DirectoryInfo`, `DriveInfo`, `File`, `FileInfo`, `FileStream`, `FileSystemWatcher`, `FileVersionInfo`, `Path` and `RandomAccess`). + +Each of them has `Methods` and `Properties` collections. An entry carries the call's name, its parameters and a global `Counter` that tells the order of the calls. + +The properties for types created by a factory (`DirectoryInfo`, `DriveInfo`, `FileInfo`, `FileStream`, `FileSystemWatcher`, `FileVersionInfo`) also have an indexer keyed by path - `stats.FileInfo["a.txt"]` returns just the calls made on the `IFileInfo` for that path: + +```csharp +_ = fileSystem.FileInfo.New("a.txt").Exists; + +await Expect.That(stats.FileInfo["a.txt"].Properties) + .Contains(p => p.Name == nameof(IFileInfo.Exists)); +``` `TotalCount` returns the sum across every sub-property - handy for "did anything touch the file system at all?" assertions. diff --git a/Docs/pages/docs/file-system/unix-file-mode.mdx b/Docs/pages/docs/file-system/unix-file-mode.mdx index 3dd869c5c..3cc8ae090 100644 --- a/Docs/pages/docs/file-system/unix-file-mode.mdx +++ b/Docs/pages/docs/file-system/unix-file-mode.mdx @@ -1,49 +1,51 @@ --- sidebar_position: 8 title: Unix file mode +description: Simulate POSIX users, groups and permissions in the MockFileSystem. --- # Unix file mode -To simulate POSIX permissions in the mock, plug in an `IUnixFileModeStrategy` via `WithUnixFileModeStrategy(...)`. +To simulate POSIX permissions in the mock, plug in an `IUnixFileModeStrategy` via `WithUnixFileModeStrategy(...)`. Without one, the mock grants access to every entry whose `UnixFileMode` is not `None`. The strategy decides: - whether a given user/group is allowed to read or write a path (`IsAccessGranted`) -- what to record when a test calls `File.SetUnixFileMode(...)` (`OnSetUnixFileMode`) +- what to record when the file mode is set, for example by `File.SetUnixFileMode(...)` (`OnSetUnixFileMode`) -A typical implementation stores the file's user and group alongside the file via [`IFileSystemExtensibility`](./extensibility), then uses that data plus the file's `UnixFileMode` to decide whether to grant access. +A typical implementation stores the file's user and group alongside the file via [`IFileSystemExtensibility`](./extensibility), then uses that data plus the file's `UnixFileMode` to decide whether to grant access. When access is denied, the mock throws `UnauthorizedAccessException`. -Read access is granted when: +:::warning[Not on Windows] +`WithUnixFileModeStrategy` throws `PlatformNotSupportedException` when the host (or simulated OS) is Windows. On a Windows machine, [simulate Linux or macOS](./simulating-os). +::: -- the file's mode includes `OtherRead`, **or** -- the mode includes `GroupRead` and the file's group matches the simulated group, **or** -- the mode includes `UserRead` and the file's user matches the simulated user +## Example: simulating users and groups -The same rules apply to write access with the `*Write` flags. +The strategy below is not part of the library; copy and adapt it. It lets a test simulate "running as user X in group Y" via fluent setters and records the simulated user and group as the owner whenever the mode of an entry is set. It grants read access when: -:::warning[Not on Windows] -`WithUnixFileModeStrategy` throws `PlatformNotSupportedException` when the host (or simulated OS) is Windows. -::: +- the mode includes `OtherRead`, **or** +- the mode includes `GroupRead` and the owner's group matches the simulated group, **or** +- the mode includes `UserRead` and the owner matches the simulated user -## A complete reference implementation +The same rules apply to write access with the `*Write` flags. -`DefaultUnixFileModeStrategy` lets a test simulate "running as user X in group Y" via fluent setters and stores the user/group on each file at creation time: +
+`SimulatedUserStrategy` ```csharp -public class DefaultUnixFileModeStrategy : IUnixFileModeStrategy +public class SimulatedUserStrategy : IUnixFileModeStrategy { private string _user = ""; private string _group = ""; - public DefaultUnixFileModeStrategy SimulateUser(string user) { _user = user; return this; } - public DefaultUnixFileModeStrategy SimulateGroup(string group) { _group = group; return this; } + public SimulatedUserStrategy SimulateUser(string user) { _user = user; return this; } + public SimulatedUserStrategy SimulateGroup(string group) { _group = group; return this; } public void OnSetUnixFileMode(string fullPath, IFileSystemExtensibility extensibility, UnixFileMode mode) { // Record the simulated owner at the moment the file's mode was set - extensibility.StoreMetadata(nameof(DefaultUnixFileModeStrategy), + extensibility.StoreMetadata(nameof(SimulatedUserStrategy), new UserGroup(_user, _group)); } @@ -52,7 +54,7 @@ public class DefaultUnixFileModeStrategy : IUnixFileModeStrategy UnixFileMode mode, FileAccess requestedAccess) { UserGroup owner = extensibility - .RetrieveMetadata(nameof(DefaultUnixFileModeStrategy)) + .RetrieveMetadata(nameof(SimulatedUserStrategy)) ?? new UserGroup(_user, _group); bool readOk = @@ -77,11 +79,14 @@ public class DefaultUnixFileModeStrategy : IUnixFileModeStrategy } ``` +
+ ### Wiring it into a test ```csharp -DefaultUnixFileModeStrategy strategy = new(); -MockFileSystem fileSystem = new MockFileSystem() +SimulatedUserStrategy strategy = new(); +MockFileSystem fileSystem = new MockFileSystem(o => o + .SimulatingOperatingSystem(SimulationMode.Linux)) .WithUnixFileModeStrategy(strategy); // "alice" creates a directory readable only by its group "editors" @@ -90,7 +95,7 @@ fileSystem.Directory.CreateDirectory("foo", UnixFileMode.GroupRead); // "bob" (also "editors") can read it strategy.SimulateUser("bob").SimulateGroup("editors"); -fileSystem.Directory.GetFiles("foo"); // ✓ no exception +fileSystem.Directory.GetFiles("foo"); // no exception // switch to a different group → read denied strategy.SimulateGroup("guests"); @@ -99,8 +104,6 @@ fileSystem.Directory.GetFiles("foo"); // throws UnauthorizedAccessException ### Asserting denial -The mock throws `UnauthorizedAccessException` whenever `IsAccessGranted` returns `false`: - ```csharp strategy.SimulateUser("alice"); fileSystem.Directory.CreateDirectory("private", UnixFileMode.UserWrite); diff --git a/Docs/pages/docs/getting-started.mdx b/Docs/pages/docs/getting-started.mdx index 0f37a61ac..2d6d9c3f4 100644 --- a/Docs/pages/docs/getting-started.mdx +++ b/Docs/pages/docs/getting-started.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 2 title: Getting Started +description: Install the packages, register the real implementations and replace them with mocks in tests. --- # Getting Started @@ -14,7 +15,16 @@ dotnet add package Testably.Abstractions dotnet add package Testably.Abstractions.Testing ``` -Optional companion packages: +| Package | Install it in | +|---------------------------------------------|---------------------------------------------------------------------------------------------------| +| `Testably.Abstractions` | Production code - contains `RealFileSystem`, `RealTimeSystem` and `RealRandomSystem` | +| `Testably.Abstractions.Testing` | Test projects - contains `MockFileSystem`, `MockTimeSystem` and `MockRandomSystem` | +| `Testably.Abstractions.Interface` | Libraries that only consume `IFileSystem`, `ITimeSystem` or `IRandomSystem` and leave the choice of implementation to the application | +| `Testably.Abstractions.FileSystem.Interface`| Libraries that only need `IFileSystem` | + +Both `Testably.Abstractions` and `Testably.Abstractions.Testing` pull in the interface packages transitively. + +Optional [companion packages](./companion-libraries): ```powershell dotnet add package Testably.Abstractions.Compression @@ -26,7 +36,7 @@ dotnet add package Testably.Abstractions.MemoryMappedFiles With `Microsoft.Extensions.DependencyInjection`: -```csharp +```csharp title="Program.cs" builder.Services .AddSingleton() .AddSingleton() @@ -35,38 +45,40 @@ builder.Services ## 3. Use the interfaces in your services -```csharp -public class MyService(IFileSystem fileSystem) +```csharp title="ReportService.cs" +public class ReportService(IFileSystem fileSystem) { - private readonly IFileSystem _fileSystem = fileSystem; - - public void StoreData() + public void Save(string content) { - _fileSystem.File.WriteAllText("result.xml", GetFileContent()); + fileSystem.Directory.CreateDirectory("reports"); + fileSystem.File.WriteAllText("reports/latest.xml", content); } - - private string GetFileContent() => /* ... */; } ``` ## 4. Replace the real implementations in tests -```csharp -[Test] -public void StoreData_ShouldWriteValidFile() +```csharp title="ReportServiceTests.cs" +[Fact] +public async Task Save_WritesReportToReportsFolder() { - IFileSystem fileSystem = new MockFileSystem(); - var sut = new MyService(fileSystem); + // highlight-next-line + var fileSystem = new MockFileSystem(); + var sut = new ReportService(fileSystem); - sut.StoreData(); + sut.Save(""); - var content = fileSystem.File.ReadAllText("result.xml"); - // assert on content + await Expect.That(fileSystem.File.ReadAllText("reports/latest.xml")) + .IsEqualTo(""); } ``` That's it - your code now runs against an in-memory file system in tests, and against the real one in production. +:::info[Test framework] +The examples in this documentation use [xUnit](https://xunit.net/) and [aweXpect](https://docs.testably.org/aweXpect/) assertions. The mocks do not depend on either, so any test framework and assertion library works. +::: + ## Next steps - [File system](./file-system) - `IFileSystem`, `MockFileSystem`, drives, watcher, statistics, strategies. diff --git a/Docs/pages/docs/index.mdx b/Docs/pages/docs/index.mdx index b6cb3d53d..0a8956bdd 100644 --- a/Docs/pages/docs/index.mdx +++ b/Docs/pages/docs/index.mdx @@ -2,6 +2,7 @@ title: Testably.Abstractions slug: /Abstractions/ sidebar_position: -1 +description: Injectable abstractions for the file system, time and randomness in .NET, with in-memory mocks for tests. --- import Tabs from '@theme/Tabs'; @@ -10,11 +11,11 @@ import TabItem from '@theme/TabItem'; [![Testably.Abstractions](https://img.shields.io/nuget/v/Testably.Abstractions?label=Testably.Abstractions&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions) [![Testably.Abstractions.Testing](https://img.shields.io/nuget/v/Testably.Abstractions.Testing?label=Testably.Abstractions.Testing&logo=nuget)](https://www.nuget.org/packages/Testably.Abstractions.Testing) -`Testably.Abstractions` is a feature-complete testing helper for the [`IFileSystem`](https://github.com/TestableIO/System.IO.Abstractions) abstractions of the `System.IO` namespace, plus matching abstractions for time and randomness. +`Testably.Abstractions` provides injectable abstractions for the static parts of the .NET base class library (BCL) - file system, time and randomness - with feature-complete in-memory mocks for tests. ## Inject. Mock. Test. -Depend on `IFileSystem`, `ITimeSystem` and `IRandomSystem` in production. Swap in the in-memory mocks for tests — deterministic, cross-platform, no temp folders or `Thread.Sleep`. +Depend on `IFileSystem`, `ITimeSystem` and `IRandomSystem` in production. Swap in the in-memory mocks for tests - deterministic, cross-platform, no temp folders or `Thread.Sleep`. @@ -107,6 +108,6 @@ See [Migration from TestableIO](./migration-from-testableio) if you are already ## Where to go next -- **[Getting Started](./getting-started)** — install the packages and wire them up. -- **[File system](./file-system)**, **[Time system](./time-system)**, **[Random system](./random-system)** — explore each abstraction. -- **[Companion libraries](./companion-libraries)** — ZIP and ACL helpers. +- **[Getting Started](./getting-started)** - install the packages and wire them up. +- **[File system](./file-system)**, **[Time system](./time-system)**, **[Random system](./random-system)** - explore each abstraction. +- **[Companion libraries](./companion-libraries)** - ZIP, ACL and memory-mapped file helpers. diff --git a/Docs/pages/docs/migration-from-testableio/index.mdx b/Docs/pages/docs/migration-from-testableio/index.mdx index ceb0da737..9e213ad6e 100644 --- a/Docs/pages/docs/migration-from-testableio/index.mdx +++ b/Docs/pages/docs/migration-from-testableio/index.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 7 title: Migration from TestableIO +description: Switch the tests from TestableIO.System.IO.Abstractions.TestingHelpers to Testably.Abstractions.Testing. --- # Migration from TestableIO @@ -15,11 +16,11 @@ Pick **Testably.Abstractions** when you need a watcher, `SafeFileHandle`, multip ## How to migrate -The migration only affects your **test** projects. There are two paths — pick one. +The migration only affects your **test** projects. There are two paths - pick one. ### Automated (recommended) -The [`Testably.Abstractions.Migration`](./Migration/) analyzer rewrites the supported call sites for you. Install it alongside `Testably.Abstractions.Testing`, apply the code fix from your IDE or `dotnet format analyzers`, then uninstall it. Patterns it cannot rewrite safely are still reported so you can address them by hand — see the [analyzer page](./Migration/) for the full mapping table and recommended workflow. +The [`Testably.Abstractions.Migration`](./Migration/) analyzer rewrites the supported call sites for you. Install it alongside `Testably.Abstractions.Testing`, apply the code fix from your IDE or `dotnet format analyzers`, then uninstall it. Patterns it cannot rewrite safely are still reported so you can address them by hand - see the [analyzer page](./Migration/) for the full mapping table and recommended workflow. ### Manual diff --git a/Docs/pages/docs/random-system/deterministic-randomness.mdx b/Docs/pages/docs/random-system/deterministic-randomness.mdx index bcc2fa97b..be7fbf176 100644 --- a/Docs/pages/docs/random-system/deterministic-randomness.mdx +++ b/Docs/pages/docs/random-system/deterministic-randomness.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 1 title: Deterministic randomness +description: Drive Random and Guid from fixed values, sequences or callbacks for reproducible tests. --- # Deterministic randomness diff --git a/Docs/pages/docs/random-system/index.mdx b/Docs/pages/docs/random-system/index.mdx index b0f4b5346..9f3cf0093 100644 --- a/Docs/pages/docs/random-system/index.mdx +++ b/Docs/pages/docs/random-system/index.mdx @@ -1,5 +1,6 @@ --- title: Random system +description: IRandomSystem abstracts Random and Guid; MockRandomSystem makes them predictable in tests. --- # IRandomSystem @@ -27,4 +28,4 @@ int n = randomSystem.Random.Shared.Next(100); Guid id = randomSystem.Guid.NewGuid(); ``` -By default the mock seeds itself fresh per test and produces unpredictable values - useful for catching code that accidentally depends on a specific output. See [Deterministic randomness](./deterministic-randomness) for how to drive `Random` and `Guid` from a known sequence. +By default the mock produces unpredictable values, like the real system - useful for catching code that accidentally depends on a specific output. See [Deterministic randomness](./deterministic-randomness) for how to drive `Random` and `Guid` from a known sequence. diff --git a/Docs/pages/docs/time-system/auto-advance.mdx b/Docs/pages/docs/time-system/auto-advance.mdx index e7bb83674..52bd91dfa 100644 --- a/Docs/pages/docs/time-system/auto-advance.mdx +++ b/Docs/pages/docs/time-system/auto-advance.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 2 title: Auto-advance +description: How MockTimeSystem moves its clock when the code under test waits, and how to drive the clock yourself. --- # Auto-advance @@ -11,22 +12,40 @@ By default, `MockTimeSystem` advances its clock every time the code under test w MockTimeSystem timeSystem = new(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); await timeSystem.Task.Delay(TimeSpan.FromMinutes(10)); -timeSystem.DateTime.UtcNow; // 2026-01-01 00:10:00 UTC + +await Expect.That(timeSystem.DateTime.UtcNow) + .IsEqualTo(new DateTime(2026, 1, 1, 0, 10, 0, DateTimeKind.Utc)); ``` ## Disabling auto-advance -When you need to assert *that* code waited (instead of just letting it through), turn auto-advance off via `MockTimeSystemOptions.DisableAutoAdvance()`: +When you need to assert *that* code waited (instead of just letting it through), turn auto-advance off via `MockTimeSystemOptions.DisableAutoAdvance()` and move the clock yourself with `TimeProvider.AdvanceBy(...)` or `TimeProvider.SetTo(...)`: ```csharp MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); -await timeSystem.Task.Delay(TimeSpan.FromMinutes(10)); -// Clock did NOT move - Delay returned because nothing simulates progress +Task delay = timeSystem.Task.Delay(TimeSpan.FromMinutes(10)); +await Expect.That(delay.IsCompleted).IsFalse(); + +// highlight-next-line +timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromMinutes(10)); +await delay; // completes, because the clock has reached the end of the delay ``` -With auto-advance off you control time yourself by writing into the time provider. Combine this with the [notifications](./notifications) hooks to assert what your code asked for. +With auto-advance off: + +| Call | Behaviour | +|---------------------------------------|------------------------------------------------------------------------| +| `Task.Delay` | Stays pending until the clock reaches the end of the delay | +| `PeriodicTimer.WaitForNextTickAsync` | Stays pending until the clock reaches the next tick | +| `Thread.Sleep` | Returns immediately without moving the clock | + +:::danger[Awaiting a delay with auto-advance off] +Awaiting `Task.Delay` before anything advances the clock waits forever. Start the delay, advance the clock (or let another task do it), then await it. +::: + +Combine this with the [notifications](./notifications) hooks to assert what your code asked for. ## Multi-threaded scenarios -The default time provider stores the clock in a single field, so two parallel `Sleep(5s)` calls move it by **10 seconds total**, which can be surprising. If your test exercises real parallelism, swap in the [thread-aware provider](./thread-aware-provider) so each virtual thread has its own clock. +All threads share one simulated clock, so two parallel `Sleep(5s)` calls move it by **10 seconds total**, which can be surprising. If your test exercises real parallelism, swap in the [thread-aware provider](./thread-aware-provider) so each async context has its own clock. diff --git a/Docs/pages/docs/time-system/configuring-time.mdx b/Docs/pages/docs/time-system/configuring-time.mdx index d856f366f..7c7b7916a 100644 --- a/Docs/pages/docs/time-system/configuring-time.mdx +++ b/Docs/pages/docs/time-system/configuring-time.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 1 title: Configuring time +description: Choose the start time and the local time zone of the MockTimeSystem, or plug in a custom time provider. --- # Configuring time @@ -27,11 +28,13 @@ new MockTimeSystem(TimeProviderFactory.Now()); The factory picks the initial local time zone: ```csharp -// Now() and Use(DateTime) default the local time zone to TimeZoneInfo.Local +// The parameterless constructor, Now() and Use(DateTime) default the local +// time zone to the host's TimeZoneInfo.Local +new MockTimeSystem(); new MockTimeSystem(TimeProviderFactory.Now()); -// Random() also picks a random local time zone from a curated, cross-platform -// set (including one with daylight-saving-time transitions) +// TimeProviderFactory.Random() also picks a random local time zone from a curated, +// cross-platform set (including one with daylight-saving-time transitions) new MockTimeSystem(TimeProviderFactory.Random()); // Pin both the instant and the local time zone @@ -73,7 +76,7 @@ ITimeProviderFactory factory = new MyTimeProviderFactory(...); MockTimeSystem timeSystem = new(factory); ``` -The factory hands the mock an `Action` callback to invoke whenever the simulated time changes. That callback drives the [notifications](./notifications) and the auto-advance behaviour. +`ITimeProviderFactory` receives a callback that your provider must invoke whenever the simulated time changes; the [notifications](./notifications) and waiting timers depend on it. ## Sharing the time system diff --git a/Docs/pages/docs/time-system/index.mdx b/Docs/pages/docs/time-system/index.mdx index 22da76156..d60a2535d 100644 --- a/Docs/pages/docs/time-system/index.mdx +++ b/Docs/pages/docs/time-system/index.mdx @@ -1,7 +1,10 @@ --- title: Time system +description: ITimeSystem abstracts the clock, timers and waiting; MockTimeSystem lets tests fast-forward time. --- +import DocCardList from '@theme/DocCardList'; + # ITimeSystem `ITimeSystem` wraps everything in the BCL that depends on the wall clock or makes a thread wait. Replace it with a `MockTimeSystem` in tests and you can fast-forward time without sleeping. @@ -15,7 +18,7 @@ title: Time system | `Stopwatch` | [`System.Diagnostics.Stopwatch`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch) | | `Task` | [`Task.Delay`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay) | | `Thread` | [`Thread.Sleep`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.sleep) | -| `PeriodicTimer` | [`System.Threading.PeriodicTimer`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.periodictimer) | +| `PeriodicTimer` | [`System.Threading.PeriodicTimer`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.periodictimer) (.NET 8 or later) | | `Timer` | [`System.Threading.Timer`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.timer) | | `TimeZoneInfo` | [`System.TimeZoneInfo`](https://learn.microsoft.com/en-us/dotnet/api/system.timezoneinfo) (`Local`, `Utc`, `FindSystemTimeZoneById`, `GetSystemTimeZones`) | @@ -34,4 +37,6 @@ timeSystem.Thread.Sleep(TimeSpan.FromMinutes(5)); DateTime now = timeSystem.DateTime.Now; // advanced by 5 minutes ``` -The remaining pages cover how to configure the underlying time provider, control auto-advance, drive timers and periodic timers, and react to time-related events. +## In this section + + diff --git a/Docs/pages/docs/time-system/notifications.mdx b/Docs/pages/docs/time-system/notifications.mdx index 8dab782e0..f55c23e70 100644 --- a/Docs/pages/docs/time-system/notifications.mdx +++ b/Docs/pages/docs/time-system/notifications.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 6 title: Notifications +description: Observe and wait for sleeps, delays, clock reads and timer ticks of the code under test. --- # Notifications @@ -29,7 +30,7 @@ Like the file-system [notifications](../file-system/intercept-and-notify), each | `TaskDelay()` | `Task.Delay` (any overload) is called | `TimeSpan` | | `ThreadSleep()` | `Thread.Sleep` (any overload) is called | `TimeSpan` | | `TimeChanged()` | the simulated time advanced (auto-advance or manual) | `DateTime` | -| `PeriodicTimer.WaitingForNextTick()` | a `PeriodicTimer` is waiting for its next tick | `IPeriodicTimer` | +| `PeriodicTimer.WaitingForNextTick()` | a `PeriodicTimer` is waiting for its next tick (.NET 8 or later) | `IPeriodicTimer` | Each accepts an optional `callback` (run synchronously when the event fires) and an optional `predicate` to filter events before they are recorded. diff --git a/Docs/pages/docs/time-system/periodic-timer.mdx b/Docs/pages/docs/time-system/periodic-timer.mdx index a7927b4e3..c0b99a895 100644 --- a/Docs/pages/docs/time-system/periodic-timer.mdx +++ b/Docs/pages/docs/time-system/periodic-timer.mdx @@ -1,15 +1,20 @@ --- sidebar_position: 5 title: PeriodicTimer +description: Test loops built on PeriodicTimer without waiting for real time to pass. --- # PeriodicTimer `MockTimeSystem.PeriodicTimer` wraps `System.Threading.PeriodicTimer` (`IPeriodicTimer`). It's the **async pull-based** counterpart to `Timer`: instead of registering a callback and waiting for it to fire, your code `await`s `WaitForNextTickAsync()` inside a loop. +:::note[.NET 8 or later] +`ITimeSystem.PeriodicTimer` is only available on .NET 8 and later. +::: + Typical use case: a hosted/background service that does work on a fixed cadence - every 30 seconds, every 5 minutes - and needs to be testable without actually sleeping for that long. -```csharp +```csharp title="HeartbeatWorker.cs" public class HeartbeatWorker(ITimeSystem timeSystem, IFileSystem fileSystem) { public async Task RunAsync(CancellationToken cancellationToken) @@ -23,70 +28,63 @@ public class HeartbeatWorker(ITimeSystem timeSystem, IFileSystem fileSystem) } ``` -## Driving the loop in a test +## Driving the clock from a test + +To assert exactly how often the loop ran, turn auto-advance off and advance the clock by hand. `WaitForNextTickAsync` then stays pending until the simulated time reaches the next tick. -With auto-advance on (the default), every `await timer.WaitForNextTickAsync()` returns immediately and advances the simulated clock by exactly one `Period`. A four-iteration loop runs in microseconds and ends two minutes ahead on the simulated clock: +`On.PeriodicTimer.WaitingForNextTick()` fires every time the worker calls `WaitForNextTickAsync`, so waiting for it tells the test that the worker finished the previous iteration and is idle again: ```csharp -MockTimeSystem timeSystem = new(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); +MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); MockFileSystem fileSystem = new(o => o.UseTimeSystem(timeSystem)); HeartbeatWorker worker = new(timeSystem, fileSystem); - using CancellationTokenSource cts = new(); -Task run = worker.RunAsync(cts.Token); - -// Let the worker iterate four times, then stop it -await Task.Delay(50); -cts.Cancel(); -try { await run; } catch (OperationCanceledException) { } - -string[] lines = fileSystem.File.ReadAllLines("heartbeat.log"); -await Expect.That(lines).HasCount(4); -await Expect.That(timeSystem.DateTime.UtcNow) - .IsEqualTo(new DateTime(2026, 1, 1, 0, 2, 0, DateTimeKind.Utc)); -``` +using IAwaitableCallback waiting = + timeSystem.On.PeriodicTimer.WaitingForNextTick(); -## Asserting iterations with `On.PeriodicTimer.WaitingForNextTick` +Task run = worker.RunAsync(cts.Token); -The polling pattern above is brittle - it depends on `Task.Delay` letting the worker run far enough. The deterministic alternative is to subscribe to the periodic-timer notification, which fires on every `WaitForNextTickAsync` call: +// The worker is waiting for its first tick - nothing was written yet +waiting.Wait(); +await Expect.That(fileSystem.File.Exists("heartbeat.log")).IsFalse(); -```csharp -MockTimeSystem timeSystem = new(); -using IAwaitableCallback ticks = - timeSystem.On.PeriodicTimer.WaitingForNextTick(); +// highlight-next-line +timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(30)); +waiting.Wait(); +await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log")).HasCount(1); -Task run = worker.RunAsync(cts.Token); -ticks.Wait(count: 4); // block until the worker has looped four times cts.Cancel(); +await Expect.That(async () => await run).Throws(); ``` +Like the real `PeriodicTimer`, advancing the clock by several periods at once results in a single tick, not one tick per elapsed period. + `WaitingForNextTick` accepts an optional `predicate` to filter - useful when multiple periodic timers run in the same test and you only want to wait for one specific period. -## DisableAutoAdvance - drive the clock yourself +## With auto-advance -When you want to assert the worker actually waits between iterations (not just that it iterated), turn auto-advance off and advance the clock by hand. With auto-advance disabled, `WaitForNextTickAsync` blocks until the simulated time crosses `lastTick + Period`: +With auto-advance on (the default), every `WaitForNextTickAsync` returns immediately and advances the simulated clock by one `Period`, so the loop runs as fast as it can until it is cancelled. That is useful to check that the loop does its work, but not to count iterations: ```csharp -MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); -Task run = worker.RunAsync(cts.Token); - -// Worker is blocked inside WaitForNextTickAsync - clock has not moved -await Expect.That(fileSystem.File.Exists("heartbeat.log")).IsFalse(); +MockTimeSystem timeSystem = new(); +MockFileSystem fileSystem = new(o => o.UseTimeSystem(timeSystem)); +HeartbeatWorker worker = new(timeSystem, fileSystem); +using CancellationTokenSource cts = new(); +using IAwaitableCallback waiting = + timeSystem.On.PeriodicTimer.WaitingForNextTick(); -// Push the clock forward → exactly one tick fires -timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(30)); -await Task.Yield(); -await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log")).HasCount(1); +Task run = worker.RunAsync(cts.Token); +waiting.Wait(count: 5); // the worker finished at least four iterations +cts.Cancel(); +await Expect.That(async () => await run).Throws(); -// Push it forward by two periods → two more ticks fire -timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(60)); -await Task.Yield(); -await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log")).HasCount(3); +await Expect.That(fileSystem.File.ReadAllLines("heartbeat.log").Length) + .IsGreaterThanOrEqualTo(4); ``` -## Mutating the period mid-loop +## Changing the period -Unlike the BCL on older runtimes, `IPeriodicTimer.Period` is settable on every framework version. The next `WaitForNextTickAsync` uses the new value: +`IPeriodicTimer.Period` is settable; the next `WaitForNextTickAsync` uses the new value: ```csharp using IPeriodicTimer timer = timeSystem.PeriodicTimer.New(TimeSpan.FromSeconds(1)); @@ -96,11 +94,11 @@ timer.Period = TimeSpan.FromMilliseconds(100); await timer.WaitForNextTickAsync(); // waits 100ms ``` -Periods below 1 ms throw `ArgumentOutOfRangeException`, matching the BCL contract. +Periods below 1 ms throw `ArgumentOutOfRangeException`, matching the base class library (BCL). ## Stopping the loop -`Dispose()` ends the loop the next time around - `WaitForNextTickAsync` returns `false` instead of `true`, so the `while (await …)` exits cleanly. A `CancellationToken` passed into `WaitForNextTickAsync` raises `TaskCanceledException` when triggered. +`Dispose()` ends the loop the next time around - `WaitForNextTickAsync` returns `false` instead of `true`, so the `while (await …)` exits cleanly. A `CancellationToken` passed into `WaitForNextTickAsync` makes it throw an `OperationCanceledException` when triggered. ```csharp // graceful: dispose ends the loop on the next tick diff --git a/Docs/pages/docs/time-system/stopwatch.mdx b/Docs/pages/docs/time-system/stopwatch.mdx index f49678323..26eb6c443 100644 --- a/Docs/pages/docs/time-system/stopwatch.mdx +++ b/Docs/pages/docs/time-system/stopwatch.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 3 title: Stopwatch +description: Measure simulated elapsed time with the mocked Stopwatch, independent of changes to the wall clock. --- # Stopwatch diff --git a/Docs/pages/docs/time-system/thread-aware-provider.mdx b/Docs/pages/docs/time-system/thread-aware-provider.mdx index ab58e3df8..49a08d2c7 100644 --- a/Docs/pages/docs/time-system/thread-aware-provider.mdx +++ b/Docs/pages/docs/time-system/thread-aware-provider.mdx @@ -1,15 +1,19 @@ --- sidebar_position: 7 title: Thread-aware time provider +description: Give each async context its own simulated clock with a custom ITimeProvider. --- # Thread-aware time provider -The default `ITimeProvider` stores the simulated time in a single `DateTime` field. Two parallel threads each calling `Sleep(5s)` therefore advance the shared clock by **10 seconds total**, which doesn't match what would happen in the real world (where they'd both finish after roughly 5 seconds of wall time). +By default, all threads share one simulated clock. Two parallel threads each calling `Sleep(5s)` therefore advance it by **10 seconds total**, which doesn't match what would happen in the real world (where they'd both finish after roughly 5 seconds of wall time). -The fix is a custom `ITimeProvider` that stores the clock per logical thread using `AsyncLocal`. +The fix is a custom `ITimeProvider` that stores the clock per logical thread using `AsyncLocal`. The library does not ship one; copy and adapt the example below. -## A reference implementation +## Example implementation + +
+`ThreadAwareTimeProvider` ```csharp public sealed class ThreadAwareTimeProvider : ITimeProvider @@ -53,6 +57,8 @@ public sealed class ThreadAwareTimeProvider : ITimeProvider } ``` +
+ `AsyncLocal` flows with the async context, so each `Task.Run` (or `new Thread`) starts with its own copy of the current time. `SynchronizeClock()` lets you re-merge the per-thread clocks when you need to compare them. ### Per-task clocks diff --git a/Docs/pages/docs/time-system/timers.mdx b/Docs/pages/docs/time-system/timers.mdx index 1b3c9c885..e3dc1c52e 100644 --- a/Docs/pages/docs/time-system/timers.mdx +++ b/Docs/pages/docs/time-system/timers.mdx @@ -1,6 +1,7 @@ --- sidebar_position: 4 title: Timer +description: Wait for a mocked System.Threading.Timer to fire a given number of times instead of sleeping. --- # Timer diff --git a/README.md b/README.md index fb30fcd1f..df3531e71 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,9 @@ Then register the implementations in your DI container - see [Getting Started](h | Package | Purpose | |--------------------------------------|-------------------------------------------------------------------------| -| `Testably.Abstractions` | Production interfaces (`IFileSystem`, `ITimeSystem`, `IRandomSystem`) | +| `Testably.Abstractions` | `IFileSystem`, `ITimeSystem`, `IRandomSystem` and their real implementations | | `Testably.Abstractions.Testing` | `MockFileSystem`, `MockTimeSystem`, `MockRandomSystem` | -| `Testably.Abstractions.Compression` | Zip / `ZipArchive` extension methods on `IFileSystem` | +| `Testably.Abstractions.Compression` | `ZipFile` / `ZipArchive` extension properties on `IFileSystem` | | `Testably.Abstractions.AccessControl`| `GetAccessControl` / `SetAccessControl` on files and directories | | `Testably.Abstractions.MemoryMappedFiles` | `MemoryMappedFile` support on `IFileSystem` |