Build and Test Stages
By the end of this lesson
Run builds and tests automatically on every change.
The build and test stages are the part of the pipeline that answers one question: does this change work? Everything later in the pipeline — packaging, deploying, promoting — depends on that answer being trustworthy.
Two properties make it trustworthy. The run has to be repeatable, meaning the same commit produces the same result on a machine that has never seen your project. And it has to fail, loudly and immediately, when something is wrong. A pipeline that reports success on a broken commit is worse than no pipeline, because people act on it.
The order of a build and test job, and the reason for each position:
Check out the exact commit
Not a branch name. The pipeline records which commit it verified, which is what makes the result mean something later when you are tracing a bug back through a release.
Restore dependencies from a cache, with locked versions
Cache the downloaded packages, keyed on the lock file. When the lock file changes, the key changes and you get a fresh restore. Locked versions are what keep the pipeline and your machine on the same package set.
Build once, in release configuration
One build, reused by every later step. Rebuilding per step wastes minutes and, worse, means the tests and the artifact were produced by different runs of the compiler.
Run the fast tests
Unit tests with no database, no network and no filesystem. These should finish in seconds to a couple of minutes, and they catch most mistakes. Putting them first is free and it shortens the common failure case dramatically.
Run the slow tests
Integration tests against a real database in a container, contract tests, anything that needs infrastructure. Slower and more valuable per test, so they run second rather than not at all.
Publish the results, whatever happened
Test reports and logs are most needed on the failing run, which is exactly the run where later steps are skipped by default. The publish step needs to run even after a failure.
name: employees-api-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
cache: true
cache-dependency-path: "**/packages.lock.json"
- name: Restore
run: dotnet restore --locked-mode
- name: Build once
run: dotnet build --no-restore --configuration Release -warnaserror
- name: Fast tests
run: dotnet test tests/Employees.UnitTests --no-build --configuration Release
- name: Slow tests
run: dotnet test tests/Employees.IntegrationTests --no-build --configuration Release
- name: Keep the test results even on failure
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: "**/*.trx"- Running on both push to main and pull_request means a change is verified before it merges and again after. Verifying only after the merge lets the shared branch break first, which blocks everyone else.
- Caching is keyed on the lock file, so a dependency change invalidates it automatically. Cache the package downloads and nothing else — caching build output is how a pipeline ends up testing binaries from a previous commit, and that failure is extremely hard to diagnose.
- --locked-mode makes the restore fail if the lock file does not match the project files. Without it, a dependency can resolve to a newer version in the pipeline than the one you tested against, and the difference shows up as a mystery.
- The build happens once and both test steps pass --no-build. They run against the same binaries, which removes a whole class of the works-in-one-step-not-the-other problem.
- Fast tests are a separate step before the slow ones, so a broken unit test fails the job in under a minute instead of after the integration suite has finished starting containers. Reordering costs nothing and is usually the largest single improvement to feedback time.
- if: always() on the upload is what makes results available for the run that failed. Steps after a failure are skipped by default, so without it you get reports only for the runs you did not need them for. The timeout kills a hung test rather than letting it hold a runner for hours.
- Azure Pipelines expresses the same job with trigger and pr blocks, a Cache task keyed on the lock file, and a publish-test-results task with an always condition. The names differ; the ordering and the reasoning transfer unchanged.
[Trait("Category", "Integration")]
public class EmployeeSearchTests : IClassFixture<PostgresContainerFixture>
{
private readonly EmployeesContext _db;
public EmployeeSearchTests(PostgresContainerFixture fixture) => _db = fixture.Context;
[Fact]
public async Task Search_matches_partial_surname()
{
var results = await _db.Employees
.Where(e => e.Surname.StartsWith("Whit"))
.ToListAsync();
Assert.Contains(results, e => e.Surname == "Whitfield");
}
}- The trait is metadata the test runner can filter on: dotnet test --filter Category!=Integration for the fast pass, and the inverse for the slow one. Use this when fast and slow tests share a project.
- Separate projects, as in the pipeline above, are the simpler split and are worth preferring for a new codebase. The filter approach exists because most codebases did not start that way, and moving files is a bigger change than adding an attribute.
- The fixture starts a real database in a container for the test class. That is what makes this test slow and what makes it worth having — a partial-match query is exactly the kind of thing an in-memory substitute gets wrong, because it does not implement the same string comparison rules.
- Each test class gets its own fixture instance, so classes do not share state through the database. Tests that share one database and run in parallel interfere with each other, and the resulting failures look exactly like flakiness.
Both kinds belong in the pipeline. Treating them as one undifferentiated suite is what makes pipelines slow.
| Fast tests | Slow tests | |
|---|---|---|
| Touch | Your own code in memory | A database, a container, the filesystem, sometimes the network |
| Typical run time | Seconds to a couple of minutes for the whole suite | Minutes, and it grows with the number of things it starts |
| A failure tells you | A specific behaviour is wrong, usually with an obvious cause | Something between the parts is wrong, which takes longer to narrow down |
| Flakiness risk | Low. Failures are almost always real | Higher. Timing, ports, container start-up and shared state all interfere |
| When to run | Before every push, and first in the pipeline | In the pipeline, after the fast ones pass |
| What they cannot tell you | Whether your components work together, or whether the SQL is valid | Little, but they are too slow to be your main feedback loop |
Signs a build and test stage needs work, in roughly the order they show up:
- The main check takes longer than about ten minutes, so people push and look away. Feedback that arrives after you have moved on costs a context switch to act on
- Re-running a failed job is the team's first response. That is the symptom of flakiness, and it is also how a real failure gets merged
- A pull request is green and main goes red after the merge. Usually the two run different steps, or the pull request checked a merge result that no longer applies
- The pipeline passes and the application does not start. Something the tests never exercised — configuration, a missing file in the published output, a dependency injection registration — has no coverage at all
- It works locally and fails in the pipeline. Nearly always an undeclared dependency, a difference in locale or time zone, or a test relying on data someone left in a local database
- Nobody can explain what a step does. A pipeline file is code, and an unexplained step is the one that silently stops working
Summary
- The build and test stages exist to answer whether a change works, and everything downstream depends on that answer being reliable
- Restore from a cache keyed on the lock file, build once, and reuse those binaries for every test step
- Run fast tests before slow ones so the common failure is reported in under a minute
- Publish test results with an always condition, because the failing run is the one you need them from
- A pipeline that cannot fail the build, or whose failures are routinely re-run, provides no information
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Time it, then reorder it
Look at your most recent pipeline runs and write down how long each step took. Then find the earliest step that would have caught the last three real failures.
If that step is not near the front, move it and measure again.
Show solution
Most pipelines place steps in the order they were added rather than by how often they catch something. Moving a cheap, high-yield check earlier — compilation, a linter, the unit suite — often cuts the average failing run by several minutes at no cost.
The other common finding is a step that has never caught anything. Sometimes that is coverage worth keeping for the case it protects against. Sometimes it is a check that was already covered elsewhere, and being explicit about which is which is the point of the exercise.
Measure the failing case, not the passing one. A green run takes as long as it takes, and the run that matters to a person waiting for feedback is the one that fails.
Think about it
Passes locally, fails in the pipeline
A test passes on every developer machine and fails in the pipeline. List the causes you would check, in order.
Show solution
Start with data. A local database usually holds rows that accumulated over months, and a test that depends on one of them passes locally and fails on an empty schema. This is the most common cause by a wide margin.
Then environment: time zone and locale differ between a laptop and a Linux runner, and date formatting, string sorting and case-insensitive comparison all change with them.
Then file paths and case sensitivity. A path written with a backslash, or a filename whose case does not match, works on Windows and fails on Linux.
Then ordering and parallelism. A runner may execute tests in a different order or with more parallelism, which exposes tests that depend on each other or share state.
Then undeclared dependencies: a tool, a certificate or an environment variable present on your machine and nowhere else.
The pattern worth internalising is that the pipeline is usually right. It runs on a clean machine, which is a closer approximation of production than a laptop that has been accumulating state since you joined.
Saved in this browser only.