But it works on my PC!

The random thoughts of Richard Fennell on technology and software development

Lessons learnt building a custom activity to run Typemock Isolator in VS2010 Team Build

Updated 25th March 2010 - All the source is now available at the Typemock Add-in site 
Updated 2nd July 2010 - Some usage notes posted
Updated 26th July 2011 - More usage notes
Updated 21st Nov 2011 - Typemock Isolator now has direct support for TFS 2010 Build, see usage notes 

I have previously posted on how you can run Typemock Isolator based tests within a VS2010 using the InvokeMethod activity. After this post Gian Maria Ricci, a fellow Team System MVP suggested it would be better to put this functionality in a custom code activity, and provided the basis of the solution. I have taken this base sample and worked it up to be a functional activity, and boy have I learnt a few things doing it.

Getting the custom activity into a team build

Coding up a custom Team Build activity is not easy, there are a good few posts on the subject (Jim Lamb’s is a good place to start). The problem is not writing the code but getting the activity into the VS toolbox. All documentation gives basically the same complex manual process, there is no way of avoiding it. Hopefully this will be addressed in a future release of Visual Studio. But for now the basic process is this:

  1. Create a Class Library project in your language of choice
  2. Code up your activity inheriting it from the CodeActivity<T> class
  3. Branch the build workflow, that you wish to use for testing, into the folder of the class library project
  4. Add the build workflow’s .XAML file to the class library project then set it’s properties: “build action” to none and “copy to output directory” to do not copy
  5. Open the .XAML file (in VS2010), the new activity should appear in the toolbox, it can be dropped onto the workflow. Set the properties required.
  6. Check in the file .XAML file
  7. Merge the .XAML file to the original location, if you get conflicts simply tell merge to use the new version discarding the original version, so effectively overwriting the original version with the version edited in the project.
  8. Check in the merged original .XAML file that now contains the modifications.
  9. Take the .DLL containing the new activity and place it in a folder under source control (usually under the BuildProcessTemplates folder)
  10. Set the Build Controller’s custom assemblies path to point to this folder (so your custom activity can be loaded) 

    image
  11. Run the build and all should be fine

But of course is wasn’t. I kept getting the error when I ran a build

TF215097: An error occurred while initializing a build for build definition \Typemock Test\BuildTest Branch: Cannot create unknown type '{clr-namespace:TypemockBuildActivity}ExternalTestRunner'.

This was because I had not followed the procedure correctly. I had tried to be clever. Instead of step 6 and onwards I had had an idea. I created a new build that referenced the branched copy of the .XAML file in the class library project directly. I thought this would save me a good deal of tedious merging while I was debugged my process. It did do this but introduced other issues

The problem was when I inspected the .XAML in my trusty copy of Notepad, I saw that there was no namespace declared for my assembly (as the TF21509 error suggested). If I looked at the actual activity call in the file it was declared as <local:ExternalTestRunner  …… />, the local: replacing the namespace reference I would expect. This is obviously down to the way I was editing the .XAML file in the VS2010.

The fix is easy, using Notepad I added a namespace declaration to the Activity block

<Activity ……    xmlns:t="clr-namespace:TypemockBuildActivity;assembly=TypemockBuildActivity" >

and then edited the references from local: to t: (the alias for my namespace) for any classes called from the custom assembly e.g.

<t:ExternalTestRunner ResultsFileRoot="{x:Null}" BuildNumber="[BuildDetail.Uri.ToString()]" Flavor="[platformConfiguration.Configuration]" sap:VirtualizedContainerService.HintSize="200,22" MsTestExecutable="C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe" Platform="[platformConfiguration.Platform]" ProjectCollection="http://typhoon:8080/tfs/DefaultCollection" Result="[ExternalTestRunnerResult]" ResultsFile="ExternalTestRunner.Trx" SearchPathRoot="[outputDirectory]" TeamProjectName="[BuildDetail.TeamProject]" TestAssemblyNames="[testAssemblies.ToArray()]" TestRunnerExecutable="C:\Program Files (x86)\Typemock\Isolator\6.0\TMockRunner.exe" TestSettings="[localTestSettings]" />

