Migrate from xUnit Assert to Axiom¶
The xUnit path is the strongest analyzer-backed migration path in Axiom today. It is still intentionally conservative: the analyzer helps with safe, direct rewrites and leaves ambiguous tests alone.
When This Migration Path Is A Good Fit¶
This path is a good fit when your xUnit tests mostly use straightforward Assert.* calls and you want to move gradually to fluent assertions without redesigning the whole suite at once.
It works best when tests already check one clear fact per assertion: equality, nullability, string containment, collection membership, dictionary keys, single-item expectations, type checks, simple range checks, or direct exception assertions.
It is a weaker fit when your suite leans heavily on custom comparers, precision rules, string-equality options, inspectors, or broad structural comparisons.
What Axiom Can Rewrite Safely Today¶
The xUnit analyzer support covers common scalar, string, dictionary-key, Single(...), and exception shapes when the rewrite preserves value flow.
Examples include:
Assert.Equal(expected, actual)toactual.Should().Be(expected)Assert.Contains("sub", actual, StringComparison.OrdinalIgnoreCase)toactual.Should().Contain("sub", StringComparison.OrdinalIgnoreCase)Assert.Contains(key, lookup)tolookup.Should().ContainKey(key)and.WhoseValuewhen the old returned value was usedAssert.Single(values)andAssert.Single(values, predicate)toContainSingle(...)and.SingleItemwhen neededAssert.IsNotAssignableFrom<T>(value)tovalue.Should().NotBeAssignableTo<T>()Assert.InRange(actual, low, high)toactual.Should().BeInRange(low, high)for simple comparable valuesAssert.Throws<TException>(...), awaitedAssert.ThrowsAsync<TException>(...), and awaitedAssert.ThrowsAnyAsync<TException>(...)when the target Axiom exception assertion is exact
For exact rule IDs and edge cases, use the Analyzer reference.
A Recommended First Migration Pass¶
Start with one project or folder. Apply only analyzer-provided code fixes, then review the diff with this question: did the assertion intent stay the same?
Good first-pass candidates:
- scalar checks
- simple string checks
- simple collection checks
- dictionary key checks
Single(...)checks where the returned value flow remains obvious- direct type and range checks
- direct exception assertions
Do not use the first pass to redesign structural assertions. Mark those for manual review.
Before/After Examples¶
Scalar and string assertions:
Before:
Assert.Equal(expected, actual);
Assert.NotNull(actual);
Assert.Contains("sub", actual);
Assert.Contains("sub", actual, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith("pre", actual);
After:
actual.Should().Be(expected);
actual.Should().NotBeNull();
actual.Should().Contain("sub");
actual.Should().Contain("sub", StringComparison.OrdinalIgnoreCase);
actual.Should().StartWith("pre");
Dictionary-key and Single(...) assertions:
Before:
Assert.Contains(key, lookup);
var found = Assert.Contains(key, lookup);
var item = Assert.Single(values);
var match = Assert.Single(values, IsPositive);
After:
lookup.Should().ContainKey(key);
var found = lookup.Should().ContainKey(key).WhoseValue;
var item = values.Should().ContainSingle().SingleItem;
var match = values.Should().ContainSingle(IsPositive).SingleItem;
Type and range assertions:
Before:
Assert.IsNotAssignableFrom<IDisposable>(actualObject);
Assert.InRange(count, minimum, maximum);
After:
actualObject.Should().NotBeAssignableTo<IDisposable>();
count.Should().BeInRange(minimum, maximum);
Exception assertions:
Before:
Assert.Throws<InvalidOperationException>(() => work());
var ex = Assert.Throws<ArgumentNullException>("name", () => work());
After:
new Action(() => work()).Should().Throw<InvalidOperationException>();
var ex = new Action(() => work()).Should().Throw<ArgumentNullException>().WithParamName("name").Thrown;
Async exception assertions:
Before:
await Xunit.Assert.ThrowsAsync<InvalidOperationException>(
() => Task.FromException(new InvalidOperationException()));
await Xunit.Assert.ThrowsAnyAsync<Exception>(
() => Task.FromException(new ArgumentException()));
After:
await new Func<Task>(() => Task.FromException(new InvalidOperationException()))
.Should()
.ThrowExactlyAsync<InvalidOperationException>();
await new Func<Task>(() => Task.FromException(new ArgumentException()))
.Should()
.ThrowAsync<Exception>();
What To Keep Manual For Now¶
Keep these manual during an xUnit migration:
- structural comparisons that should become
BeEquivalentTo(...) - string-equality special cases such as ignore-case equality options
- precision and tolerance assertions
- comparer edge cases that do not map directly to a current local-comparer assertion
Assert.IsNotType<T>(...), because Axiom does not currently expose an exact type-exclusion assertionAssert.NotInRange(...)and comparer-bearing range overloads- inspector overloads and message-bearing overloads
- consumed exception cases outside the supported
.Thrownflows - span or memory string overloads
Manual does not mean unsupported forever. It means the analyzer should not guess.
Practical Staged Rollout¶
A realistic rollout is:
- Enable
Axiom.Assertionsin one test project. - Apply xUnit analyzer fixes for simple scalar and string assertions.
- Review dictionary-key,
Single(...), and exception rewrites carefully because they can carry returned values. - Move structural tests to
BeEquivalentTo(...)only when the test intent is object-graph comparison. - Repeat by folder or feature area.
- Keep a short list of skipped patterns so the team knows which tests need manual treatment.
This keeps the migration small enough to review and easy to pause.
When Not To Migrate Yet¶
Do not migrate yet if the current xUnit assertions are clear, the team is not feeling friction, or most of the suite depends on precision, comparer, inspector, or structural semantics that need review.
In that case, start by improving the tests that are unclear. A later Axiom migration will be safer if the assertion intent is already explicit.