CI with Jenkins, MSBuild, NuGet, and Git: Part 3
Marco Franssen

Loading...
Marco Franssen

In the previous parts (part 1, part 2), I showed you how to clean the output folders, download NuGet packages, and build your projects with MSBuild. In this part, we'll add MSBuild targets for MSpec tests and code coverage.
MSpec is a testing framework that lets you write living specifications. Its console runner can generate an HTML report, which we can later publish with the Jenkins report plugin. This gives us documentation of the software's specifications, backed by executable tests. That's why I call it living documentation :D.
For code coverage, we'll use OpenCover and ReportGenerator to produce reports from our test runs. We'll publish these reports with the Jenkins report plugin too.
I installed all three packages in my solution using NuGet, so the paths in my build script point to my source/packages folders.
First, we'll add some properties to our build script to make the tools easier to reference. In the MSpec target, we scan for assemblies with 'Specs' in their names. My specification projects are named something like this:
One more thing to note about the MSpec target: we need to encode quotation marks as " in MSBuild. You can see these encoded quotes in the command definition.
<PropertyGroup>
<!-- MSpec -->
<MSpecPath>$(Packages)\Machine.Specifications.0.5.12\tools</MSpecPath>
<MSpecExe>mspec-clr4.exe</MSpecExe>
<MSpecXmlOutputFile>$(ReportsPath)\mspec-output.xml</MSpecXmlOutputFile>
<MSpecHtmlOutputPath>$(ReportsPath)\mspec</MSpecHtmlOutputPath>
<MSpecSettings></MSpecSettings>
</PropertyGroup>
<Target Name="Specs" DependsOnTargets="Clean;LoadNuGetPackages;Compile">
<CreateItem Include="**\bin\$(Configuration)\*Specs*.dll" Exclude="**\bin\$(Configuration)\*Specs*.mm.dll">
<Output TaskParameter="Include" ItemName="SpecsAssemblies" />
</CreateItem>
<PropertyGroup>
<MSpecCommand>"$(MSpecPath)\$(MSpecExe)" $(MSpecSettings) --xml "$(MSpecXmlOutputFile)" --html "$(MSpecHtmlOutputPath)" -t "@(SpecsAssemblies, '" "')"</MSpecCommand>
</PropertyGroup>
<Message Importance="high" Text="Running Specs with this command: $(MSpecCommand)"/>
<Exec Command="$(MSpecCommand)" />
</Target>Oh, the exclusion for Specs*.mm.dll is there because I use ContinuousTests as a Visual Studio plugin. It runs my specs when I save my files. Since I also run MSBuild locally, I want to exclude these generated assemblies from the build task.
For code coverage, we'll add some more properties to our MSBuild script. Again, I scan for the spec assemblies to run. OpenCover runs the specifications through MSpec, and we exclude the spec assemblies themselves from the coverage report using $(OpenCoverFilter). ReportGenerator then uses OpenCover's XML output to generate an HTML report and an XML summary of our solution's coverage.
<PropertyGroup>
<!-- OpenCover -->
<!-- The tools path for OpenCover -->
<OpenCoverPath>$(Packages)\OpenCover.4.5.1403</OpenCoverPath>
<OpenCoverExe>OpenCover.Console.exe</OpenCoverExe>
<OpenCoverFilter>-[*Specs*]* +[*]*</OpenCoverFilter>
<ReportGeneratorPath>$(Packages)\ReportGenerator.1.8.1.0</ReportGeneratorPath>
<ReportGeneratorExe>ReportGenerator.exe</ReportGeneratorExe>
<OpenCoverOutputFile>$(ReportsPath)\coverage-output.xml</OpenCoverOutputFile>
<CoverageReport>$(ReportsPath)\coverage</CoverageReport>
<ReportGeneratorSummary>$(ReportsPath)\coverage-summary.xml</ReportGeneratorSummary>
</PropertyGroup>
<Target Name="CodeCoverage" DependsOnTargets="Clean;LoadNuGetPackages;Compile">
<CreateItem Include="**\Bin\Debug\*Specs*.dll" Exclude="**\Bin\$(Configuration)\*Specs*.mm.dll">
<Output TaskParameter="Include" ItemName="SpecsAssemblies" />
</CreateItem>
<PropertyGroup>
<OpenCoverCommand>"$(OpenCoverPath)\$(OpenCoverExe)" -register:user "-target:"$(MSpecPath)\$(MSpecExe)"" "-targetargs:"@(SpecsAssemblies, '" "')"" "-filter:$(OpenCoverFilter)" "-output:$(OpenCoverOutputFile)"</OpenCoverCommand>
<ReportGeneratorCommand>"$(ReportGeneratorPath)\$(ReportGeneratorExe)" "-reports:$(OpenCoverOutputFile)" "-targetdir:$(CoverageReport)" "-reporttypes:html;xml"</ReportGeneratorCommand>
</PropertyGroup>
<Message Importance="high" Text="Running code coverage with this command: $(OpenCoverCommand)"/>
<Exec Command="$(OpenCoverCommand)" />
<Message Importance="high" Text="Generate report with this command: $(ReportGeneratorCommand)"/>
<Exec Command="$(ReportGeneratorCommand)" />
<!-- Report Generator has no way to name the output file so rename it by copying and deleting the original file -->
<Copy SourceFiles="$(CoverageReport)\Summary.xml" DestinationFiles="$(ReportGeneratorSummary)"></Copy>
<Delete Files="$(CoverageReport)\Summary.xml"></Delete>
</Target>We could put the assembly scan outside the targets so both targets share the results. I chose to keep a separate scan in each target because I want them to be independent. That way, changing the assemblies included in my coverage report doesn't affect the other target. You're free to share the scan between both targets if that suits your project.
Here's our complete MSBuild script. To run the targets, we can use the /t:Specs or /t:Coverage command-line parameter, or /t:Specs;Coverage to run both. You could add some conditional statements to the batch file from part 1 to let you choose a target.
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
DefaultTargets="Compile">
<PropertyGroup>
<Configuration>Debug</Configuration>
<Platform>AnyCPU</Platform>
<DefineSolutionProperties>false</DefineSolutionProperties>
<!-- General Paths -->
<RootPath>$(MSBuildProjectDirectory)</RootPath>
<SrcPath>$(RootPath)\src</SrcPath>
<ReportsPath>$(RootPath)\reports</ReportsPath>
<ToolsPath>$(RootPath)\tools</ToolsPath>
<Packages>$(SrcPath)\packages</Packages>
<!-- MSpec -->
<MSpecPath>$(Packages)\Machine.Specifications.0.5.12\tools</MSpecPath>
<MSpecExe>mspec-clr4.exe</MSpecExe>
<MSpecXmlOutputFile>$(ReportsPath)\mspec-output.xml</MSpecXmlOutputFile>
<MSpecHtmlOutputPath>$(ReportsPath)\mspec</MSpecHtmlOutputPath>
<MSpecSettings></MSpecSettings>
<!-- OpenCover -->
<!-- The tools path for OpenCover -->
<OpenCoverPath>$(Packages)\OpenCover.4.5.1403</OpenCoverPath>
<OpenCoverExe>OpenCover.Console.exe</OpenCoverExe>
<OpenCoverFilter>-[*Specs*]* +[*]*</OpenCoverFilter>
<ReportGeneratorPath>$(Packages)\ReportGenerator.1.8.1.0</ReportGeneratorPath>
<ReportGeneratorExe>ReportGenerator.exe</ReportGeneratorExe>
<OpenCoverOutputFile>$(ReportsPath)\coverage-output.xml</OpenCoverOutputFile>
<CoverageReport>$(ReportsPath)\coverage</CoverageReport>
<ReportGeneratorSummary>$(ReportsPath)\coverage-summary.xml</ReportGeneratorSummary>
</PropertyGroup>
<!-- The Clean Target -->
<ItemGroup>
<ProjectFiles Include="**\*.csproj" />
</ItemGroup>
<Target Name="Clean">
<Message Importance="high" Text="Cleaning folders"/>
<RemoveDir Directories="$(ReportsPath)" Condition="Exists('$(ReportsPath)')" />
<MakeDir Directories = "$(ReportsPath);$(ReportsPath)\MSpec;$(ReportsPath)\Coverage" />
<!-- Clean the source code projects -->
<MSBuild Projects="@(ProjectFiles)"
ContinueOnError="false"
Targets="Clean"
Properties="Configuration=$(Configuration)" />
</Target>
<!-- The LoadNuGetPackages Target -->
<ItemGroup>
<NuGetPackageConfigs Include="$(MSBuildStartupDirectory)\**\packages.config" />
</ItemGroup>
<Target Name="LoadNuGetPackages">
<Message Importance="high" Text="Retrieving packages for %(NuGetPackageConfigs.Identity)" />
<Exec Command=""$(SrcPath)\.nuget\nuget" install "%(NuGetPackageConfigs.Identity)" -o "$(SrcPath)\packages"" />
</Target>
<!-- The Compile Target -->
<Target Name="Compile" DependsOnTargets="Clean;LoadNuGetPackages">
<Message Importance="high" Text="Compiling core projects"/>
<MSBuild Projects="$(SrcPath)\MyProject.Core\MyProject.Core.csproj"
Properties="Configuration=$(Configuration);Platform=$(Platform)" />
<MSBuild Projects="$(SrcPath)\MyProject.Web\MyProject.Web.csproj;$(SrcPath)\MyProject.Win\MyProject.Win.csproj"
Properties="Configuration=$(Configuration);Platform=$(Platform)"
BuildInParallel="true" />
</Target>
<Target Name="Specs" DependsOnTargets="Clean;LoadNuGetPackages;Compile">
<CreateItem Include="**\bin\$(Configuration)\*Specs*.dll" Exclude="**\bin\$(Configuration)\*Specs*.mm.dll">
<Output TaskParameter="Include" ItemName="SpecsAssemblies" />
</CreateItem>
<PropertyGroup>
<MSpecCommand>"$(MSpecPath)\$(MSpecExe)" $(MSpecSettings) --xml "$(MSpecXmlOutputFile)" --html "$(MSpecHtmlOutputPath)" -t "@(SpecsAssemblies, '" "')"</MSpecCommand>
</PropertyGroup>
<Message Importance="high" Text="Running Specs with this command: $(MSpecCommand)"/>
<Exec Command="$(MSpecCommand)" />
</Target>
<Target Name="CodeCoverage" DependsOnTargets="Clean;LoadNuGetPackages;Compile">
<CreateItem Include="**\Bin\Debug\*Specs*.dll" Exclude="**\Bin\$(Configuration)\*Specs*.mm.dll">
<Output TaskParameter="Include" ItemName="SpecsAssemblies" />
</CreateItem>
<PropertyGroup>
<OpenCoverCommand>"$(OpenCoverPath)\$(OpenCoverExe)" -register:user "-target:"$(MSpecPath)\$(MSpecExe)"" "-targetargs:"@(SpecsAssemblies, '" "')"" "-filter:$(OpenCoverFilter)" "-output:$(OpenCoverOutputFile)"</OpenCoverCommand>
<ReportGeneratorCommand>"$(ReportGeneratorPath)\$(ReportGeneratorExe)" "-reports:$(OpenCoverOutputFile)" "-targetdir:$(CoverageReport)" "-reporttypes:html;xml"</ReportGeneratorCommand>
</PropertyGroup>
<Message Importance="high" Text="Running code coverage with this command: $(OpenCoverCommand)"/>
<Exec Command="$(OpenCoverCommand)" />
<Message Importance="high" Text="Generate report with this command: $(ReportGeneratorCommand)"/>
<Exec Command="$(ReportGeneratorCommand)" />
<!-- Report Generator has no way to name the output file so rename it by copying and deleting the original file -->
<Copy SourceFiles="$(CoverageReport)\Summary.xml" DestinationFiles="$(ReportGeneratorSummary)"></Copy>
<Delete Files="$(CoverageReport)\Summary.xml"></Delete>
</Target>
</Project>In the next part, we'll let Jenkins do all this cool stuff for us. Stay tuned, and in the meantime, share the first three parts with your friends.
Marco Franssen
Build a simple Node.js web server with Express, use npm packages and nodemon during development, and serve an HTML page from a static public folder.
Marco Franssen
Connect a .NET MSBuild CI script to Jenkins, trigger builds from GitHub, configure repository access, and publish MSpec and code coverage reports.
Marco Franssen
Add Clean and LoadNuGetPackages targets to a .NET MSBuild script so your CI build clears previous output and restores missing NuGet packages before compiling.
Marco Franssen
Start a .NET continuous integration build with an MSBuild script and a Windows batch file, then choose Debug or Release builds from the command line.