Once this was done I could use my custom activity in a Team Build, though I had to make this manual edit every time I edited the branched .XAML file in VS2010 IDE. So I had swapped repeated merges with repeated editing, you take your view as to which is worst.

So what is in my Typemock external test runner custom activity?

The activity is basically the same as the one suggest by Gian Maria, it takes all the same parameters as the MSTest team build activity and then executes the TMockRunner to wrapper MSTest. What I have done is add a couple of parameters that were missing in the original sample and also added some more error traps and logging.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Activities;
using System.IO;
using System.Diagnostics;
using Microsoft.TeamFoundation.Build.Workflow.Activities;
using Microsoft.TeamFoundation.Build.Client;
using System.Text.RegularExpressions;
 
namespace TypemockBuildActivity
{
    public enum ExternalTestRunnerReturnCode { Unknown =0 , NotRun, Passed, Failed };
 
    [BuildExtension(HostEnvironmentOption.Agent)]
    [BuildActivity(HostEnvironmentOption.All)]
    public sealed class ExternalTestRunner : CodeActivity<ExternalTestRunnerReturnCode>
    {
        // Define an activity input argument of type string  
 
        /// <summary>
        /// The name of the wrapper application, usually tmockrunner.exe
        /// </summary>
        public InArgument<string> TestRunnerExecutable { get; set; }
        
        /// <summary>
        /// The name of the application that actually runs the test, defaults to MSTest.exe if not set
        /// </summary>
        public InArgument<string> MsTestExecutable { get; set; }
 
        /// <summary>
        /// The project collection to publish to e.g. http://tfs2010:8080/tfs/DefaultCollection
        /// </summary>
        public InArgument<string> ProjectCollection { get; set; }
 
        /// <summary>
        /// The build ID to to publish to e.g. vstfs:///Build/Build/91
        /// </summary>
        public InArgument<string> BuildNumber { get; set; }
 
        /// <summary>
        /// The project name to publish to e.g: "Typemock Test"
        /// </summary>
        public InArgument<string> TeamProjectName { get; set; }
 
        /// <summary>
        /// The platform name to publish to e.g. Any CPU
        /// </summary>
        public InArgument<string> Platform { get; set; }
 
        /// <summary>
        /// The flavour (configuration) to publish to e.g. "Debug"
        /// </summary>
        public InArgument<string> Flavor { get; set; }
 
 
        /// <summary>
        /// Array of assembly names to test
        /// </summary>
        public InArgument<string[]> TestAssemblyNames { get; set; }
        
        /// <summary>
        /// Where to search for assemblies under test
        /// </summary>
        public InArgument<string> SearchPathRoot { get; set; }
        
        /// <summary>
        /// A single name result file
        /// </summary>
        public InArgument<string> ResultsFile { get; set; }
 
        /// <summary>
        /// A directory to store results in (tends not be used if the ResultFile is set)
        /// </summary>
        public InArgument<string> ResultsFileRoot { get; set; }
 
