Posted on • Originally published atdetunized.net
NUnit to xUnit automatic test conversion
I'm currently working on a major refactoring of a C# library which has many NUnit tests. I decided, without having any good reason, it would be a good idea to migrate them to xUnit. I did a few by hand and it turns out to be tedious. Like really tedious. The most common pattern is the following:
The test in NUnit
Assert.That(actual,Is.EqualTo(expected));
becomes the test in xUnit:
Assert.Equal(expected,actual);
To convert each by hand requires a lot of patience and stamina. Since I don't have either, after doing a few dozens manually I decided to automate the whole thing. The first most obvious approach would be to use a regexp and convert one to the other like so:
/Assert\.That\((.*?), Is\.EqualTo\((.*)\)\)/ -> Assert.Equal($2, $1)
It does work for some simple cases, but throw in something a bit hairier and the whole thing goes sideways. A perfectly valid small test tears this regexp to shreds:
Assert.That("));",Is.EqualTo("));"));->Assert.Equal(", "));");;"));
Not good.
The better way to do that would be to parse the source file into an AST (Abstract Syntax Tree) and perform source to source transformations on it. I've done a bit of this in the past with C++ usingClang/LLVM and with JavaScript usingAcorn parser. For C# there'sRoslyn.
How difficult could this be? Let's find out. There's some amount of documentation out there and some samples. Also, it's possible to generate a starter project with VS2017 that would do the file loading and minimal AST traversal. It's a good start, we can build on it. Here's a goodstarting point for source transformation, for example.
So here's a simplest NUnit module:
usingNUnit.Framework;namespaceTest{[TestFixture]classDumpTests{[Test]publicvoidOne_plus_one_should_be_two(){Assert.That(1+1,Is.EqualTo(2));}}}
When converted to xUnit, it becomes this:
usingXunit;namespaceTest{classDumpTests{[Fact]publicvoidOne_plus_one_should_be_two(){Assert.Equal(2,1+1);}}}
Precisely the following needs to be done:
- change
using
directive - remove
TextFixture
class attribute - replace
Test
attribute withFact
- change
Assert.That
toAssert.Equal
and swap arguments
The syntax rewriter does all the work, we just need to fill in some logic:
publicclassNunitToXunitRewriter:CSharpSyntaxRewriter{...}
Let's start with the easiest, removing theTextFixture
attribute:
publicclassNunitToXunitRewriter:CSharpSyntaxRewriter{publicoverrideSyntaxNodeVisitAttributeList(AttributeListSyntaxnode){if(ShouldRemoveTestFixture(node))returnnull;returnbase.VisitAttributeList(node);}// Checks if the node is "[TestFixture]" and should be removedprivateboolShouldRemoveTestFixture(AttributeListSyntaxnode){returnnode.Attributes.Count==1&&node.Attributes[0].Name.ToString()=="TestFixture"&&node.ParentisClassDeclarationSyntax;}}
The code, in this case, is quite simple. TheVisitAttributeList
function gets called for every attribute in the source file. We just check that the attribute list has only one attribute, that its name isTextFixture
and the parent node is a class. If it's all true, then we just returnnull
from the visitor method to indicate that the node should be deleted from the syntax tree. Easy.
Next up is theTest
attribute:
publicclassNunitToXunitRewriter:CSharpSyntaxRewriter{publicoverrideSyntaxNodeVisitAttributeList(AttributeListSyntaxnode){varnewNode=TryConvertTestAttribute(node);if(newNode!=null)returnnewNode;returnbase.VisitAttributeList(node);}// Converts "[Test]" to "[Fact]"privateSyntaxNodeTryConvertTestAttribute(AttributeListSyntaxnode){if(node.Attributes.Count!=1)returnnull;if(node.Attributes[0].Name.ToString()!="Test")returnnull;if(!(node.ParentisMethodDeclarationSyntax))returnnull;returnAttributeList(AttributeList<AttributeSyntax>(Attribute(IdentifierName("Fact")))).NormalizeWhitespace().WithTriviaFrom(node);}}
What we do here is quite similar to the previous example, with one exception that we're not deleting the node, but replacing it with something else. First, we check that it's a single attribute namedTest
and it's attached to a function. To replace it, we need to construct a new syntax node. In this case, it's the same thing, just the name is different. To build the syntax node we useSyntaxFactory
methods, likeAttributeList
,Attribute
and so on. The small quirk is theNormalizeWhitespace
andWithTriviaFrom
bits. Those make sure the resulting code is formatted and has the whitespace copied from the original node. Otherwise, the output code would look out of place and would require reformatting.
Theusing
directive change is also trivial. It's very similar to theFact
attribute situation above:
publicclassNunitToXunitRewriter:CSharpSyntaxRewriter{publicoverrideSyntaxNodeVisitUsingDirective(UsingDirectiveSyntaxnode){varnewNode=TryConvertUsingNunit(node);if(newNode!=null)returnnewNode;returnbase.VisitUsingDirective(node);}// Converts "using NUnit.Framework" to "using Xunit"privateSyntaxNodeTryConvertUsingNunit(UsingDirectiveSyntaxnode){if(node.Name.ToString()!="NUnit.Framework")returnnull;returnUsingDirective(IdentifierName("Xunit")).NormalizeWhitespace().WithTriviaFrom(node);}}
TheAssert
conversion is a much more complicated case. The problem that the expression we want to match is quite complex, even though it doesn't look like that. There's a member function accessAssert.That
and a function callAssert.That(...)
and the argument list made up of two arguments, where the second one is a member function call as well:Assert.That(actual, Is.EqualTo(expected))
. UsingRoslyn Quoter tool it's possible to generate the code that creates such an expression:
InvocationExpression(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,IdentifierName("Assert"),IdentifierName("That"))).WithArgumentList(ArgumentList(SeparatedList<ArgumentSyntax>(newSyntaxNodeOrToken[]{Argument(IdentifierName("actual")),Token(SyntaxKind.CommaToken),Argument(InvocationExpression(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,IdentifierName("Is"),IdentifierName("EqualTo"))).WithArgumentList(ArgumentList(SingletonSeparatedList<ArgumentSyntax>(Argument(IdentifierName("expected"))))))})))
In the AST form this little snippet of code looks pretty huge. When we want to replace this pattern with a different piece of code, we need to find it first. And that means we need to check against the structure of every function call expression in the file and see if it's similar:
publicclassNunitToXunitRewriter:CSharpSyntaxRewriter{publicoverrideSyntaxNodeVisitInvocationExpression(InvocationExpressionSyntaxnode){varnewNode=TryConvertAssertThatIsEqualTo(node);if(newNode!=null)returnnewNode;returnbase.VisitInvocationExpression(node);}// Converts Assert.That(actual, Is.EqualTo(expected)) to Assert.Equal(expected, actual)privateSyntaxNodeTryConvertAssertThatIsEqualTo(InvocationExpressionSyntaxnode){// Check it's Assert.That memberif(!IsMethodCall(node,"Assert","That"))returnnull;// It must have exactly two argumentsvarassertThatArgs=GetCallArguments(node);if(assertThatArgs.Length!=2)returnnull;// The second argument must be a `Is.EqualTo`varisEqualTo=assertThatArgs[1].Expression;if(!IsMethodCall(isEqualTo,"Is","EqualTo"))returnnull;// With exactly one argumentvarisEqualToArgs=GetCallArguments(isEqualTo);if(isEqualToArgs.Length!=1)returnnull;// Grab the argumentsvarexpected=isEqualToArgs[0];varactual=assertThatArgs[0];// Build a new AST with the actual and expected nodes inserted into itreturnInvocationExpression(MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,IdentifierName("Assert"),IdentifierName("Equal"))).WithArgumentList(ArgumentList(SeparatedList<ArgumentSyntax>(newSyntaxNodeOrToken[]{expected,Token(SyntaxKind.CommaToken),actual}))).NormalizeWhitespace().WithTriviaFrom(node);}}
To match the expression we have to drill down into the AST and compare node by node. It's very tedious, but luckily after the code is written it will convert all the tests that have a similar structure. Write once, run many times. The two helper functions that are used in this matching code look like this:
privateboolIsMethodCall(ExpressionSyntaxnode,stringobjekt,stringmethod){varinvocation=nodeasInvocationExpressionSyntax;if(invocation==null)returnfalse;varmemberAccess=invocation.ExpressionasMemberAccessExpressionSyntax;if(memberAccess==null)returnfalse;if((memberAccess.ExpressionasIdentifierNameSyntax)?.Identifier.ValueText!=objekt)returnfalse;if(memberAccess.Name.Identifier.ValueText!=method)returnfalse;returntrue;}privateArgumentSyntax[]GetCallArguments(ExpressionSyntaxnode){return((InvocationExpressionSyntax)node).ArgumentList.Arguments.ToArray();}
In case the expression is a match, we take theexpected
andactual
arguments, or the AST nodes that represent them, to be exact and wrap them into a different AST that represents the xUnit equivalent:Assert.Equal(expected, actual)
.
Not that crazy difficult. But now we have a tool that can convert a majority of tests from NUnit to xUnit automagically. And it not only converts theAssert
expressions but the whole file. Nice!
The sucky part is that the matching code is very specific to the expression we're trying to convert. So if we have a few variations of theAssert
it would take writing so much code for every case. It's gonna very quickly get out of control. Imagine just a few very simple variations:
Assert.That(actual,Is.True());Assert.That(actual,Is.EqualTo(true));Assert.That(actual,Is.False());Assert.That(actual,Is.EqualTo(false));
To cover most common NUnit cases we'd have to write hundreds of those matching functions with very repetitive code. That would be aLOT of work. Can we do better? Yes, we can! I have an idea and I'll describe in the next post.
Conclusion
In only175 lines of code we have a fully functional converter that does in a second what takes a lot of time to do by hand. Even though it's just a proof of concept and doesn't cover any significant amount of NUnit assertions, I was able to convert a few files with tests with almost no additional fixing.
Originally published ondetunized.net
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse