Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

Cover image for NUnit to xUnit automatic test conversion
Dmitry Yakimenko
Dmitry Yakimenko

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:

  • changeusing directive
  • removeTextFixture class attribute
  • replaceTest attribute withFact
  • changeAssert.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 and
WithTriviaFrom 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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Grew up in Russia, lived in the States, moved to Germany, sometimes live in Spain. I program since I was 13. I used to program games, maps and now I reverse engineer password managers and other stuff
  • Location
    Berlin and Málaga
  • Education
    MS in CS from State Polytechnic University of St. Petersburg
  • Work
    Principal Software Engineer at HERE
  • Joined

More fromDmitry Yakimenko

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp