TL;DR: You can minimize the effort of creating new test projects according to your project’s conventions by defining some global default MSBuild properties. A new test project csproj will then be a five-liner consisting only of the SDK and TFM.
The entire solution is available on my github.
While maintaining and refactoring a mid-sized mono-repo (~60 projects total), I found myself creating new projects and accompanying test projects frequently.
It was mildly annoying to do the same clicks and csproj edits over and over to setup a test project:
- use a consistent version of the test SDK and the test adapter (in my case NUnit)
- use consistent versions of assertion library and mocking library
- add default usings for test framework, assertions library and mocking library
- reference the implementation project from the test project
- reference a TestUtil project from the test project
- make the implementation project’s internals visible to the test project
- make the implementation project’s internals mockable
So, let’s try to establish some defaults that do everything on this list for us.
We are going to assume the following project and file system structure:
<repo_root> |-- src |-- Project1 - Project1.csproj - Directory.Build.props |-- test |-- Project1.Tests - Project1.Tests.csproj |-- TestUtil - TestUtil.csproj - Directory.Packages.props - Directory.Build.props
Using consistent versions of test packages
<!-- test/Directory.Packages.props --> <Project> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.3.1" /> <PackageReference Include="FluentAssertions" Version = "6.8.0" /> <!-- and so on --> </ItemGroup> </Project>
Nowadays this is easy to achieve when you follow the established practice of separating implementation and test projects on the file system into a src
folder and a test
folder. Just add a Directory.Packages.props
file in your test
folder with the package references.
If you are using central package management, this can easily be adapted as well.
If you have a hierarchy of Directory.Packages.props
files, make sure to import the one above according to rule 3 of this other post.
Add default usings for test framework, assertions and mocking
<!-- in the test/Directory.Build.props --> <ItemGroup> <Using Include="NUnit.Framework" /> <!-- add any other namespace you use in every test class --> </ItemGroup>
Since C# 10 global usings can save us from the noise of declaring the same usings in every test file.
The current VS template will add a Usings.cs
file with global usings to a newly created test project.
But we can do even better.
Global usings can also be declared in the project file via the Using
item.
This means they can be imported into all test project files from a single location like the above snippet.
Reference the corresponding implementation project
<!-- test/Directory.Build.props --> <PropertyGroup> <!-- The implementation project name is assumed to be the test project name without the last '.' and everything after that --> <__LastIndexOfPeriod>$([System.String]::Copy($(MSBuildProjectName)).LastIndexOf('.'))</__LastIndexOfPeriod> <__HeuristicImplementationProjectName Condition="'$(__LastIndexOfPeriod)' != '-1'">$([System.String]::Copy($(MSBuildProjectName)).Substring(0, $([System.String]::Copy($(MSBuildProjectName)).LastIndexOf('.'))))</__HeuristicImplementationProjectName> <__HeuristicImplementationProjectPath Condition="'$(__HeuristicImplementationProjectName)' != ''">$(MSbuildThisFileDirectory)../src/$(__HeuristicImplementationProjectName)/$(__HeuristicImplementationProjectName).csproj</__HeuristicImplementationProjectPath> </PropertyGroup> <ItemGroup> <ProjectReference Include="$(__HeuristicImplementationProjectPath)" Condition="__HeuristicImplementationProjectPath != '' AND Exists($(__HeuristicImplementationProjectPath))" /> </ItemGroup>
If you follow a naming convention like naming test projects after the implementation projects suffixed with Tests
, you can easily add a project reference for the implementation project to every test project by default.
It may look complex, but it is quite simple except for the awkward MSBuild syntax.
Note that this follows rule 2 for props files.
It also includes a check on whether the predicted implementation project exists, so that the generic inclusion does not break projects in the test folder that do not adhere to the convention.
Reference a TestUtil project
<!-- test/Directory.Build.props --> <ItemGroup> <ProjectReference Include="$(MSBuildThisFileDirectory)TestUtil/TestUtil.csproj" Condition="'$(MSBuildProjectName)' != 'TestUtil'" /> </ItemGroup>
This is pretty straightforward.
Follow rule 2.
Exclude the TestUtil project itself with a Condition
.
Set InternalsVisibleTo
<!-- src/Directory.Build.props --> <ItemGroup> <InternalsVisibleTo Include="DynamicProxyGenAssembly2" /> <InternalsVisibleTo Include="$(MSBuildProjectName).Tests" /> /<ItemGroup>
The first InternalsVisibleTo
trivially enables mocking of internal types with frameworks like Moq
or NSubstitute
.
The second InternalsVisibleTo
makes internal types visible to a predicted test assembly.
Note that despite the fact that we need an assembly name here,
this does not use $(AssemblyName)
.
The reason is that the assembly name of the implementation project will often be different from its project name.
The test assembly however will usually have its default name, i. e. the same name as the test project.
Since the test project is named like the implementation project with .Tests suffix, we can predict the test assembly name from the implementation project name.
Enjoy
Any new test project can now look like this and be immediately ready, thanks to our defaults.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net7.0</TargetFramework> </PropertyGroup> </Project>
The entire solution is available on my github.