Use the open-source free `coverlet` toolchain for .NET code coverage.
Detect Static Dependencies
Scan C# source files for hard-to-test static dependencies — DateTime.Now/UtcNow, File.*, Directory.*, Environment.*, HttpClient, Console.*, Process.*, and other untestable statics. Produces a ranked report of static call sites by frequency. USE FOR: find untestable statics, scan for static dependencies, testability audit, identify hard-to-mock code, find DateTime.Now usage, detect static coupling, testability report, static analysis for testability. DO NOT USE FOR: generating wrappers (use generate-testability-wrappers), migrating code (use migrate-static-to-wrapper), general code review, or finding statics that are already behind abstractions.
Workflow
Step 1: Determine scan scope
Resolve the target to a set of .cs files:
- If a
.csfile, scan that single file. - If a directory, scan all
.csfiles recursively (excludingobj/,bin/). - If a
.csproj, find its directory and scan.csfiles within. - If a
.sln, parse it, find all project directories, and scan.csfiles across all projects.
Always exclude obj/, bin/, and any user-specified exclusion patterns.
Step 2: Search for static dependency patterns
Scan each file for calls matching these categories:
Treat pattern matches as candidates, not findings. Before counting an instance call, trace how its receiver enters the class. A collaborator supplied through a constructor, parameter, property, or dependency injection (DI) is already a test seam. In particular, an injected HttpClient is testable with a controlled HttpMessageHandler; do not count its calls or recommend replacing it merely because the injected type is concrete.
| Category | Patterns to search for | Recommended replacement | |----------|----------------------|------------------------| | Time | DateTime.Now, DateTime.UtcNow, DateTime.Today, DateTimeOffset.Now, DateTimeOffset.UtcNow, Task.Delay(, new CancellationTokenSource(TimeSpan | TimeProvider (.NET 8+) | | File System | File.ReadAllText(, File.WriteAllText(, File.Exists(, File.Delete(, File.Copy(, File.Move(, Directory.Exists(, Directory.CreateDirectory(, Directory.GetFiles(, Directory.Delete(, Path.GetTempPath(, and instance members that hit the disk (new FileInfo(...), new DirectoryInfo(...), .LastWriteTimeUtc, new StreamReader(path)) | IFileSystem (System.IO.Abstractions NuGet) | | Randomness / identity | new Random(, Random.Shared, Guid.NewGuid( | TimeProvider-style seam: inject Random / an IGuidProvider | | Culture / serialization | CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture, JsonSerializer.Serialize(, JsonSerializer.Deserialize( | Pass culture/options explicitly, or inject a serializer abstraction | | Environment | Environment.GetEnvironmentVariable(, Environment.SetEnvironmentVariable(, Environment.MachineName, Environment.UserName, Environment.CurrentDirectory, Environment.Exit( | Custom IEnvironmentProvider | | Network | new HttpClient(, .GetAsync(, .PostAsync(, .SendAsync( (confirm the receiver is an HttpClient; exclude calls whose receiver is injected or produced by an injected factory) | Inject HttpClient (commonly supplied by IHttpClientFactory) | | Console | Console.WriteLine(, Console.ReadLine(, Console.Write(, Console.ReadKey( | IConsole wrapper or ILogger | | Process | Process.Start(, Process.GetCurrentProcess(, Process.GetProcessesByName( | Custom IProcessRunner |
For time calls, inspect use as well as count. Two ambient clock reads in one logical operation are two call sites and a consistency defect: for example, separate DateTime.UtcNow reads for CreatedAt and ExpiresAt = DateTime.UtcNow.AddDays(30) can drift. Recommend one captured instant. With TimeProvider, retain DateTimeOffset where possible; when the existing member requires UTC DateTime, use GetUtcNow().UtcDateTime, never .DateTime, which loses the UTC kind. Treat capturing one instant as an optional behavior-level follow-up: a mechanical wrapper migration must preserve the original reads one-for-one unless the user separately approves that semantic change.
Step 3: Aggregate and rank results
Count each call site across the entire scan scope — including the instance-member call sites covered by the rules below, not only static ones.
Counting rules — inaccurate totals are the main way this report loses to an ad-hoc scan:
- Build one occurrence ledger before writing prose. Give each included call
site exactly one row containing category, exact pattern, file:line, and recommended seam. Derive every category, pattern, and per-file count by grouping that same ledger; never recount independently while writing tables.
- Keep the three count domains separate.
Files scannedincludes every
eligible source file; affected files includes only files with ledger rows; call sites is the number of ledger rows. Never substitute one for another.
- One authoritative total. Every call site you found belongs in the category summary and the grand total. Never park real findings in an "additional observations" section that the totals exclude.
- Classify by what the member touches, not by whether it is `static`. Instance members that reach the same untestable resource still count and belong in the matching category (
new FileInfo(path).LastWriteTimeUtc→ File System;new HttpClient().GetAsync(...)→ Network). Say "hidden dependency", not "static", when the member is an instance call. - Check receiver provenance before counting instance calls. Count a resource access only when the code under test acquires or constructs the dependency itself. Exclude constructor-, parameter-, property-, and DI-injected collaborators from the "needs wrapping" total, including concrete
HttpClientinstances. - Exclude deterministic pure helpers from the "needs wrapping" total.
Path.Combine,Path.GetExtension,Path.GetFileName, andMath.*/string.*statics take no ambient input and are trivially testable. List them, if at all, in a separate "no action needed" note — never as testability blockers. - Cover every category before reporting — time, file system, environment, network, console, process, randomness (
new Random(),Guid.NewGuid()), culture (CultureInfo.CurrentCulture), and serialization/statics such asJsonSerializer. Omitting a category that is present is an under-count. - Give `file:line` for every occurrence so the user can jump straight to it.
- Reconcile before publishing. The category totals, the top-patterns table, and the per-file table must sum to the same grand total.
- Treat exclusions as a scope decision, not a category. Remove
obj/,
bin/, generated, and user-excluded files before building the ledger. Do not include their files or call sites in any reported count. State the exclusions once rather than mixing excluded candidates into the arithmetic.
- Label truncated rankings. In a comprehensive audit, list all distinct
patterns when needed for reconciliation. If the user asked only for a top-N subset, label it as a subset and do not imply that its rows sum to the grand total.
Produce a summary with:
- Category summary — total call sites per category (time, filesystem, env, etc.)
- Top patterns — the 10 most frequent individual patterns ranked by count
- Most affected files — files with the highest number of static dependencies
- Existing abstractions available — for each category, note the recommended .NET abstraction:
- Time → TimeProvider (built-in since .NET 8) - File system → System.IO.Abstractions (NuGet package) - HTTP → IHttpClientFactory (built-in) - Environment → custom IEnvironmentProvider - Console → custom IConsole or ILogger - Process → custom IProcessRunner
Step 4: Present the report
Format the output as a structured report:
Related skills
Write, run, or repair .NET tests that use MSTest.
Write, run, or repair .NET tests that use NUnit.