Sanely Testing MSBuild SDKs

Without remote NuGet feeds or hardcoded version values.

Update: The template repository presented here has changed a bit since I wrote the post. Among other small things, the nuget.config file is now generated just like global.json, and the build system only deletes the cached SDK package, rather than the entire cache directory.


MSBuild SDKs are very convenient when you want to extend the .NET build ecosystem beyond the handful of officially-supported languages, or when you want to augment the build to a degree visible enough that having the logic hidden away in an innocent-looking PackageReference isn't appropriate.

Unfortunately, documentation on authoring custom SDKs is basically not a thing, so even figuring out how to put one together and package it correctly requires combing through existing custom SDK projects on GitHub and experimenting until you find the right combination of incantations. But what's much worse in my view is that if you look at all of these existing projects, you would come away with the impression that there isn't actually a good way to test an SDK in a way that's representative of how a user would use it while keeping the process sane for developers of the SDK. But don't worry - there is a way.


Let's first set up some requirements for testing:

  • The final NuGet package containing the SDK should be tested.
  • The package version being tested should be the one that was just built with dotnet build.
  • The package should be tested locally without having to push it to a remote NuGet feed.
  • Developers should be able to easily iterate on SDK changes and test them locally.

It's unusual to test an entire NuGet package end-to-end rather than just running unit tests, but the reason for this is that packaging logic for an MSBuild SDK is non-trivial and unconventional, so errors can sneak in which would make the package unusable but wouldn't show up in unit tests.

If you've done some experimentation with MSBuild SDKs, you'll know that to resolve SDKs from NuGet, MSBuild requires a full version (i.e. no wildcards) to be specified either in the project file or in global.json. This is the first issue. Having to constantly update the version value in the test project is clearly not a reasonable or scalable solution. We need a way to make the test project use the version that was just built with dotnet build.

It turns out that we can solve this problem when building the SDK package; after all, at this point, the build system knows the exact version of the package. We'll generate a global.json containing the correct version as part of the build and write it to the test project directory:

<Target Name="_WriteGlobalJson"
        DependsOnTargets="GetBuildVersion"
        AfterTargets="Build">
    <PropertyGroup>
        <_GlobalJson>
{
  "$schema": "https://json.schemastore.org/global.json",
  "msbuild-sdks": {
    "Foo.Sdk": "$(Version)"
  }
}
        </_GlobalJson>
    </PropertyGroup>

    <WriteLinesToFile File="../samples/global.json"
                        Lines="$(_GlobalJson)"
                        Overwrite="true"
                        WriteOnlyWhenDifferent="true" />
</Target>

This is a bit crude, but it does the job well enough.

Next, we need a local NuGet feed to push the SDK package to, which will then be used by the test project. Another issue here is that we don't want NuGet to reuse a previously-restored SDK package because the version number didn't change; rather, whenever we do a fresh build of the SDK package, we want a subsequent build of the test project to do a fresh restore.

We start by setting PackageOutputPath to a well-known directory in the repository:

<PropertyGroup>
    <PackageOutputPath>$(MSBuildThisFileDirectory)pkg/</PackageOutputPath>
</PropertyGroup>

We then add a nuget.config in the test project:

<configuration>
    <config>
        <add key="globalPackagesFolder" value="../../pkg/cache" />
    </config>
    <packageSources>
        <add key="foo-sdk" value="../../pkg" />
    </packageSources>
</configuration>

We still need to solve the issue of NuGet using a stale package. That's why we set globalPackagesFolder above rather than letting NuGet use the system-wide package cache.

We'll add another target to the SDK project:

<Target Name="_ClearPackageCache"
        AfterTargets="Build">
    <RemoveDir Directories="$(PackageOutputPath)cache" />
</Target>

That's it. You can now type dotnet build in the SDK project, followed by dotnet build, dotnet run, etc in the test project and it'll Just Work.


Of course, the snippets above omit a lot of context in the interest of brevity. The good news is that I've set up a GitHub template repository that contains a functional example of building and testing a custom MSBuild SDK with continuous integration and everything:

alexrp/msbuild-sdk-template
A template repository for sanely building a custom MSBuild SDK in .NET 6+. - alexrp/msbuild-sdk-template

Feel free to grab anything you need out of that repository.