Career OS

Testing & Quality Gates

A green checkmark on a pull request should mean one thing: this code is safe to merge. But a pipeline that only runs mvn compile lies — it goes green while the repository layer is silently broken, because nothing exercised a real database. This module is about making the checkmark trustworthy: running your tests against a real Postgres in CI, measuring how much code those tests actually touch, and wiring it all so a red check physically blocks the merge button.

The Goal

By the end of this module you can:

  • Spin up a real PostgreSQL service container inside a GitHub Actions job and point your tests at it
  • Explain why a database service container beats mocks for the tests that matter most
  • Measure coverage with JaCoCo and set a gate that fails the build below a threshold
  • Configure a required status check so a red pipeline disables the merge button — not just shows a sad red dot
  • Read a matrix build and say when running across multiple Java versions is worth the minutes

The Lesson

The checkmark has to mean something

In CI/CD 02 you ran mvn verify on every push and watched the Actions tab go green. That green means “it compiled and the unit tests passed.” For splitease-api, the most important code is the part that talks to PostgreSQL — the repositories, the JPA queries from Spring Boot 04, the transactions from SQL 07. Unit tests with everything mocked never touch SQL. They pass even when your query has a typo that Postgres would reject instantly.

So there are two layers of tests, and CI must run both:

LayerWhat it checksNeeds a database
Unit testsOne class in isolation, dependencies mockedNo
Integration testsReal wiring — repository against a real PostgresYes

The integration layer is the one CI makes possible. On your laptop you have a Postgres installed; the CI runner is a fresh empty Ubuntu machine. It has no database until you give it one.

A service container is a throwaway database for the job

GitHub Actions can start extra containers alongside your job — service containers. You declare postgres:16 as a service, Actions pulls the official image, starts it, waits for it to be healthy, and exposes it on localhost:5432 for the duration of the job. When the job ends, it’s destroyed. Every run gets a pristine database with zero leftover state — which is exactly what makes tests repeatable.

flowchart LR
    A[Push to branch] --> B[Job starts on fresh Ubuntu runner]
    B --> C[Service container postgres 16 boots alongside]
    C --> D[Health check waits until Postgres accepts connections]
    D --> E[mvn verify runs integration tests against localhost 5432]
    E --> F{All green}
    F -->|yes| G[Checkmark - safe to merge]
    F -->|no| H[Red X - merge blocked]

Here is the job, read it key by key:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: splitease_test
          POSTGRES_USER: splitease
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U splitease"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - name: Run tests
        run: mvn -B verify
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/splitease_test
          SPRING_DATASOURCE_USERNAME: splitease
          SPRING_DATASOURCE_PASSWORD: testpass
BlockWhy it’s there
services.postgres.imageThe exact Postgres version — pin it to match production, not “latest”
env under the serviceCreates the database, user, and password inside the container at boot
ports: 5432:5432Maps the container’s Postgres port to the runner’s localhost
options --health-cmd pg_isreadyThe job waits for this to pass before running steps — without it, your tests start before Postgres is ready and fail with connection refused
env under the stepHands Spring the connection details via the same SPRING_DATASOURCE_* properties you’d use anywhere

The classic trap is leaving out the health check. The container appears “started” the instant it’s created, but Postgres inside it needs a second or two to accept connections. Skip the health gate and roughly one run in three fails with Connection refused — a flaky pipeline that erodes trust faster than no pipeline at all.

Why a real database beats mocks here

You can mock the repository and assert “the service called save once.” That tests your Java wiring. It does not test that the SQL is valid, that the unique constraint fires, that a BIGINT paise column rounds the way you expect, or that the transaction rolls back on failure. Those bugs live at the boundary between your code and Postgres — review the schema and constraints in SQL — overview — and only a real database surfaces them.

flowchart TD
    subgraph Mocked["Mocked repository test"]
        M1[Service code] --> M2[Fake repo returns canned data]
    end
    subgraph Real["Integration test with service container"]
        R1[Service code] --> R2[Real JPA repository]
        R2 --> R3[Real SQL hits real Postgres]
        R3 --> R4[Constraints, types, transactions all enforced]
    end

