How to unit test T4 code generators

So, you're like me and have a greater than 500 line T4 template that is a steaming pile of... code. And of course, no syntax highlighting without addons, no intellisense, generally horrible Visual Studio support, and near impossible to unit test.

Well, my friends, I have just the thing for you! After beating my head against a wall for several days, I've found salvation!

To use my method requires some "neat" code, and it requires a few assumptions:

  • You're using T4 to generate C# code
  • You're then taking this C# code and including it in your project
  • You're ready to do some major refactoring for an awesome experience in the end (and your T4 template will be so much cleaner afterwards!)
  • You can have at least 2 tiles tied to T4 (one T4 template and one include file)

The corner stone of making T4 testable is to separate out the "logic" and the "content"; where the content is the generated C# code, and the logic is what you do to generate it.

To do this and enforce this separation cleanly, you must have two files. One of these files is the T4 "view", and another file is the logic, which is capable of being normally compiled outside of T4.

Example Untestable T4 Code

Lets start with a simple example. You have a simple T4 template which takes a file like so:

foo=bar
biz=baz

and turns it into

public class GeneratedClass
{
  public foo="bar";
  public biz="baz";
}

Here is a simple(and untestable) T4 template for it.:

<#@ template language="C#v3.5" hostspecific="true"#>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System" #>
<#@ import namespace="System.Linq" #>

using System;
namespace Earlz.SampleT4
{
    public class GeneratedClass
    {
<#
    string filename="fields.txt";

    string path=Path.GetDirectoryName(Host.TemplateFile);
    var f=File.OpenText(Path.Combine(path, filename));
    string text=f.ReadToEnd();
    text=text.Replace("\r", ""); //strip extra line endings (if needed)
    var lines=text.Split('\n');
    foreach(var line in lines)
    {
        var parts=line.Split('='); //split for each element
#>
        public string <#= parts[0] #> = @"<#= parts[1].Replace("\"", "\"\"") #>";
<#
    }
#>
    }
}

There are a few rumored methods of testing this T4 file:

  • Run an integration test manually comparing the code (very very brittle)
  • I can't think of anything else worth mentioning

The Great Refactor

Now, What I suggest:

Let's refactor this and make it so we can eliminate some logic out of our "view". In this case, the view should worry about getting the code into the generated file and that's it. The logic on the other hand should work at a level of abstraction.

Here's what we're going to do:

  • Eliminate most of this code from the view
  • Create a new file for the logic
  • Use a very clever trick so that our logic file will compile outside of T4 and work when included in T4
  • Make it so that instead of just outputting text, we're building an object model that happens to be easily translatable to text