        /// <summary>
        /// The file that list as to how test should be run
        /// </summary>
        public InArgument<string> TestSettings { get; set; }
 
 
        // If your activity returns a value, derive from CodeActivity<TResult> 
        // and return the value from the Execute method. 
        protected override ExternalTestRunnerReturnCode Execute(CodeActivityContext context)
        {
            String msTestOutput = string.Empty;
            ExternalTestRunnerReturnCode exitMessage = ExternalTestRunnerReturnCode.NotRun;
 
            if (CheckFileExists(TestRunnerExecutable.Get(context)) == false)
            {
                LogError(context, string.Format("TestRunner not found {0}", TestRunnerExecutable.Get(context)));
            }
            else
            {
                String mstest = MsTestExecutable.Get(context);
                if (CheckFileExists(mstest) == false)
                {
                    mstest = GetDefaultMsTestPath();
                }
 
                String testrunner = TestRunnerExecutable.Get(context);
 
                var arguments = new StringBuilder();
                arguments.Append(string.Format("\"{0}\"", mstest));
                arguments.Append(" /nologo ");
 
                // the files to test
                foreach (string name in TestAssemblyNames.Get(context))
                {
                    arguments.Append(AddParameterIfNotNull("testcontainer", name));
                }
 
                // settings about what to test
                arguments.Append(AddParameterIfNotNull("searchpathroot", SearchPathRoot.Get(context)));
                arguments.Append(AddParameterIfNotNull("testSettings", TestSettings.Get(context)));
                
                // now the publish bits
                if (string.IsNullOrEmpty(ProjectCollection.Get(context)) == false)
                {
                    arguments.Append(AddParameterIfNotNull("publish", ProjectCollection.Get(context)));
                    arguments.Append(AddParameterIfNotNull("publishbuild", BuildNumber.Get(context)));
                    arguments.Append(AddParameterIfNotNull("teamproject", TeamProjectName.Get(context)));
                    arguments.Append(AddParameterIfNotNull("platform", Platform.Get(context)));
                    arguments.Append(AddParameterIfNotNull("flavor", Flavor.Get(context)));
                }
 
                // where do the results go, tend to use one of these not both
                arguments.Append(AddParameterIfNotNull("resultsfile", ResultsFile.Get(context)));
                arguments.Append(AddParameterIfNotNull("resultsfileroot", ResultsFileRoot.Get(context)));
 
                LogMessage(context, string.Format("Call Mstest With Wrapper [{0}] and arguments [{1}]", testrunner, arguments.ToString()), BuildMessageImportance.Normal);
 
                using (System.Diagnostics.Process process = new System.Diagnostics.Process())
                {
                    process.StartInfo.FileName = testrunner;
                    process.StartInfo.WorkingDirectory = SearchPathRoot.Get(context);
                    process.StartInfo.WindowStyle = ProcessWindowStyle.Normal;
                    process.StartInfo.UseShellExecute = false;
                    process.StartInfo.ErrorDialog = false;
                    process.StartInfo.CreateNoWindow = true;
                    process.StartInfo.RedirectStandardOutput = true;
                    process.StartInfo.Arguments = arguments.ToString();
                    try
                    {
                        process.Start();
                        msTestOutput = process.StandardOutput.ReadToEnd();
                        process.WaitForExit();
                        // for TypemockRunner and MSTest this is alway seems to be 1 so does not help tell if test passed or not
                        //  In general you can detect test failures by simply checking whether mstest.exe returned 0 or not.  
                        // I say in general because there is a known bug where on certain OSes mstest.exe sometimes returns 128 whether 
                        // successful or not, so mstest.exe 10.0 added a new command-line option /usestderr which causes it to write 
                        // something to standard error on failure.
 
                        // If (error data received)
                        //    FAIL
                        // Else If (exit code != 0 AND exit code != 128)
                        //    FAIL
                        // Else If (exit code == 128)
                        //    Write Warning about weird error code, but SUCCEED
                        // Else
                        //   SUCCEED
 
                        ///int exitCode = process.ExitCode;
                        LogMessage(context, string.Format("Output of ExternalTestRunner: {0}", msTestOutput), BuildMessageImportance.High);
                    }
                    catch (InvalidOperationException ex)
                    {
                        LogError(context, "ExternalTestRunner InvalidOperationException :" + ex.Message);
                    }
 
                    exitMessage = ParseResultsForSummary(msTestOutput);
                }
            }
            LogMessage(context, string.Format("ExternaTestRunner exiting with message [{0}]", exitMessage), BuildMessageImportance.High);
            return exitMessage;
        }
 