Rule of thumb: mock things that are slow, external, or out of your control (a payment gateway, an email API). Do not mock your own database — that’s the part you most need to verify, and a service container makes it cheap to verify for real.

Coverage gates — measuring what the tests touch

Tests passing tells you the code you tested works. It says nothing about the code you forgot to test. Coverage measures the fraction of your lines (or branches) that ran during the test suite. For Java the standard free tool is JaCoCo, which plugs into Maven.

A coverage gate fails the build when coverage drops below a line you set:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit>
                <counter>LINE</counter>
                <value>COVEREDRATIO</value>
                <minimum>0.70</minimum>
              </limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

Now mvn verify fails if line coverage falls under 70%. Because CI runs mvn verify, the gate runs automatically on every push — no extra step.

The honest warning every senior will give you: coverage is a floor, not a goal. 100% coverage with assertion-free tests proves nothing — you ran every line and checked none of the results. Treat a coverage number as “did we forget to test a whole area,” not as “is this code correct.” A reasonable starting gate is 60–70% and never let it drop, rather than chasing a vanity 95%.

Coverage signalWhat it actually tells you
Coverage dropped 80% → 55% on a PRA big chunk of new code has no tests — investigate
Coverage is 95% but bugs shipTests run the code but assert nothing — quality problem, not quantity
Coverage is 70% and steadyHealthy floor — focus review on whether the right things are tested

Status checks — making red actually block the merge

A red pipeline that still lets you click “Merge” is decoration. The teeth come from branch protection on main: mark the test workflow as a required status check, and GitHub physically disables the merge button until that check is green.

In the repo: Settings → Branches → add a rule for main → “Require status checks to pass before merging” → select your test job. Now the workflow from earlier isn’t just informative — it’s a gate. A PR with a failing test cannot reach main, no matter who opens it.

flowchart LR
    A[PR opened] --> B[Required check: test job runs]
    B --> C{Green}
    C -->|yes| D[Merge button enabled]
    C -->|no| E[Merge button disabled - fix first]

Pair it with “Require a pull request before merging” and you’ve built the real workflow used at every product company: no direct pushes to main, every change reviewed and CI-verified before it lands. This is the safety net you read about in Git 03 — the pipeline is what makes the PR gate honest.

Matrix builds — the same job across many versions

A matrix runs the same job multiple times with different inputs — most commonly several Java or Node versions — in parallel. You declare the axis once and Actions fans out:

strategy:
  matrix:
    java: ['17', '21']
steps:
  - uses: actions/setup-java@v4
    with:
      distribution: temurin
      java-version: ${{ matrix.java }}

That runs your whole test job twice — once on Java 17, once on 21 — and both must pass. It’s how a library that must support multiple runtimes proves it actually does.

The honest take for your situation: splitease-api deploys on exactly one Java version. A matrix across versions earns nothing and burns double the free-tier minutes. Matrices are gold for libraries and tools used by many people on many setups; for a single-target application, one version is correct. Know the feature, recognize it in someone’s YAML, and reach for it only when you genuinely ship to multiple runtimes.

Check The Concept

How This Shows Up At Work

  • The PR that compiled but broke prod. Someone’s unit tests were fully mocked, CI went green, and a malformed JPA query blew up the first time real Postgres saw it. The fix in the postmortem is always the same: an integration test against a real database in CI. Being the person who already wired the service container is the seniority signal.
  • The coverage argument in code review. A reviewer blocks a PR because coverage dropped from 78% to 61%. The author argues the new code is “trivial.” The mature move is showing the gate isn’t about the number — it’s a tripwire that a whole feature shipped untested. You’ll be on both sides of this conversation.
  • The flaky-pipeline witch hunt. Tests pass locally, fail one CI run in four. Hours get lost before someone spots the missing service health check. Knowing this failure mode by sight saves the team a day.
  • The interview probe. “How do you test code that talks to a database in CI?” The weak answer is “I mock the repository.” The strong answer names service containers (or Testcontainers), explains why the real database matters, and mentions the health-check gotcha. That single answer moves you up a tier.

