Allows incremental improvements of .NET architecture for large teams, or a legacy codebase. Inspired by betterer but for .NET.
Status: functional parity with
betterer's core, plus .NET-native extensions. See DOCUMENTATION.md for the full user guide, and ROADMAP.md for the plan and what's implemented.
Write a test that reports the current set of issues in your codebase (architecture violations, lint findings, etc.). BettererNet records an accepted baseline and then:
- New issues fail the test — regressions are caught immediately.
- Fixed issues ratchet the baseline down — improvements are locked in automatically and cannot be undone.
- The baseline lives in a single, diff-friendly, deterministically-sorted
.betterer.resultsfile that you commit to source control.
BettererNet uses BettererNet: this repo's CI runs betterernet ci against its own committed
baseline (see betterer.json), so new #pragma warning disable, async void,
NotImplementedException, or sync-over-async calls fail the build.
src/
BettererNet.Core # engine: results-file reader/writer, result model
BettererNet.Xunit # xUnit adapter — assert against the baseline from your tests
BettererNet.Cli # `betterernet` global tool — ci/start/watch/precommit, betterer.json
samples/
SampleProject # a project with intentional issues
SampleTest # example Betterer tests (NetArchTest-based)
tools/
InspectCodeSnapshot # legacy ReSharper-snapshot prototype (folded into Roslyn integration later)
tests/
BettererNet.Tests # unit tests for the core + xUnit adapter
- .NET SDK 10.0 or newer.
dotnet build BettererNet.slnxUnit, NUnit and MSTest run on VSTest; TUnit runs on
Microsoft.Testing.Platform,
which can't share a dotnet test invocation with them on the .NET 10 SDK. Run the
VSTest suites and the TUnit suite separately (as CI does):
# xUnit / NUnit / MSTest (VSTest)
dotnet test tests/BettererNet.Tests
dotnet test tests/BettererNet.MSTest.Tests
dotnet test tests/BettererNet.NUnit.Tests
dotnet test tests/BettererNet.Roslyn.MSBuild.Tests
dotnet test samples/SampleTest
# TUnit (Microsoft.Testing.Platform)
dotnet run --project tests/BettererNet.TUnit.TestsReference BettererNet.Xunit from your test project, then assert against the baseline:
using BettererNet;
using NetArchTest.Rules;
using Xunit;
public class ArchitectureTests
{
[Fact]
public async Task InterfacesShouldStartWithI()
{
var result = Types.InAssembly(typeof(MyMarker).Assembly)
.That().AreInterfaces()
.Should().HaveNameStartingWith("I")
.GetResult();
// result.FailingTypeNames -> BettererResult
var betterer = new BettererResult();
betterer.FailingTypeNames.AddRange(result.FailingTypeNames);
await new Betterer().AssertAsync(betterer);
}
}new Betterer() keys the result by the calling test method name and stores it in
.betterer.results in the test project directory. Pass an explicit testName or
resultsPath to the constructor to override either.
By default, a test that reports issues with no existing baseline fails — accepting a new
baseline is meant to be an explicit choice. Set the BETTERER_UPDATE environment variable to
record the current results as the baseline (it also accepts regressions, like betterer's
--update):
BETTERER_UPDATE=1 dotnet testCommit the generated .betterer.results. From then on, run dotnet test normally: it passes
while issues stay the same or shrink, and fails when new issues appear.
The same adapter runs any engine test, so you can also assert counting and file tests:
// Counting test: keep a number from growing (e.g. compiler warnings), aiming for zero.
await new Betterer().AssertAsync(
BettererCountTest.Create("CompilerWarnings", CountWarnings, goal: 0));
// File test: track issues per file (line/column/length/message), with a stable,
// line-independent hash so issues survive code moving around.
await new Betterer().AssertAsync(BettererFileTest.Create("Analyzer", () =>
{
var issues = new BettererFileIssues();
issues.Add("Foo.cs", line: 10, column: 5, length: 3, message: "CA1822: mark member static");
return issues;
}));BettererNet.Regex and BettererNet.Roslyn provide ready-made file tests:
// Regex: ban a pattern and burn down existing matches.
await new Betterer().AssertAsync(
BettererRegexTest.Create("NoConsoleWriteLine", @"Console\.WriteLine", new[] { "**/*.cs" }));
// Roslyn compiler diagnostics: adopt nullable reference types incrementally.
await new Betterer().AssertAsync(BettererRoslynTest.Diagnostics(
"Nullable", sourceFiles,
filter: d => d.Id.StartsWith("CS8"),
compilationOptions: new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)));
// Nullable-adoption preset (BettererNet.Roslyn.MSBuild): the turnkey "enable #nullable and
// burn the warnings down" recipe over a real project — goal defaults to zero.
await new Betterer().AssertAsync(BettererNullableTest.Create("Nullable", "src/App/App.csproj"));
// Roslyn syntax query (the tsquery analog): count nodes you want to eliminate.
await new Betterer().AssertAsync(BettererRoslynTest.SyntaxQuery(
"NoGoto", sourceFiles, node => node is GotoStatementSyntax));
// Roslyn analyzers (the eslint analog): track any DiagnosticAnalyzer's findings.
await new Betterer().AssertAsync(BettererRoslynTest.Analyzers("Style", sourceFiles, analyzers));
// Coverage: track uncovered lines from a Cobertura report, aiming for full coverage.
await new Betterer().AssertAsync(
BettererCoverageTest.Create("Coverage", "coverage.cobertura.xml", goal: BettererFileTest.NoIssues));
// Architecture (NetArchTest): baseline existing violations, fail on new ones.
await new Betterer().AssertAsync(BettererArchTest.Create("Layering", () =>
Types.InAssembly(assembly).That().ResideInNamespace("App.Domain")
.ShouldNot().HaveDependencyOn("App.Infrastructure").GetResult()));
// SARIF: import any analyzer's report (the whole SARIF ecosystem) as a file test.
await new Betterer().AssertAsync(BettererSarifTest.Create("Analyzers", "analysis.sarif"));
// dotnet format: adopt strict formatting/style incrementally. Run
// `dotnet format --verify-no-changes --report format-report.json` in CI, then track its findings.
await new Betterer().AssertAsync(BettererFormatTest.Create("Format", "format-report.json"));Install the tool, then define a suite — declaratively or in code:
dotnet tool install --global BettererNet.Cli # run as `betterernet`Declarative betterer.json (no code; covers the data-driven tests — regex, coverage, SARIF, dotnet-format):
{
"results": ".betterer.results",
"tests": {
"NoTodos": { "type": "regex", "pattern": "TODO", "includes": ["**/*.cs"] },
"Coverage": { "type": "coverage", "report": "coverage.cobertura.xml", "goalZero": true },
"Analyzers": { "type": "sarif", "report": "analysis.sarif", "levels": ["error"],
"owner": "@platform-team", "budget": 50 }
}
}Any test can carry an owner (a person/team, surfaced in reports so debt routes to the right
place) and a budget (a hard ceiling on the issue count — a run over budget fails even if it
improved on its baseline, and is never recorded above the ceiling). In code, wrap any test with
.WithOwnership(owner, budget).
betterernet ci auto-detects betterer.json in the working directory. Tests that need code (Roslyn
syntax queries, NetArchTest rules) use a compiled config assembly — a class library implementing
IBettererSuiteProvider, passed with --config My.dll.
betterernet start # run; record improvements, fail on regressions
betterernet ci # fail if the results file is out of date or regressed (no write)
betterernet watch # re-run on .cs changes
betterernet precommit # run, then `git add` the results
betterernet results # print the current results file
betterernet init # scaffold a starter betterer.json (--automerge also sets up the git merge driver)
betterernet merge <base> <ours> <theirs> # resolve a .betterer.results conflictCommon options: --results <path>, --filter <regex> (repeatable; a leading ! negates),
--update (accept regressions), --workers <n> (parallelism),
--reporter <console|github|silent> (the github reporter emits CI annotations + a step-summary
table), --sarif <path> (also write a SARIF report of current issues), --markdown <path> (run
summary to post as a PR comment), --history <path> (append a snapshot + render a trend),
--cache (skip tests with unchanged inputs), --silent.
Prefer NUnit, MSTest, or TUnit?
BettererNet.NUnit,BettererNet.MSTest, andBettererNet.TUnitexpose the samenew Betterer().AssertAsync(...)API.
With --automerge configured, git resolves .betterer.results conflicts automatically by taking
the tightest baseline (so no branch's improvements are lost).