        /// <summary>
        /// Adds a parameter to the MSTest line, it has been extracted to allow us to do a isEmpty chekc in one place
        /// </summary>
        /// <param name="parameterName">The name of the parameter</param>
        /// <param name="value">The string value</param>
        /// <returns>If the value is present a formated block is return</returns>
        private static string AddParameterIfNotNull(string parameterName, string value)
        {
            var returnValue = string.Empty;
            if (string.IsNullOrEmpty(value) == false)
            {
                returnValue = string.Format(" /{0}:\"{1}\"", parameterName, value);
            }
            return returnValue;
       }
 
        /// <summary>
        /// A handler to check the results for the success or failure message
        /// This is a rough way to do it, but is more reliable than the MSTest exit codes
        /// It returns a string as opposed to an  exit code so that it 
        /// Note this will not work of the /usestderr flag is used
        /// </summary>
        /// <param name="output">The output from the test run</param>
        /// <returns>A single line summary</returns>
        private static ExternalTestRunnerReturnCode ParseResultsForSummary(String output)
        {
            ExternalTestRunnerReturnCode exitMessage = ExternalTestRunnerReturnCode.NotRun;
            if (Regex.IsMatch(output, "Test Run Failed"))
            {
                exitMessage = ExternalTestRunnerReturnCode.Failed;
            }
            else if (Regex.IsMatch(output, "Test Run Completed"))
            {
                exitMessage = ExternalTestRunnerReturnCode.Passed;
            }
            else
            {
                exitMessage = ExternalTestRunnerReturnCode.Unknown;
            }
 
            return exitMessage;
        }
 
        /// <summary>
        /// Handles finding MSTest, checking both the 32 and 64 bit paths
        /// </summary>
        /// <returns></returns>
        private static string GetDefaultMsTestPath()
        {
            String mstest = @"C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\mstest.exe";
            if (CheckFileExists(mstest) == false)
            {
                mstest = @"C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe";
                if (CheckFileExists(mstest) == false)
                {
                    throw new System.IO.FileNotFoundException("MsTest file cannot be found");
                }
            }
            return mstest;
        }
 
        /// <summary>
        /// Helper method so we log in both the VS Build and Debugger modes
        /// </summary>
        /// <param name="context">The workflow context</param>
        /// <param name="message">Our message</param>
        /// <param name="logLevel">Team build importance level</param>
        private static void LogMessage(CodeActivityContext context, string message, BuildMessageImportance logLevel)
        {
            TrackingExtensions.TrackBuildMessage(context, message, logLevel);
            Debug.WriteLine(message);
        }
 
        /// <summary>
        /// Helper method so we log in both the VS Build and Debugger modes
        /// </summary>
        /// <param name="context">The workflow context</param>
        /// <param name="message">Our message</param>
        private static void LogError(CodeActivityContext context, string message)
        {
            TrackingExtensions.TrackBuildError(context, message);
            Debug.WriteLine(message);
        }
 
        /// <summary>
        /// Helper to check a file name to make sure it not null and that the file it name is present
        /// </summary>
        /// <param name="fileName"></param>
        /// <returns></returns>
        private static bool CheckFileExists(string fileName)
        {
            return !((string.IsNullOrEmpty(fileName) == true) || (File.Exists(fileName) == false));
        }
    }
}

 

This activity does need a good bit of configuring to use it in a real build. However, as said previously, the options it takes are basically those needed for the MSTest activity, so you just replace the existing calls to the MSTest activities as shown in the graph below should be enough.

image

Note: The version of the ExternalTestRunner activity in this post does not handle tests based on Metadata parameters (blue box above), but should be OK for all other usages (it is just that these parameters have not been wired through yet). The red box show the new activity in place (this is the path taken if the tests are controlled by a test setting file) and the green box contains an MSTest activity waiting to be swapped out (this is the path taken if no test setting or metadata files are provided).

