We all know that unit tests are a good thing. One benefit of unit tests is that they allow you to perform code coverage. This works because as each test runs the code, (using VS features) it can keep track of which lines got run. The difficulty with code coverage is that you need to instrument your code such that you can track which lines are run. This is non-trivial. There have been open-source tools in the past to do this (like NCover). Then, starting with VS2005, Microsoft incorporated code coverage directly into Visual Studio.
VS's code coverage looks great for a marketing demo. But, the big problem (to my knowledge) is that there's no easy way to run it from the command line. Obviously you want to incorporate coverage into your continuous build - perhaps even add a policy that requires at least x% coverage in order for the build to pass. This is a way for automated governance - i.e. how do you "encourage" developers to actually write unit tests - one way is to not even allow the build to accept code unless it has sufficient coverage. So the build fails if a unit test fails, and it also fails if the code has insufficient coverage.
So, how to run Code Coverage from the command line? This article by joc helped a lot. Assumeing that you're already familiar with MSTest and CodeCoverage from the VS gui, the gist is to:
In your VS solution, create a "*.testrunconfig" file, and specify which assemblies you want to instrument for code coverage.
Run MSTest from the command line. This will create a "data.coverage" file in something like: TestResults\Me_ME2 2008-09-17 08_03_04\In\Me2\data.coverage
This data.coverage file is in a binary format. So, create a console app that will take this file, and automatically export it to a readable format.
Reference "Microsoft.VisualStudio.Coverage.Analysis.dll" from someplace like "C:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\PrivateAssemblies" (this may only be available for certain flavors of VS)
Use the "CoverageInfo " and "CoverageInfoManager" classes to automatically export the results of a "*.trx" file and "data.coverage"
Now, within that console app, you can do something like so:
//using Microsoft.VisualStudio.CodeCoverage;
CoverageInfoManager.ExePath = strDataCoverageFile;
CoverageInfoManager.SymPath = strDataCoverageFile;
CoverageInfo cInfo = CoverageInfoManager.CreateInfoFromFile(strDataCoverageFile);
CoverageDS ds = cInfo .BuildDataSet(null);
This gives you a strongly-typed dataset, which you can then query for results, checking them against your policy. To fully see what this dataset looks like, you can also export it to xml. You can step through the namespace, class, and method data like so:
//NAMESPACE
foreach (CoverageDSPriv.NamespaceTableRow n in ds.NamespaceTable)
{
//CLASS
foreach (CoverageDSPriv.ClassRow c in n.GetClassRows())
{
//METHOD
foreach (CoverageDSPriv.MethodRow m in c.GetMethodRows())
{
}
}
}
You can then have your console app check for policy at each step of the way (classes need x% coverage, methods need y% coverage, etc...). Finally, you can have the MSBuild script that calls MSTest also call this coverage console app. That allows you to add code coverage to your automated builds.
[UPDATE 11/5/2008]
By popular request, here is the source code for the console app:
[UPDATE 12/19/2008] - I modified the code to handle the error: "Error when querying coverage data: Invalid symbol data for file {0}"
Basically, we needed to put the data.coverage and the dll/pdb in the same directory.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.CodeCoverage;
using System.IO;
using System.Xml;
//Helpful article: http://blogs.msdn.com/ms_joc/articles/495996.aspx
//Need to reference "Microsoft.VisualStudio.Coverage.Analysis.dll" from "C:\Program Files\Microsoft Visual Studio 9.0\Common7\IDE\PrivateAssemblies"
namespace CodeCoverageHelper
{
class Program
{
static int Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("ERROR: Need two parameters:");
Console.WriteLine(" 'data.coverage' file path, or root folder (does recursive search for *.coverage)");
Console.WriteLine(" Policy xml file path");
Console.WriteLine(" optional: display only errors ('0' [default] or '1')");
Console.WriteLine("Examples:");
Console.WriteLine(@" CodeCoverageHelper.exe C:\data.coverage C:\Policy.xml");
Console.WriteLine(@" CodeCoverageHelper.exe C:\data.coverage C:\Policy.xml 1");
return -1;
}
//string CoveragePath = @"C:\Tools\CodeCoverageHelper\Temp\data.coverage";
//If CoveragePath is a file, then directly use that, else assume it's a folder and search the subdirectories
string strDataCoverageFile = args[0];
//string CoveragePath = args[0];
if (!File.Exists(strDataCoverageFile))
{
//Need to march down to something like:
// TestResults\TimS_TIMSTALL2 2008-09-15 13_52_28\In\TIMSTALL2\data.coverage
Console.WriteLine("Passed in folder reference, searching for '*.coverage'");
string[] astrFiles = Directory.GetFiles(strDataCoverageFile, "*.coverage", SearchOption.AllDirectories);
if (astrFiles.Length == 0)
{
Console.WriteLine("ERROR: Could not find *.coverage file");
return -1;
}
strDataCoverageFile = astrFiles[0];
}
string strXmlPath = args[1];
Console.WriteLine("CoverageFile=" + strDataCoverageFile);
Console.WriteLine("Policy Xml=" + strXmlPath);
bool blnDisplayOnlyErrors = false;
if (args.Length > 2)
{
blnDisplayOnlyErrors = (args[2] == "1");
}
int intReturnCode = 0;
try
{
//Ensure that data.coverage and dll/pdb are in the same directory
//Assume data.coverage in a folder like so:
// C:\Temp\ApplicationBlocks10\TestResults\TimS_TIMSTALL2 2008-12-19 14_57_01\In\TIMSTALL2
//Assume dll/pdb in a folder like so:
// C:\Temp\ApplicationBlocks10\TestResults\TimS_TIMSTALL2 2008-12-19 14_57_01\Out
string strBinFolder = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(strDataCoverageFile), @"..\..\Out"));
if (!Directory.Exists(strBinFolder))
throw new ApplicationException( string.Format("Could not find the bin output folder at '{0}'", strBinFolder));
//Now copy data coverage to ensure it exists in output folder.
string strDataCoverageFile2 = Path.Combine(strBinFolder, Path.GetFileName(strDataCoverageFile));
File.Copy(strDataCoverageFile, strDataCoverageFile2);
Console.WriteLine("Bin path=" + strBinFolder);
intReturnCode = Run(strDataCoverageFile2, strXmlPath, blnDisplayOnlyErrors);
}
catch (Exception ex)
{
Console.WriteLine("ERROR: " + ex.ToString());
intReturnCode = -2;
}
Console.WriteLine("Done");
Console.WriteLine(string.Format("ReturnCode: {0}", intReturnCode));
return intReturnCode;
}
private static int Run(string strDataCoverageFile, string strXmlPath, bool blnDisplayOnlyErrors)
{
//Assume that datacoverage file and dlls/pdb are all in the same directory
string strBinFolder = System.IO.Path.GetDirectoryName(strDataCoverageFile);
CoverageInfoManager.ExePath = strBinFolder;
CoverageInfoManager.SymPath = strBinFolder;
CoverageInfo myCovInfo = CoverageInfoManager.CreateInfoFromFile(strDataCoverageFile);
CoverageDS myCovDS = myCovInfo.BuildDataSet(null);
//Clean up the file we copied.
File.Delete(strDataCoverageFile);
CoveragePolicy cPolicy = CoveragePolicy.CreateFromXmlFile(strXmlPath);
//loop through and display results
Console.WriteLine("Code coverage results. All measurements in Blocks, not LinesOfCode.");
int TotalClassCount = myCovDS.Class.Count;
int TotalMethodCount = myCovDS.Method.Count;
Console.WriteLine();
Console.WriteLine("Coverage Policy:");
Console.WriteLine(string.Format(" Class min required percent: {0}%", cPolicy.MinRequiredClassCoveragePercent));
Console.WriteLine(string.Format(" Method min required percent: {0}%", cPolicy.MinRequiredMethodCoveragePercent));
Console.WriteLine("Covered / Not Covered / Percent Coverage");
Console.WriteLine();
string strTab1 = new string(' ', 2);
string strTab2 = strTab1 + strTab1;
int intClassFailureCount = 0;
int intMethodFailureCount = 0;
int Percent = 0;
bool isValid = true;
string strError = null;
const string cErrorMsg = "[FAILED: TOO LOW] ";
//NAMESPACE
foreach (CoverageDSPriv.NamespaceTableRow n in myCovDS.NamespaceTable)
{
Console.WriteLine(string.Format("Namespace: {0}: {1} / {2} / {3}%",
n.NamespaceName, n.BlocksCovered, n.BlocksNotCovered, GetPercentCoverage(n.BlocksCovered, n.BlocksNotCovered)));
//CLASS
foreach (CoverageDSPriv.ClassRow c in n.GetClassRows())
{
Percent = GetPercentCoverage(c.BlocksCovered, c.BlocksNotCovered);
isValid = IsValidPolicy(Percent, cPolicy.MinRequiredClassCoveragePercent);
strError = null;
if (!isValid)
{
strError = cErrorMsg;
intClassFailureCount++;
}
if (ShouldDisplay(blnDisplayOnlyErrors, isValid))
{
Console.WriteLine(string.Format(strTab1 + "{4}Class: {0}: {1} / {2} / {3}%",
c.ClassName, c.BlocksCovered, c.BlocksNotCovered, Percent, strError));
}
//METHOD
foreach (CoverageDSPriv.MethodRow m in c.GetMethodRows())
{
Percent = GetPercentCoverage(m.BlocksCovered, m.BlocksNotCovered);
isValid = IsValidPolicy(Percent, cPolicy.MinRequiredMethodCoveragePercent);
strError = null;
if (!isValid)
{
strError = cErrorMsg;
intMethodFailureCount++;
}
string strMethodName = m.MethodFullName;
if (blnDisplayOnlyErrors)
{
//Need to print the full method name so we have full context
strMethodName = c.ClassName + "." + strMethodName;
}
if (ShouldDisplay(blnDisplayOnlyErrors, isValid))
{
Console.WriteLine(string.Format(strTab2 + "{4}Method: {0}: {1} / {2} / {3}%",
strMethodName, m.BlocksCovered, m.BlocksNotCovered, Percent, strError));
}
}
}
}
Console.WriteLine();
//Summary results
Console.WriteLine(string.Format("Total Namespaces: {0}", myCovDS.NamespaceTable.Count));
Console.WriteLine(string.Format("Total Classes: {0}", TotalClassCount));
Console.WriteLine(string.Format("Total Methods: {0}", TotalMethodCount));
Console.WriteLine();
int intReturnCode = 0;
if (intClassFailureCount > 0)
{
Console.WriteLine(string.Format("Failed classes: {0} / {1}", intClassFailureCount, TotalClassCount));
intReturnCode = 1;
}
if (intMethodFailureCount > 0)
{
Console.WriteLine(string.Format("Failed methods: {0} / {1}", intMethodFailureCount, TotalMethodCount));
intReturnCode = 1;
}
return intReturnCode;
}
private static bool ShouldDisplay(bool blnDisplayOnlyErrors, bool isValid)
{
if (isValid)
{
//Is valid --> need to decide
if (blnDisplayOnlyErrors)
return false;
else
return true;
}
else
{
//Not valid --> always display
return true;
}
}
private static bool IsValidPolicy(int ActualPercent, int ExpectedPercent)
{
return (ActualPercent >= ExpectedPercent);
}
private static int GetPercentCoverage(uint dblCovered, uint dblNot)
{
uint dblTotal = dblCovered + dblNot;
return Convert.ToInt32( 100.0 * (double)dblCovered / (double)dblTotal);
}
}
}