Build This

You’ll add a database-backed CI job to splitease-api, prove it works, then make a coverage gate fail on purpose so you’ve seen the red.

  1. In splitease-api, create .github/workflows/test.yml with the test job from the Lesson (the services.postgres block + the mvn -B verify step with the SPRING_DATASOURCE_* env). Make sure your application.yml for tests reads those same env var names.

  2. Add one integration test that actually hits Postgres — annotate it @SpringBootTest, autowire a real repository, save an entity, read it back, assert the values. No mocks in this test.

  3. Commit and push to a branch. Open the Actions tab and watch the run. Expand the job — you’ll see a “Initialize containers” step starting postgres:16, then your test step. Confirm it goes green.

✓ Initialize containers (postgres:16)
✓ Set up JDK 21 (temurin)
✓ Run tests  → BUILD SUCCESS
  1. Add the JaCoCo check plugin from the Lesson with <minimum>0.70</minimum>. Run mvn verify locally first to see your current number, then commit.

  2. Break it on purpose #1 — fail the coverage gate. Add a new class with a real method and no test for it. Push. Watch the build go red with a JaCoCo message like:

Rule violated for bundle splitease-api: lines covered ratio is 0.63, but expected minimum is 0.70

Read it: the gate caught untested code exactly as designed. Add a test, push, watch it go green.

  1. Break it on purpose #2 — kill the database connection. Temporarily change the service POSTGRES_DB to a different name than your SPRING_DATASOURCE_URL expects. Push. Your integration test fails with a connection or database-not-found error — proof the test really depends on the live database and isn’t quietly passing on mocks. Fix the name back.

  2. In GitHub repo Settings → Branches, add a rule for main requiring the test status check. Open a PR with a deliberately failing test and confirm the Merge button is disabled until you fix it.

Interview Practice

These are asked verbatim at Indian product companies for backend roles. Answer out loud first, then check.

1. How do you test code that talks to a database in your CI pipeline?

I run integration tests against a real database started as a service container in the CI job — for example postgres:16 declared under services in GitHub Actions, exposed on localhost, with a health check so tests wait until it accepts connections. The test config points Spring’s datasource at that container. I prefer this over mocking the repository because the bugs that matter — invalid SQL, constraint violations, type and transaction behaviour — only surface against a real database. Testcontainers is the same idea managed from inside the test code instead of the workflow file.

2. What's the difference between a unit test and an integration test?

A unit test exercises one class in isolation with its dependencies mocked — fast, no I/O, proves the logic of that class. An integration test exercises real wiring across components — a repository against a real database, or an HTTP endpoint through the full Spring context — proving the pieces actually work together. You want many fast unit tests and a focused set of integration tests on the risky boundaries, especially the database.

3. What does code coverage measure, and what's a good target?

Coverage measures the fraction of code (lines or branches) executed during the test run. It tells you what you forgot to test, not whether the tests are correct — you can hit 100% with assertion-free tests that prove nothing. I treat it as a floor: a sensible gate around 60–70% that’s never allowed to drop, rather than chasing a vanity number. The real review question is whether the right behaviour is asserted, not the percentage.

4. How do you stop broken code from being merged?

Branch protection on the main branch with the CI workflow set as a required status check. GitHub then disables the merge button until the check is green, so a failing pipeline physically blocks the merge. Combined with requiring a pull request and a review, no unreviewed or untested change can reach main — direct pushes are off.

5. What is a matrix build and when would you use one?

A matrix runs the same job multiple times across different inputs — typically several language versions or operating systems — in parallel, and all must pass. It’s the right tool for a library or CLI that must support many runtimes its users run. For a single application that deploys on one fixed version, a matrix just doubles CI minutes for no benefit, so I wouldn’t use one there.

6. Your CI tests pass locally but fail intermittently in the pipeline. Where do you look first?