The parameters on the activity in the red box are as follows, as said before they are basically the same as parameters for the standard MSTest activity.

image

The Result parameter (the Execute() method return value) does need to be associated with a variable declared in the workflow, in my case ExternalTestRunnerResult. This is defined at the sequence scope, the scope it is defined at must be such that it can be read by any other steps in the workflow that require the value. It is declared as being of the enum type ExternalTestRunnerReturnCode defined in the custom activity.

image

Further on in the workflow you need to edit the if statement that branches on whether the tests passed or not to use this ExtenalTestRunnerResult value

image

Once all this is done you should have all your MSTests running inside a Typemock’ed wrapper and all the results should be shown correctly in the build summary

image

And the log of the build should show you all the parameters that got passed through to the MSTest program.

image

Is there a better way to test a custom activity project?

Whilst sorting out the logic for the custom activity I did not want to have to go through the whole process of running the team build to test the activity, it just took too long. To speed this process I did the following

  1. In my solution I created a new Console Workflow project
  2. I referenced my custom activity project from this new workflow project
  3. I added my custom activity as the only item in my workflow
  4. For each parameter of the custom activity I created a matching argument for the workflow and wired the two together.

    image
  5. I then created a Test Project that referenced the workflow project and custom activity project.
  6. In this I could write unit tests (well more integration tests really) that exercise many of the options in the custom activity. To help in this process I created some simple Test Projects assemblies that contained just passing tests, just failing test and a mixture of both.
  7. A sample test is shown below
    [TestMethod]
    public void RunTestWithTwoNamedAssembly_OnePassingOneFailingTestsNoPublishNoMstestSpecified_FailMessage()
    {
     
        // make sure we have no results file, MSTest fails if the file is present
        File.Delete(Directory.GetCurrentDirectory() + @"\TestResult.trx");
     
        var wf = new Workflow1();
     
        Dictionary<string, object> wfParams = new Dictionary<string, object>
        {
            { "BuildNumber", string.Empty },
            { "Flavour", "Debug" },
            { "MsTestExecutable", string.Empty },
            { "Platform", "Any CPU" },
            { "ProjectCollection",string.Empty },
            { "TeamProjectName", string.Empty },
            { "TestAssemblyNames", new string[] { 
                Directory.GetCurrentDirectory() + @"\TestProjectWithPassingTest.dll",
                Directory.GetCurrentDirectory() + @"\TestProjectWithfailingTest.dll"
            }},
            { "TestRunnerExecutable", @"C:\Program Files (x86)\Typemock\Isolator\6.0\TMockRunner.exe" },
            { "ResultsFile", "TestResult.trx" }
        };
     
     
        var results = WorkflowInvoker.Invoke(wf, wfParams);
     
        Assert.AreEqual(TypemockBuildActivity.ExternalTestRunnerReturnCode.Failed, results["ResultSummary"]);
    }
  8. The only real limit here is that some of the options (the publishing ones) need a TFS server to be tested. You have to make a choice as to whether this type of publishing test is worth the effort of filling your local TFS server with test runs  from the test project or whether you want to test these features manually in a real build environment, especially give the issues I mention in my past post

 

 

 

 

 

Summary

So I have a working implementation of a custom activity that makes it easy to run Typemock based tests without losing any of the other features of a Team Build. Butt as I learnt getting around the deployment issues can be a real pain.

Comments (2) -

  • Richard

    4/16/2010 11:14:55 AM |

    Hi!

    great job! One question: does it work in Visual Studio 2010 B2??

    I have tried to add the activity to a workflow, but although I can see it in the toolbox It don't let me drag it to the workflow designer...

    Any idea? Thanks!

  • Richard

    4/16/2010 2:31:49 PM |

    I can't see a reason it should not work with B2. Have you tried adding it by editing the XML directly?

Pingbacks and trackbacks (14)+

Comments are closed