Build and Test in CI

Set up a GitHub Actions job that restores from a cache, builds Release with warnings as errors, runs every test once and keeps the results.

Syntaxdotnet restore --locked-mode dotnet build -c Release --no-restore dotnet test --solution Orders.slnx -c Release --no-build --report-xunit-trx --results-directory TestResults

A CI job answers one question for every push and pull request: does this commit build and pass its tests on a clean machine? For a .NET API that is five steps: check out, install the SDK the repository pins, restore packages, build once in Release, run the tests against that build. The rest is making them reproducible, fast and honest.

The SDK version

GitHub's ubuntu-24.04 image ships several .NET SDKs and the set changes when the image is updated. Using whatever is on the path means a runner image update can bring a newer compiler with new warnings. actions/setup-dotnet with global-json-file: global.json installs exactly the SDK the repository pins, so the developer laptop, the CI job and the Docker build use the same compiler and analyzers.

Restore and the cache

Restore downloads packages into ~/.nuget/packages. setup-dotnet's cache: true saves that folder keyed on a hash of packages.lock.json files, so it needs lock files: set RestorePackagesWithLockFile in Directory.Build.props and commit them. In CI run dotnet restore --locked-mode: restore fails (NU1004) when a project's references no longer match its lock file, instead of silently resolving a different transitive version than the one that was reviewed. Without lock files, key actions/cache on Directory.Packages.props and the project files.

Build once, test that build

dotnet test defaults to the Debug configuration (only publish and pack default to Release), and without --no-build it builds again. A job that runs dotnet build -c Release and then a bare dotnet test compiles everything twice and tests a Debug build you never ship. Restore once, build with --no-restore, test with -c Release --no-build.

Test runner mode and results

The course's xunit.v3 test projects use UseMicrosoftTestingPlatformRunner, and global.json puts dotnet test in Microsoft.Testing.Platform mode ("test": { "runner": "Microsoft.Testing.Platform" }). In that mode the target is named with --solution or --project, and reports come from the test app: --report-xunit-trx --results-directory TestResults writes TRX files. Upload them with if: ${{ !cancelled() }} so they survive a failing test step. The Microsoft.Testing.Extensions.HangDump package adds --hangdump --hangdump-timeout 5m, which dumps and kills a hung test app instead of the job waiting for its own timeout. A repository still in VSTest mode (Microsoft.NET.Test.Sdk plus xunit.runner.visualstudio) uses a positional path, --logger trx and --blame-hang-timeout 5m instead. The option sets do not mix; the dotnet test lesson compares the modes.

Warnings as errors

WhereEffectChoose it when
dotnet build -warnaserrorEvery MSBuild, compiler and NuGet warning fails this build onlyQuick start, but local builds still accept warnings
TreatWarningsAsErrors in Directory.Build.propsCompiler, analyzer and NuGet restore warnings (including audit NU190x) fail every build, everywhereThe default for new services: developers see the error before pushing
Same property with Condition="'$(CI)' == 'true'"Strict in CI, relaxed while editingTeams that want to compile half-finished code locally

The trap is NuGet audit: NU1901 to NU1904 are warnings, so with blanket warnings-as-errors a vulnerability advisory published overnight fails every pull request, including ones that change a README. Keep audit warnings out of the blanket rule and gate them deliberately (see quality gates).

Green locally, red in CI

The runner is Linux: case-sensitive paths, UTC, invariant culture. Tests that format decimals with the current culture, compare DateTime.Now to a local date or open Data/Orders.json as data/orders.json fail only there. Fix the test, not the runner.

Example

Example · yaml
name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-test:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    env:
      DOTNET_NOLOGO: "true"
      DOTNET_CLI_TELEMETRY_OPTOUT: "true"
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-dotnet@v5
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: "**/packages.lock.json"

      - name: Restore
        run: dotnet restore Orders.slnx --locked-mode

      - name: Build
        run: dotnet build Orders.slnx -c Release --no-restore

      # global.json selects Microsoft.Testing.Platform; --hangdump comes from the
      # Microsoft.Testing.Extensions.HangDump package in the test projects.
      - name: Test
        run: >
          dotnet test --solution Orders.slnx -c Release --no-build
          --results-directory TestResults --report-xunit-trx
          --hangdump --hangdump-timeout 5m

      - name: Upload test results
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults/**/*.trx
          retention-days: 14