Because I came prepared, I already have the object model abstraction built. you can catch it at the bottom of the last code snippet. (It's easy to rip out and put elsewhere)

Compiling Code In and Out of T4

So, we create a new T4 logic file and name it something like "GenerateClassLogic.tt.cs". Now, you may be thinking "but you can't use the same code in T4 and a regular project!" *WRONG!

I came across a very nifty trick. Behold:

//<#+
/*That line above is very carefully constructed to be awesome and make it so this works!*/
#if NOT_IN_T4
//Apparently T4 places classes into another class, making namespaces impossible
namespace MyNamespace.Foo.Bar
{
    using System;
    using System.Linq;
    using System.Text;
    using System.Collections.Generic;
    using System.IO;
#endif
//regular ol' C# classes and code...

#if NOT_IN_T4
} //end the namespace
#endif
//#>

The key parts are the first and last lines. These begin with a comment so that the C# compiler will ignore them outside of T4, but inside of T4 these instruct the transformer to include this as a "class feature" (which ends up being a nested class)

Note here also that you must define a compile symbol. This is super easy to do. If you add it to your project, then it won't carry over to the T4 template though, making this an easy way to add in a few key things that can't be done without knowing if we're executing within T4 or not.

So, now we have a T4 logic file that will compile inside and outside of T4. Perfect for unit testing! All we need now is some logic!

The Result

Here is what I came up with:

//<#+
/*That line above is very carefully constructed to be awesome and make it so this works!*/
#if NOT_IN_T4
//Apparently T4 places classes into another class, making namespaces impossible
namespace Earlz.SampleT4.Internal
{
    using System;
    using System.Linq;
    using System.Text;
    using System.Collections.Generic;
    using System.IO;
#endif
    //regular ol' C# classes and code...

    public class GenerateClassFromText : ClassGenerator
    {
        public GenerateClassFromText(string text)
        {
            Init(text);
        }
        public GenerateClassFromText(string templatefile, string filename)
        {
            string path=Path.GetDirectoryName(templatefile);
            var f=File.OpenText(Path.Combine(path, filename));
            Init(f.ReadToEnd());
        }
        public void Init(string text)
        {
            text=text.Replace("\r", ""); //strip extra line endings (if needed)
            var lines=text.Split('\n');
            foreach(var line in lines)
            {
                var parts=line.Split('='); //split for each element
                var field=new Field
                {
                    Accessibility="public",
                    Name=parts[0],
                    Type="string",
                    InitialValue=string.Format("@\"{0}\"",parts[1].Replace("\"", "\"\""))
                };
                Fields.Add(field);
            }
        }

    }

    //shove this all into one file so we don't force implementers to hand combine this or copy over more than 2 files
    public class ClassGenerator : CodeElement
    {
        virtual public List<Property> Properties
        {
            get;
            private set;
        }
        virtual public List<Method> Methods
        {
            get;
            private set;
        }
        virtual public List<Field> Fields
        {
            get;
            private set;
        }
        virtual public string Namespace
        {
            get;set;
        }
        virtual public string OtherCode
        {
            get;set;
        }
        public virtual string BaseClass
        {
            get;set;
        }
        public ClassGenerator()
        {
            Properties=new List<Property>();
            Methods=new List<Method>();
            Fields=new List<Field>();
            Accessibility="";
        }
        public override string ToString ()
        {
            StringBuilder sb=new StringBuilder();
            sb.Append("namespace "+Namespace);
            sb.AppendLine("{");
            sb.AppendLine(PrefixDocs);
            sb.Append(GetTab(1)+Accessibility+" class "+Name);
            if(string.IsNullOrEmpty(BaseClass))
            {
                sb.AppendLine();
            }
            else
            {
                sb.AppendLine(": "+BaseClass);
            }
            sb.AppendLine(GetTab(1)+"{");
            foreach(var p in Properties)
            {
                sb.AppendLine(p.ToString());
            }
            foreach(var m in Methods)
            {
                sb.AppendLine(m.ToString());
            }
            foreach(var f in Fields)
            {
                sb.AppendLine(f.ToString());
            }
            sb.AppendLine(OtherCode);
            sb.AppendLine(GetTab(1)+"}");
            sb.AppendLine("}");
            return sb.ToString();
        }

    }
    abstract public class CodeElement
    {
        public const string Tab="    ";
        public string Name
        {
            get;
            set;
        }
        public string Accessibility
        {
            get;
            set;
        }
        string prefixdocs;
        virtual public string PrefixDocs
        {
            get
            {
                return prefixdocs;
            }
            set
            {
                prefixdocs=GetTab(2)+"///<summary>\n"+GetTab(2)+"///"+value+"\n"+GetTab(2)+"///</summary>";
            }
        }
        public override string ToString ()
        {
            throw new NotImplementedException();
        }
        public static string GetTab(int nest)
        {
            string tmp="";
            for(int i=0;i<nest;i++)
            {
                tmp+=Tab;
            }
            return tmp;
        }
        protected CodeElement()
        {
            Accessibility="";
            PrefixDocs="";
        }
    }
    public class Property : CodeElement
    {
        public string Type
        {
            get;set;
        }
        public string GetMethod
        {
            get;
            set;
        }
        public string SetMethod
        {
            get;
            set;
        }
        public override string ToString ()
        {
            string tmp=GetTab(2)+PrefixDocs+"\n";
            tmp+=GetTab(2)+CodeElement.Tab+Accessibility+" "+Type+" "+Name+"{\n";
            if(GetMethod!=null)
            {
                tmp+=GetTab(2)+GetMethod+"\n";
            }
            if(SetMethod!=null)
            {
                tmp+=GetTab(2)+SetMethod+"\n";
            }
            tmp+=GetTab(2)+"}\n";
            return tmp;
        }
        public Property()
        {
            GetMethod="get;";
            SetMethod="set;";
        }
    }
    public class Field : CodeElement
    {
        public string Type
        {
            get;
            set;
        }
        public string InitialValue
        {
            get;
            set;
        }
        public override string ToString ()
        {
            string tmp=GetTab(2)+PrefixDocs+"\n";
            tmp+=GetTab(2)+Accessibility+" " +Type+" " +Name;
            if(InitialValue!=null)
            {
                tmp+="="+InitialValue+";";
            }else{
                tmp+=";";
            }
            return tmp;
        }
    }
    public class Method : CodeElement
    {
        public string ReturnType
        {
            get;
            set;
        }
        public List<MethodParam> Params
        {
            get;set;
        }
        public string Body
        {
            get;set;
        }
        public Method()
        {
            Params=new List<MethodParam>();
            Body="";
            ReturnType="void";
        }
        public override string ToString ()
        {
            string tmp=GetTab(2)+PrefixDocs+"\n";
            tmp=GetTab(2)+Accessibility+" "+ReturnType+" "+Name+"(";
            for(int i=0;i<Params.Count;i++)
            {
                tmp+=Params[i].ToString();
                if(i==Params.Count-1)
                {
                    tmp+=")";
                }
                else
                {
                    tmp+=", ";
                }
            }
            if(Params.Count==0)
            {
                tmp+=")";
            }
            tmp+="\n"+GetTab(2)+"{\n";
            tmp+=Body;
            tmp+="\n"+GetTab(2)+"}";
            return tmp;
        }
    }
    public class MethodParam
    {
        public string Name{get;set;}
        public string Type{get;set;}
        public override string ToString ()
        {
            return Type+" "+Name;
        }
    }
#if NOT_IN_T4
} //end the namespace
#endif
//#>

Wow, so much cleaner! Plus, go to modify it in Visual Studio. What's that? Theirs actually intellisense!? Yes! There is! It will also throw compiler errors when you screw stuff up.

Also, I trimmed the T4 view down significantly as well and of course put in an include statement for our logic file:

<#@ template language="C#v3.5" hostspecific="true"#>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System" #>
<#@ import namespace="System.Linq" #>

<#
    string filename="fields.txt";
    var gen=new GenerateClassFromText(Host.TemplateFile, filename);
    gen.Namespace="Earlz.SampleT4";
    gen.Name="GeneratedClass";
    gen.Accessibility="public";
#>

<#= gen.ToString() #>

<#@ include file="GenerateClassLogic.tt.cs" #>

Wow that's simple!

Now note: None of the stuff in your "view" is testable. You should always keep that in mind and keep it as simple as humanely possible.

If you'll notice, other than a bit of boilerplate XML documentation, the generated code is exactly the same. This is intentional :)

Unit Testing T4

What were we trying to do again? Ah yes. Tests. Let's add some NUnit tests for this!

And so, by simply adding a reference to our project in the NUnit test project, we instantly get access to the logic of the code generator. Here is the quick testing I did:

[TestFixture]
public class CodeGeneratorTests
{
    string TestText=
@"Foo=Bar
Biz=Baz
TestQuotes=""foo bar""";

    [Test]
    public void EnsureFieldWritten()
    {

        var gen=new GenerateClassFromText(TestText);
        Assert.IsTrue(gen.Fields.Any(x=>x.Name=="Foo"));
        Assert.IsTrue(gen.Fields.Any(x=>x.Name=="Biz"));
    }
    [Test]
    public void EnsureFieldsPublic()
    {
        var gen=new GenerateClassFromText(TestText);
        var tmp=gen.Fields.Single(x=>x.Name=="Foo");
        Assert.AreEqual(tmp.Accessibility, "public");
    }
    [Test]
    public void EnsureFieldsQuoted()
    {
        var gen=new GenerateClassFromText(TestText);
        var tmp=gen.Fields.Single(x=>x.Name=="TestQuotes");
        Assert.AreEqual(tmp.InitialValue, @"@""""""foo bar""""""");
    }
}

Conclusion and Remarks

So, in conclusion, we've learned that T4 is actually capable of taming. (when I first learned T4, I wouldn't have thought it possible either) The main thing to do is maintain a separation of "content" and logic. Now of course, there are some gotchas to watch for:

  • This only works when your T4 generator's target is to generate C# code
  • When you add a reference to something, you must add it to both the logic file and the view
  • It's still very difficult to reference external assemblies(particularly project assemblies). This doesn't solve that problem at all
  • You must define a compiler symbol for your project if you wish to run unit tests against it.
  • When other people use your T4 template outside of your project(and thus don't need to test it), they must ensure that the logic file is not compiled outside of the T4
  • Unit testing that a piece of code is generated is very difficult and brittle(hence the need for abstractions where possible to make this easier)
  • My abstraction mechanisms for building objects aren't really that good. They're good enough for me, but please someone improve them! (if you do it I'll link to you from here)

Happy code generation!

Posted: 11/21/2012 3:46:10 AM

Comments

Posting comments is currently disabled(probably due to spam)