﻿# Continuous integration: pipelines, unit tests, static analysis

> Milestone 3: a pipeline on every merge request – format check, build, unit tests with coverage and static analysis – for Java, .NET and Unity, plus how to read a red job and run the same checks locally.

Source: https://laszlonemes.com/university/software-technology/continuous-integration · Author: László Nemes

Milestone 3 · Software Technology

Milestone 3: a pipeline on every merge request – format check, build, unit tests with coverage and static analysis – for Java, .NET and Unity, plus how to read a red job and run the same checks locally.

13 min read `Pipeline stages and triggers` `Per-stack setup` `Unit tests and coverage` `Static analysis` `Reading a red pipeline` `The ratchet`

Milestone 3, in week 10, is the second prototype: about 90% of the use cases work, unit tests exist, and **CI is set up and used regularly** – every merge request runs a pipeline, including a static-analysis code-quality check. This is the milestone where many teams discover that their "working" code only worked on one laptop.

After this session you can

- explain which pipeline jobs run on which events, and why
- switch on the pipeline for Java, .NET or Unity with one include line
- write unit tests for the model that cover the error path, and read the coverage number
- go from a red job to the fix in a few minutes
- tighten the pipeline over time without blocking the team

## What CI is for

Continuous integration means every change is built and tested in a clean, identical environment before it reaches `develop` – not on your machine, with your cached files and your operating system. From then on, "it works for me" is not an argument; the pipeline is.

> A green pipeline on a merge request means: it compiles, the tests pass, the code is formatted, and the analyser found no known bug pattern. A reviewer can then spend their attention on what a machine can't check.

- **Pipeline** – All the jobs that run for one event (an MR, a push, a tag).
- **Job** – One step in a container: format-check, build, test, static-analysis, package, release.
- **Stage** – A column of jobs. With needs: a job starts as soon as the jobs it needs are done.
- **Runner** – The machine that executes jobs. The course GitLab has shared runners.
- **Artifact** – A file a job keeps: test reports, coverage XML, the packaged game.

## The pipeline and when it runs

The recommended pipeline has the same shape for every stack: a fast format check first, then the build, then tests with coverage and static analysis in parallel, and – only for a version tag – packaging and a release (Fig. 1).

> **Fig. 1** · Format-check fails in seconds, so it runs first. Tests and analysis don't wait for each other. Only a tagged, fully green state is ever packaged.
>
> *Diagram:* The pipeline's jobs. format-check and build start at once. test, with coverage, and static-analysis both need build and run in parallel. Only on a version tag do package and release follow, after all checks are green. GitLab's SAST and secret detection templates run alongside.

| Event | What runs |
| --- | --- |
| Push to a feature branch, no MR | Nothing – open an MR (a Draft is enough). Saves runner time and avoids double runs. |
| Merge request | format-check, build, test, static-analysis, SAST, secret detection. |
| Push to `develop` or `main` | The same checks; the coverage badge updates from these runs. |
| Tag `v1.0`, `v2.0` … | Everything above, plus package and release: a downloadable build on the Releases page. |

The rules at the top of the pipeline file decide this:

Which events start a pipeline

```yaml
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+(\.\d+)?$/
```

Good to know

**Why only on MRs?**

If both the push and the MR started a pipeline, every commit would run twice. Running on MRs also means GitLab can show the results where the reviewer looks: the test summary and the covered lines in the MR's diff. A newer push to the same branch cancels the older, still-running pipeline (`interruptible`).

## Switching it on, per stack

The template keeps one complete pipeline per stack under `ci/gitlab/`; the root `.gitlab-ci.yml` only includes one of them. The real logic sits in the build tool – Gradle, Maven, MSBuild – and the pipeline just calls it, so the build file has to contain the plugins the jobs expect.

Choosing the stack

```yaml
# .gitlab-ci.yml – exactly one line active
include:
  - local: ci/gitlab/java-gradle.gitlab-ci.yml
  # - local: ci/gitlab/java-maven.gitlab-ci.yml
  # - local: ci/gitlab/dotnet.gitlab-ci.yml
  # - local: ci/gitlab/unity.gitlab-ci.yml
```

