Exporting unit test results from a multi-stage docker build

2021, Aug 09    

Multi-staged Docker Builds

Multi-stage Docker builds allow you to write Docker files with multiple FROM statements. This means you can create images which derive from several bases, which can help cut the size of your final build.

Multi-stage Docker builds is a great way to encapsulate your entire build process in a single file which can be run on any machine. Dependencies such as Node.js or .NET can be installed as part of the process included from another public image.

Testing within a Multi-staged Docker Build

It’s not that common for people to run their test suites inside their docker build, however, it’s a perfect use-case as once you’ve got a docker file setup then it will run the same no matter of the build environment.

For .NET, I feel the reason for not running your test suites inside docker is because all the examples omit out the testing part of the script, for example if we take a look at the docker.com’s ASP.NET Core sample you’ll notice it only contains the restore and publish before creating the runtime image of the container that we’d run in production.

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build-env
WORKDIR /app

# Copy csproj and restore as distinct layers
COPY *.csproj ./
RUN dotnet restore

# Copy everything else and build
COPY ../engine/examples ./
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:3.1
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

We could easily add in a testing phase to this test run by adding in a dotnet build and dotnet test.

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build-env
WORKDIR /app

COPY *.csproj ./
RUN dotnet restore

COPY ../engine/examples ./
RUN dotnet build -c Release --no-restore
RUN dotnet test -c Release --no-build
RUN dotnet publish -c Release --no-build -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:5.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

Above you can see the two commands we’ve included, when this is built the docker build command will return a exit code not zero if the dotnet test does not run to competition.

Test Named Build Stage

Now we’ve seen that we can easily add our tests to a build stage it’s worth looking at how we can only run the tests. Ideally we’d want to be able to run the tests and then also run build for creating the runtime image.

Within a multi-staged docker build, we don’t need to run all stages top to bottom. What we can do is name the stages, we can also fork the layer to add more layers on top.

For example, ideally we’d end up with two builds

Testing:

  • Copy csproj
  • Restore
  • Copy rest of files
  • Build
  • Test

Runtime image:

  • Copy csproj
  • Restore
  • Copy rest of files
  • Build
  • Publish
  • Copy publish artifact in to runtime image

To achieve the above we can use the FROM directive:

FROM prevStage AS newStage

Where the prevStage is the stage we’re building on top of, and the newStage is the name of the new stage.

If we swap around our docker file to be build up on multiple stages we’ll now have the following Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS restore
WORKDIR /app

COPY *.csproj ./
RUN dotnet restore

FROM restore AS build
COPY ../engine/examples ./
RUN dotnet build -c Release --no-restore

FROM build AS test
RUN dotnet test -c Release --no-build

FROM build AS publish
RUN dotnet publish -c Release --no-build -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:5.0
WORKDIR /app
COPY --from=publish /app/out .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

Now if we run our normal docker build process it won’t run our dotnet test command as that’s not a required stage to create the runtime image. However, now we can tell docker to build up to a give target stage with the --target option. So within our build script we can call docker build twice.

docker build --target test .
docker build .

Here you might be thinking this going to waste a lot of time re-building each stage twice, however, docker will cache previously built layers meaning that it will only build our restore and build stages once.

Outputting our test files

Many CI/CD solutions come with built-in aggregation dashboards for test runs, the two examples below are AppVeyor and Azure DevOps, however, there’s many other products on the market that do similar.

AppVeyor Test Results Dashboard: AppVeyor Test Results Dashboard

Azure DevOps Test Results Dashboard: Azure DevOps Test Results Dashboard

These dashboards make it much easier to see which tests have passed or failed at a glance, they sometimes even have built-in trends for tests to tell you which tests are flaky.

dotnet test has a built in logger for Visual Studio Test results files (.trx), to enable this we need to pass in an extra argument of --logger trx in to our dotnet test command. This will then create a bunch of .trx files inside a TestResults folder relative to each test project.

Also within the docker file we need to create another layer that only has these .trx files copied in, this is very similar to our stage where we build our runtime, however this time we won’t need the .NET runtime so we can use the special scratch image.

FROM mcr.microsoft.com/dotnet/sdk:5.0 AS restore
WORKDIR /app

COPY *.csproj ./
RUN dotnet restore

FROM restore AS build
COPY ../engine/examples ./
RUN dotnet build -c Release --no-restore

FROM build AS test
RUN dotnet test -c Release --no-build --logger trx

FROM scratch as export-test-results
COPY --from=test /app/TestResults/*.rtx .

FROM build AS publish
RUN dotnet publish -c Release --no-build -o out

FROM mcr.microsoft.com/dotnet/aspnet:5.0
WORKDIR /app
COPY --from=publish /app/out .
ENTRYPOINT ["dotnet", "aspnetapp.dll"]

Now we’ve got our export-test-results stage to contain our tests results in a scratch image, we now need to tell docker to export these to the current file system once that stage is built. This can be achieved with the --output option. The custom build output can output the files directly as is, or they can be rolled up in a .tar archive.

For our example we’ll just output them directly to the local file system in an out folder.

docker build --target test --output type=local,dest=out .

Once we’ve got our files on our filesystem these can be pushed up to your CI/CD solution.