Flakiness usually means a race or shared state. Top suspect with a database service container is a missing or too-short health check — tests start before Postgres accepts connections and fail with connection refused on some runs. Other causes: tests depending on each other’s data instead of a clean state per run, time or ordering assumptions, or relying on an external service. I’d reproduce by re-running the job, check the service health gate, and isolate state per test.

7. Why not just mock the database in tests — it's faster?

Speed isn’t free here. Mocking the database tests only that my Java called the repository — it never verifies the SQL is valid, that constraints fire, that types behave, or that transactions roll back correctly. Those are exactly the bugs that reach production. Service containers make a real database cheap enough in CI that I don’t have to trade away that verification. I mock slow external dependencies I don’t control, not my own database.

Where to Practice

ResourceWhat to doHow long
docs.github.com (Actions → service containers)Read the “Creating PostgreSQL service containers” guide alongside your own test.yml30 min
docs.github.com (branch protection rules)Read “About protected branches” and set up one required check on a test repo20 min
postgresql.org/docs (server programs → pg_isready)Read what pg_isready reports — it’s the exact command your health check runs10 min

Check Yourself

  1. What is a service container, and why does each CI run get a clean database from it?
  2. Why does the service block need a health check, and what breaks without it?
  3. Name one bug a real-Postgres integration test catches that a mocked-repository test cannot.
  4. What does a JaCoCo coverage gate do, and why is “100% coverage” not the goal?
  5. What makes a red status check actually block a merge?
  6. When is a version matrix build worth the extra CI minutes, and when is it waste?
  7. Which Spring properties did the workflow set via env to point tests at the container, and why pass them as env vars rather than hardcoding?
Answers
  1. An extra container Actions starts alongside the job (e.g. postgres:16), exposed on localhost for the job’s lifetime and destroyed at the end. Because it’s created fresh per run, every run starts from an empty, identical database — which is what makes tests repeatable.
  2. The container reports “started” before Postgres can accept connections; the health check (pg_isready) makes the job wait until it’s actually ready. Without it, the test step races ahead and fails intermittently with connection refused — a flaky pipeline.
  3. Any of: invalid SQL in a JPA query, a unique/foreign-key constraint firing, type/precision behaviour on a BIGINT paise column, or transaction rollback on failure. Mocks return canned data and never touch SQL, so none of these surface.
  4. It fails the build when coverage drops below a set minimum (e.g. 70% line coverage), catching whole areas shipped untested. 100% isn’t the goal because coverage measures lines run, not results asserted — you can have full coverage with tests that verify nothing.
  5. Branch protection on the target branch marking the workflow a required status check. GitHub then disables the merge button until the check passes.
  6. Worth it for a library/tool that must support multiple runtimes (Java 17 and 21, several OSes) its users actually run. Waste for a single application that deploys on one fixed version — it just doubles minutes.
  7. SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD. As env vars they stay out of the committed config, can differ per environment, and (for real secrets) come from GitHub Secrets rather than the repo.

Explain it out loud: Explain to an empty chair why a pipeline that only runs mvn compile gives a checkmark you can’t trust, and walk through exactly what you’d add — a Postgres service container with a health check, an integration test that hits it, a coverage gate, and a required status check — so the green checkmark finally means “safe to merge.”

Why AI Can’t Do This For You

AI will generate a workflow YAML with a Postgres service container in seconds — and it’ll often hand you one without the health check, or with a coverage gate set to a number that means nothing for your codebase. It can’t see that your integration test is silently passing on mocks, that your “100% coverage” suite asserts nothing, or that the flaky run last night was a race against an unready database. Those are read-the-evidence judgments made against your repo.

The skill is deciding what to test against the real database, where the coverage floor should sit so it catches regressions without becoming theatre, and which checks deserve to block a merge. That judgment comes from wiring it yourself, watching a gate catch real untested code, and debugging your own flaky run — not from a prompt that produces plausible YAML.

Module done? Add it to today’s tracker

Saves your progress on this device.