When to use it

  • Fail a pull request that adds a nullable warning in OrderService, before review, with TreatWarningsAsErrors and a Release build.
  • Cut a 9-minute pipeline to 4 by restoring from the NuGet cache keyed on packages.lock.json and testing the Release build with --no-build.
  • Keep the TRX file of a failed checkout-flow test as a build artifact so the reviewer sees which assertion broke without rerunning the job.

More examples

Directory.Build.props that makes CI strict and reproducible

MSBuild reads environment variables as properties, so <code>$(GITHUB_ACTIONS)</code> is <code>true</code> only on the runner. <code>ContinuousIntegrationBuild</code> makes the build deterministic: file paths embedded in PDBs are normalised, so two builds of one commit produce the same binaries. <code>RestoreLockedMode</code> is the property form of <code>--locked-mode</code>, which covers every restore on the runner, including the implicit one inside <code>dotnet publish</code>. A <code>docker build</code> does not see <code>GITHUB_ACTIONS</code>, so the Dockerfile copies the lock files and passes <code>--locked-mode</code> itself. <code>WarningsNotAsErrors</code> keeps a newly published advisory from failing unrelated pull requests.

Example · xml
<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <!-- Advisories are gated by a separate step, not by every build. -->
    <WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>

  <PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
    <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
    <RestoreLockedMode>true</RestoreLockedMode>
  </PropertyGroup>
</Project>

A test that only fails on the runner, and its fix

GitHub's Ubuntu runners run with an invariant culture, while developer machines use their own locale, so a test written against <code>TotalForDisplay</code> passes on one machine and fails on the other. The fix is to pin the culture in the code under test, not to set <code>LANG</code> on the runner: production containers have their own culture settings too, and the response format of an API must not depend on them.

Example · csharp
using System.Globalization;
using Xunit;

public record InvoiceLine(string Sku, int Quantity, decimal UnitPrice);

public static class InvoiceFormatter
{
    // Bug: formats with the current culture, so "1.235,50" on a de-DE laptop and "1,235.50" elsewhere.
    public static string TotalForDisplay(IEnumerable<InvoiceLine> lines) =>
        lines.Sum(l => l.Quantity * l.UnitPrice).ToString("N2");

    // Fix: the API returns invariant text and the client localises it.
    public static string TotalForApi(IEnumerable<InvoiceLine> lines) =>
        lines.Sum(l => l.Quantity * l.UnitPrice).ToString("N2", CultureInfo.InvariantCulture);
}

public class InvoiceFormatterTests
{
    private static readonly InvoiceLine[] Lines =
    [
        new("KETTLE-BLUE", 30, 39.90m),
        new("MUG-SET-4", 7, 5.50m),
    ];

    [Fact]
    public void Total_uses_invariant_formatting() =>
        Assert.Equal("1,235.50", InvoiceFormatter.TotalForApi(Lines));
}

Unit and integration tests as separate jobs

The two jobs run in parallel, so the pull request gets the fast unit-test signal in a couple of minutes while the Testcontainers suite pulls images and starts PostgreSQL. Each job builds only the test project it runs, and its references, so <code>--no-build</code> is not needed here. Make both jobs required status checks in the branch protection rules; an integration job that is optional gets ignored the first time it is slow or flaky.

Example · yaml
name: ci-tests

on:
  pull_request:

permissions:
  contents: read

jobs:
  unit:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v5
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: "**/packages.lock.json"
      - run: dotnet test --project tests/Orders.UnitTests -c Release --report-xunit-trx

  integration:
    runs-on: ubuntu-24.04
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-dotnet@v5
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: "**/packages.lock.json"
      # Testcontainers starts PostgreSQL through the Docker engine preinstalled on the runner.
      - run: dotnet test --project tests/Orders.IntegrationTests -c Release --report-xunit-trx --hangdump --hangdump-timeout 5m

Discussion

  • Be the first to comment on this lesson.