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.
dotnet restore --locked-mode
dotnet build -c Release --no-restore
dotnet test --solution Orders.slnx -c Release --no-build --report-xunit-trx --results-directory TestResultsA 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
| Where | Effect | Choose it when |
|---|---|---|
dotnet build -warnaserror | Every MSBuild, compiler and NuGet warning fails this build only | Quick start, but local builds still accept warnings |
TreatWarningsAsErrors in Directory.Build.props | Compiler, analyzer and NuGet restore warnings (including audit NU190x) fail every build, everywhere | The default for new services: developers see the error before pushing |
Same property with Condition="'$(CI)' == 'true'" | Strict in CI, relaxed while editing | Teams 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
nameci
on
pull_request
push
branchesmain
permissions
contentsread
concurrency
groupci-$ github.ref
cancel-in-progresstrue
jobs
build-test
runs-onubuntu-24.04
timeout-minutes20
env
DOTNET_NOLOGO"true"
DOTNET_CLI_TELEMETRY_OPTOUT"true"
steps
usesactions/checkout@v5
usesactions/setup-dotnet@v5
with
global-json-fileglobal.json
cachetrue
cache-dependency-path"**/packages.lock.json"
nameRestore
rundotnet restore Orders.slnx --locked-mode
nameBuild
rundotnet 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.
nameTest
run
dotnet test --solution Orders.slnx -c Release --no-build
--results-directory TestResults --report-xunit-trx
--hangdump --hangdump-timeout 5m
nameUpload test results
if$ !cancelled()
usesactions/upload-artifact@v4
with
nametest-results
pathTestResults/**/*.trx
retention-days14When 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.
<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.
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.
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