| Stack | Formatter | Tests + coverage | Static analysis |
| --- | --- | --- | --- |
| Java (Gradle / Maven) | Spotless with google-java-format | JUnit + JaCoCo | SpotBugs, compiler -Xlint:all |
| .NET (C#) | `dotnet format` | xUnit, NUnit or MSTest + Coverlet | Roslyn analyzers, warnings as errors, nullable |
| Unity | .editorconfig in the IDE | Unity Test Framework (EditMode, PlayMode) + Code Coverage package | the course CI component |

### Java

java-gradle.gitlab-ci.yml – the test job

```yaml
test:
  stage: test
  needs: [build]
  script:
    - ./gradlew test jacocoTestReport
    # ... plus a few lines that print "Line coverage: NN%" from the JaCoCo XML
  coverage: '/Line coverage: (\d+)%/'
  artifacts:
    when: always
    reports:
      junit: build/test-results/test/TEST-*.xml
      coverage_report:
        coverage_format: jacoco
        path: build/reports/jacoco/test/jacocoTestReport.xml
```

The `junit` report makes the MR show which test failed; the `coverage_report` marks covered and uncovered lines right in the diff; the `coverage` regex feeds the badge.

### .NET

dotnet.gitlab-ci.yml – the three checks

```yaml
format-check:
  script:
    - dotnet format $SOLUTION --verify-no-changes --verbosity minimal

static-analysis:
  script:
    - dotnet build $SOLUTION -c Release -p:TreatWarningsAsErrors=true -p:EnforceCodeStyleInBuild=true

test:
  script:
    - >
      dotnet test $SOLUTION --no-build -c Release
      --logger "junit;LogFilePath=$CI_PROJECT_DIR/test-results/{assembly}.junit.xml"
      --collect:"XPlat Code Coverage" --settings coverlet.runsettings
```

GitLab only reads JUnit XML, so the test projects need the `JunitXml.TestLogger` package. Set the `SOLUTION` and `MAIN_PROJECT` variables at the top of the file, and put `Directory.Build.props` (analyzers, warnings as errors) and `coverlet.runsettings` in the repository root.

### Unity

unity.gitlab-ci.yml – two test jobs

```yaml
test-EditMode:
  extends: .test            # the editor in batch mode: -runTests -testPlatform $TEST_PLATFORM
  variables:
    TEST_PLATFORM: EditMode

test-PlayMode:
  extends: .test
  variables:
    TEST_PLATFORM: PlayMode
```

- Only the Unity and Godot versions announced at the start of the semester run on the course runners, and the course provides the CI images and components for them – use those first.
- The editor needs a licence in CI: a `UNITY_LICENSE` CI/CD variable. Don't mark it *Protected*, or the MR pipelines of feature branches can't see it and every test fails with a licence error. Never commit the licence file.
- Keep the game logic in plain C# classes tested in EditMode – fast, no scene needed. PlayMode tests are for behaviour that needs the running game.
- Binary assets go in Git LFS, and only one person edits a scene at a time; split scenes into prefabs to make conflicts rarer.

### GitHub Actions

The same pipelines exist as GitHub Actions workflows: copy the chosen one to `.github/workflows/ci.yml`. The triggers express the same rules:

GitHub: when the workflow runs

```yaml
# .github/workflows/ci.yml
on:
  pull_request:
    branches: [main, develop, "release/**"]
  push:
    branches: [main, develop]
    tags: ["v[0-9]+.[0-9]+"]
```

## Unit tests and coverage

A unit test checks one behaviour of one class, in milliseconds, without a window. That is only possible if the model is separate from the view – the design decision from [milestone 1](https://laszlonemes.com/university/software-technology/requirements-design) pays off here. Test every public behaviour of the model: the happy path *and* the error path.

SaveTest.java – the error path

```java
@Test
void loadingACorruptFileThrowsAndChangesNothing(@TempDir Path dir) throws IOException {
    Path file = dir.resolve("broken.save");
    Files.writeString(file, "{ not json");
    GameState before = session.state();

    assertThrows(SaveFormatException.class, () -> session.load(file));
    assertEquals(before, session.state());
}
```

GameTests.cs – a map built from a string

```csharp
[Fact]
public void StepIntoWallLeavesPlayerInPlace()
{
    var game = new Game(Map.Parse("#P."));       // wall, player, free tile – a map as a string
    game.Step(Direction.Left);
    Assert.Equal(new Position(1, 0), game.Player.Position);
}
```

Small maps written as strings make game tests short and readable – and the same parser can load real levels. **Coverage** is the share of lines the tests execute. It is measured on the model only; the view and the entry point are excluded (Fig. 2).

> **Fig. 2** · Unit tests and the coverage number are about the model. The view is checked by people – in the demo, in the video, in PlayMode tests.
>
> *Diagram:* The view layer – windows, panels, input – is excluded from coverage and checked by hand, in the demo and the gameplay video. The model layer – rules, state, saving – is where unit tests run and coverage is measured. The entry point, Main or Program.cs, is excluded too.

JaCoCo exclusions (Gradle)

```java
// build.gradle – JaCoCo: measure the model, not the view
classDirectories.setFrom(files(classDirectories.files.collect {
    fileTree(dir: it, exclude: ['**/view/**', '**/ui/**', '**/Main.class'])
}))
```

Coverlet exclusions (.NET)

```xml
<!-- coverlet.runsettings – the same for .NET -->
<ExcludeByFile>**/Program.cs,**/View/**/*.cs,**/Views/**/*.cs,**/UI/**/*.cs</ExcludeByFile>
```

Common mistake

**Coverage without assertions**

A test that calls every method and asserts nothing reaches high coverage and catches nothing. The number says which lines ran, not whether their result was checked. The reviewer looks at what the tests *assert*.

## Static analysis

A static analyser reads the code without running it and looks for known bug patterns: a possible null dereference, a stream that is never closed, a comparison that is always true, an `equals` without `hashCode`. From M3 the course requires such a check in the pipeline.

- **Java:** SpotBugs analyses the compiled bytecode; `-Xlint:all` makes every compiler warning visible.
- **.NET:** the Roslyn analyzers are part of the SDK; `Directory.Build.props` sets the recommended level and turns warnings – including nullable warnings – into errors.
- **Course CI components:** from M3 the course provides ready-made components for static analysis that a single `include` line adds. The description is published in the course's GitLab group.

Adding a CI component

```yaml
include:
  - local: ci/gitlab/java-gradle.gitlab-ci.yml
  - component: <path published by the course>/<component name>@<ref>   # static analysis
```

If a finding is really a false positive, exclude exactly that class and pattern – with a comment saying why, so the reviewer can judge it:

An exclusion with its reason

```xml
<!-- config/spotbugs-exclude.xml – every exclusion says why -->
<!-- Board exposes its tile array on purpose: the view reads it every frame. -->
<Match>
  <Class name="~.*\.model\.Board" />
  <Bug pattern="EI_EXPOSE_REP" />
</Match>
```

## Reading a red pipeline

A red pipeline is the author's job to fix, not the reviewer's. Click the red job and scroll to the end of its log: the error is almost always in the last 30 lines (Fig. 3).

> **Fig. 3** · Each red job has a typical cause and a one-step fix. A job that didn't start at all usually means there is no MR yet.
>
> *Diagram:* What to do when a job is red. format-check: run the formatter locally and commit. build: usually an uncommitted file or a file-name case mismatch, since the Linux runner is case-sensitive. test: open the Tests tab and run the failing test locally. static-analysis: read the report and fix the bug pattern, or exclude a real false positive with a reason.

Common mistake

**Case-sensitive file names**

`Board.java` referenced as `board.java`, or an image loaded as `Player.PNG` that is stored as `player.png`: Windows doesn't care, the Linux runner does. If the build is red only in CI, check letter case and uncommitted files first.

## Run the same thing locally

Because the pipeline only calls the build tool, every job has a local equivalent (Fig. 4). Run it before you open or update an MR – if it is green locally, it will be green in CI.

> **Fig. 4** · One build file, two callers. The laptop applies the formatter, the runner only checks it – otherwise the commands are the same.
>
> *Diagram:* Your laptop and the CI runner call the same build tool with the same build file, where the plugins and rules live. So what passes locally passes in CI, and works on my machine stops being an argument.

Before every MR

```shell
# Java / Gradle
./gradlew spotlessApply && ./gradlew build spotbugsMain

# Java / Maven
mvn spotless:apply && mvn verify

# .NET
dotnet format && dotnet build -warnaserror && dotnet test
```

## The ratchet: tightening over time

The recommended setup is strict from the start – static analysis blocks the merge, warnings are errors in .NET – because on an empty project strictness costs nothing, and introducing it later is much harder. Some rules are best switched on once the team is ready, around M3: a coverage threshold, warnings as errors in Java (Fig. 5).

> **Fig. 5** · Each step is switched on when the code already passes it, and stays on. A loosened rule comes with an issue to tighten it again.
>
> *Diagram:* A ratchet of four steps that only goes up: first build and tests on every merge request; then formatting and static analysis block the merge; then a coverage gate fails the pipeline below a threshold; finally stricter still, with warnings as errors and a higher percentage. A rule is loosened only with an issue to tighten it again.

Switching on the coverage gate (Gradle)

```java
// build.gradle – the coverage gate: the build fails below 80% line coverage
check.dependsOn jacocoTestCoverageVerification

jacocoTestCoverageVerification {
    violationRules {
        rule { limit { counter = 'LINE'; value = 'COVEREDRATIO'; minimum = 0.80 } }
    }
}
```

On .NET the threshold needs the `coverlet.msbuild` package and `/p:Threshold=80` on `dotnet test`. If you must loosen a rule temporarily – importing old code, say – mark the job as allowed to fail and open an issue to undo it, with a milestone:

A temporary exception, with its issue

```yaml
static-analysis:
  allow_failure: true      # TEMPORARY – issue #41 switches it back before M4
```

## What the M3 demo shows

In your assignment

**Prototype II**

- About 90% of the use cases work. The remaining 10% are issues on the M4 milestone, not surprises.
- Every MR since CI was switched on has a pipeline; the merged ones are green. A pipeline and a coverage badge sit in the README.
- Unit tests for the model, including error paths; the coverage number is visible in GitLab.
- A static-analysis check runs in the pipeline and blocks on findings.
- The release is tagged `v2.0` and the pipeline built a downloadable package from the tag.

Next: the [final product](https://laszlonemes.com/university/software-technology/final-product) – clean code, thorough tests and the last release.

## Summary

- CI builds and tests every MR in a clean environment; feature pushes without an MR run nothing, tags add package and release.
- Pipeline: format-check → build → test + coverage and static analysis in parallel → package → release.
- One include line chooses the stack; the logic lives in the build file, so every job has a local equivalent.
- Unit-test the model, happy and error paths; coverage is measured on the model only.
- Red job: read the end of its log, fix locally, push. Tighten rules over time and never loosen one without an issue.
