Unit Test as Guard #

There are two different ways of viewing unit tests. The first way: tests are an obligation to fulfill before a PR can merge — coverage numbers must hit a threshold, CI must be green, done. This approach produces tests that exist but are meaningless: assertions that always pass without verifying any behavior, tests written after the code is done just to chase numbers, 80% coverage hiding critical paths with no coverage at all.

The second way: tests are proof that code works as expected — in all important cases, including the uncomfortable ones. Tests are executable documentation explaining to the next engineer what should happen and what shouldn’t. And tests as CI guards are the automatic mechanism ensuring no change can enter the codebase without proving itself.

The difference between the two isn’t tooling or number thresholds — it’s understanding what the test function actually is in the development cycle. This article covers unit tests from the pull request guard perspective: why they need to exist, how they should work, and what makes them truly effective as quality gatekeepers.

Why Tests Must Be Guards, Not Optional #

Human reviewers have an extraordinary ability to detect design problems, architecture inconsistencies, and potential edge cases — but they have one fundamental limitation: they can’t execute code in their heads for every possible scenario. A reviewer can see that a case isn’t handled, but can’t prove that the case actually produces wrong output.

Tests can do what reviewers can’t: execute every scenario deterministically and report results with certainty. Reviewers and tests aren’t substitutes for each other — they complement each other.

flowchart LR
    A[PR opened] --> B[CI Pipeline]
    B --> C{Unit Tests}
    C -->|Failed| D["PR blocked\nautomatically"]
    D --> E["Author fixes\nwithout needing\nto wait for reviewers"]
    E --> B
    C -->|Passed| F[Code Review]
    F --> G{Reviewer}
    G -->|Request Changes| H[Author revises]
    H --> B
    G -->|Approve| I[Merge to main]

    style D fill:#ffcccc
    style I fill:#ccffcc

Without this guard, two bad scenarios can happen. First: reviewers find bugs that tests should have detected — reviewer time is wasted on automatable things. Second: bugs go completely undetected because reviewers don’t check every code path — and the bug reaches main.


Shift-Left: The Earlier It’s Found, The Cheaper It Is #

The shift-left testing principle is moving testing to the earliest point in the development cycle — because the cost of finding and fixing bugs grows exponentially over time.

Bug-finding costs at various stages:

  While writing code        → cheapest
  (developer notices alone)     cost: a few minutes of refactoring

  When unit tests fail      → still cheap
  (CI guard catches it)         cost: tens of minutes of debugging and fixing

  At code review            → starting to get expensive
  (reviewer finds it)           cost: context switching, revisions, re-review

  At QA testing             → expensive
  (found in staging)            cost: bug report, investigation, fix, re-deploy

  At production incident    → very expensive
  (users feel it)               cost: incident response, hotfix, communication,
                              reputation, potential data loss

Unit tests as a CI guard are the concrete implementation of shift-left: every push to a PR branch automatically runs the entire test suite, and if anything fails, nothing can merge until it’s fixed. Bugs that would have been production incidents become a few-minute feedback loop in local development.


What Makes Tests Truly Useful as Guards #

This is the most often ignored part of the “unit tests are mandatory” discussion. Tests that exist but can’t fail when behavior changes are tests protecting nothing — they only provide false confidence.

Tests That Can Fail #

A good test is one that will fail if the tested logic changes to being wrong. This sounds trivial, but many written tests don’t meet this criterion.

// ANTI-PATTERN: a test that can't fail (false positive)
func TestCalculateDiscount(t *testing.T) {
    result := calculateDiscount(100, "PROMO10")
    assert.NotNil(t, result) // ← always true, verifies nothing
}

// ANTI-PATTERN: a test with no assertion
func TestProcessPayment(t *testing.T) {
    processPayment(42, 150000)
    // no assertion — the test "passes" no matter what happens
}

// CORRECT: tests truly verifying behavior
func TestCalculateDiscountWithValidPromo(t *testing.T) {
    // Arrange
    price := 100_000
    promoCode := "PROMO10"

    // Act
    discountedPrice := calculateDiscount(price, promoCode)

    // Assert
    assert.Equal(t, 90_000, discountedPrice) // 10% discount from 100.000
}

func TestCalculateDiscountWithInvalidPromo(t *testing.T) {
    // If the promo is invalid, the price must not change
    price := 100_000
    discountedPrice := calculateDiscount(price, "INVALID_CODE")
    assert.Equal(t, 100_000, discountedPrice)
}

func TestCalculateDiscountWithZeroPrice(t *testing.T) {
    // Edge case: a price of 0 must stay 0 after discount
    assert.Equal(t, 0, calculateDiscount(0, "PROMO10"))
}

Tests That Test Behavior, Not Implementation #

Tests too tightly coupled to implementation details break every time a refactor happens — even when behavior doesn’t change. Good tests test what the code does, not how it does it.

// ANTI-PATTERN: tests too tied to implementation
func TestProcessOrder(t *testing.T) {
    mockDB := new(MockDatabase)
    mockEmail := new(MockEmailSender)
    service := NewOrderService(mockDB, mockEmail)

    service.ProcessOrder(42)

    // Checking internal implementation details, not behavior
    mockDB.AssertCalled(t, "SessionBegin")       // ← implementation detail
    mockDB.AssertCalled(t, "Query", "SELECT...") // ← implementation detail
    mockEmail.AssertCalled(t, "SendTemplate", "order_confirmation", templateData)

    // If the DB implementation changes (but behavior stays the same),
    // this test will fail — even though nothing is wrong
}

// CORRECT: tests focusing on observable behavior
func TestProcessOrderSendsConfirmation(t *testing.T) {
    // Setup: a valid order
    order := CreateTestOrder(42, 1, 150000)
    fakeEmailService := &FakeEmailService{} // test double, not strict mock
    service := NewOrderService(nil, fakeEmailService)

    service.ProcessOrder(order)

    // Assert externally observable behavior
    assert.True(t, fakeEmailService.WasConfirmationSentTo(order.UserEmail))
    assert.Equal(t, "processing", order.Status)
    // Don't care how the internal DB works, only care about the result
}

Tests Covering the Happy Path AND Edge Cases #

Tests only for the happy path give false confidence. Most bugs live in edge cases — empty input, out-of-bound values, race conditions, dropped connections.

// A comprehensive test suite for a balance transfer function
func TestTransferBalance(t *testing.T) {
    // Happy path
    t.Run("success reduces sender balance", func(t *testing.T) {
        sender := createWallet(100_000)
        receiver := createWallet(50_000)
        transfer(sender, receiver, 30_000)
        assert.Equal(t, 70_000, sender.balance)
    })

    t.Run("success increases receiver balance", func(t *testing.T) {
        sender := createWallet(100_000)
        receiver := createWallet(50_000)
        transfer(sender, receiver, 30_000)
        assert.Equal(t, 80_000, receiver.balance)
    })

    // Edge case: balance limits
    t.Run("fails when balance insufficient", func(t *testing.T) {
        sender := createWallet(10_000)
        receiver := createWallet(0)
        err := transfer(sender, receiver, 50_000)
        assert.ErrorIs(t, err, ErrInsufficientBalance)
    })

    t.Run("exact balance succeeds", func(t *testing.T) {
        // Transfer exactly equal to the available balance
        sender := createWallet(50_000)
        receiver := createWallet(0)
        transfer(sender, receiver, 50_000)
        assert.Equal(t, 0, sender.balance)
        assert.Equal(t, 50_000, receiver.balance)
    })

    // Edge case: invalid values
    t.Run("fails with zero amount", func(t *testing.T) {
        sender := createWallet(100_000)
        receiver := createWallet(0)
        err := transfer(sender, receiver, 0)
        assert.ErrorIs(t, err, ErrInvalidAmount)
    })

    t.Run("fails with negative amount", func(t *testing.T) {
        sender := createWallet(100_000)
        receiver := createWallet(0)
        err := transfer(sender, receiver, -10_000)
        assert.ErrorIs(t, err, ErrInvalidAmount)
    })

    // Edge case: transferring to yourself
    t.Run("transfer to self fails", func(t *testing.T) {
        wallet := createWallet(100_000)
        err := transfer(wallet, wallet, 10_000)
        assert.ErrorIs(t, err, ErrSelfTransfer)
    })
}

Coverage: The Often-Misunderstood Metric #

Coverage is a useful indicator but is often used the wrong way. 90% coverage doesn’t mean the code is well tested — it only means 90% of code lines were executed by at least one test. Lines executed without meaningful assertions still count.

Problems with chasing coverage numbers:

  Code with 95% coverage but meaningless tests:
  def calculate_price(base_price, discount_pct):
      if discount_pct > 100:
          raise ValueError("Discount cannot exceed 100%")
      return base_price * (1 - discount_pct / 100)

  A number-chasing test:
  def test_calculate_price():
      result = calculate_price(100, 10)
      assert result is not None  # ← coverage achieved, but no value

  A genuinely useful test:
  def test_calculate_price_with_discount():
      assert calculate_price(100, 10) == 90.0   # 10% discount

  def test_calculate_price_with_zero_discount():
      assert calculate_price(100, 0) == 100.0   # no discount

  def test_calculate_price_rejects_excessive_discount():
      with pytest.raises(ValueError):
          calculate_price(100, 110)             # > 100% is invalid

A more useful approach than chasing coverage thresholds is identifying critical paths and ensuring those paths are covered with meaningful tests.

Correct coverage priorities:

  Level 1 — Must be covered with meaningful tests:
  → Core business logic (price calculations, validation, state machines)
  → Security boundaries (authentication, authorization, input validation)
  → Critical error handling (payment failures, data corruption prevention)
  → Edge cases that could cause data inconsistency

  Level 2 — Should be covered:
  → Happy paths of all main endpoints/functions
  → Complex data transformations

  Level 3 — Nice to have:
  → Simple utility functions
  → Generated code

  What doesn't need strict testing:
  → Trivial getters/setters
  → Framework boilerplate
  → Third-party library code
Don’t let coverage thresholds become a target that kills creativity. Teams forced to reach 90% coverage will write fake tests to hit the number. Better a low threshold with meaningful tests than a high threshold with tests that catch no bugs.

Fast and Deterministic Tests #

Unit tests as a CI guard are only effective if developers are actually willing to wait for the results. Slow or non-deterministic tests frustrate developers and eventually make them find ways around it.

Characteristics of unit tests effective as guards:

  ✓ Fast — each test finishes in milliseconds, the whole suite in seconds
    → No connections to real databases
    → No HTTP calls to external services
    → No unnecessary file I/O
    → Use in-memory implementations or test doubles

  ✓ Deterministic — same result every time it runs
    → Not dependent on the current time (mock time if needed)
    → Not dependent on state left by previous tests
    → Not dependent on test execution order
    → Not dependent on random numbers without a fixed seed

  ✓ Isolated — each test stands alone
    → Clean setup and teardown for every test
    → No shared state between tests
    → Tests can run in any order with the same results

  ✗ Problematic tests as guards:
    → Tests that sometimes pass, sometimes fail (flaky tests)
    → Tests that only run in specific environments
    → Tests taking 5 minutes to finish
    → Tests that can't run in parallel
// ANTI-PATTERN: a non-deterministic test
func TestSendNotification(t *testing.T) {
    // Depends on real time — could fail if run exactly at midnight
    now := time.Now()
    notification := createNotification(now)
    assert.True(t, notification.ShouldSendNow())
}

// CORRECT: control time for deterministic tests
func TestSendNotificationWhenScheduledTimeReached(t *testing.T) {
    fixedTime := time.Date(2025, 6, 1, 14, 0, 0, 0, time.UTC)
    // Inject a fixed clock instead of reading the real time
    notification := createNotification(fixedTime, WithNow(fixedTime))
    assert.True(t, notification.ShouldSendNow())
}

func TestDoNotSendNotificationBeforeScheduledTime(t *testing.T) {
    scheduledAt := time.Date(2025, 6, 1, 14, 0, 0, 0, time.UTC)
    earlierTime := time.Date(2025, 6, 1, 13, 59, 59, 0, time.UTC)
    notification := createNotification(scheduledAt, WithNow(earlierTime))
    assert.False(t, notification.ShouldSendNow())
}

Setting Up the CI Pipeline as a Guard #

Guards don’t work if not configured correctly at the infrastructure level. Here’s how an effective CI pipeline guard should be set up.

flowchart TD
    A[Push to PR branch] --> B[CI Pipeline starts]

    B --> C1[Linting & Formatting]
    B --> C2[Unit Test Suite]
    B --> C3[Security Scan]

    C1 --> D{All checks passing?}
    C2 --> D
    C3 --> D

    D -->|No| E["Merge blocked\nStatus check failed"]
    E --> F["Developer fixes\nbased on CI feedback"]
    F --> A

    D -->|Yes| G["PR ready for\nhuman review"]
    G --> H[Code Review]
    H -->|Passed| I[Merge to main]

    subgraph "Optional — runs in parallel or after merge"
        J[Integration Tests]
        K[E2E Tests]
        L[Performance Tests]
    end

    I --> J
    I --> K
    I --> L

    style E fill:#ffcccc
    style I fill:#ccffcc
# Example GitHub Actions workflow as a guard
name: PR Guard

on:
  pull_request:
    branches: [main, develop]

jobs:
  unit-test:
    name: Unit Tests
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup environment
        uses: actions/setup-go@v4  # or adjust to your language
        with:
          go-version: '1.22'

      - name: Install dependencies
        run: go mod download

      - name: Run linting
        run: golangci-lint run ./...

      - name: Run unit tests
        run: go test -race -timeout 120s ./...
        # -race: detect race conditions
        # -timeout: tests must not run longer than 2 minutes

      - name: Check coverage
        run: |
          go test -coverprofile=coverage.out ./...
          go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
            awk -F'%' '{if ($1 < 60) {print "Coverage " $1 "% below threshold"; exit 1}}'          
        # 60% threshold as a minimum, not a target

# Branch protection rules on GitHub:
# Settings → Branches → main → Require status checks to pass before merging
# → Add: "Unit Tests" as a required check
# → Enable: "Require branches to be up to date before merging"

Tests as Executable Documentation #

One of the most undervalued values of unit tests is their documentation value. Well-written tests explain to the next engineer — who may have no context about why a piece of logic was written that way — what should happen under various conditions.

// Bad documentation as tests
func TestDiscount(t *testing.T) {
    assert.Equal(t, 80, applyDiscount(100, "VIP"))
}

// Good documentation as tests
// The discount system follows these rules:
//   - VIP members get a 20% discount
//   - Regular members get a 5% discount
//   - Non-members get no discount
//   - Discounts don't apply to products in the 'no_discount' category
func TestApplyDiscount(t *testing.T) {
    t.Run("VIP member gets 20 percent discount", func(t *testing.T) {
        // VIP member rate is 20% per the loyalty program agreement
        basePrice := 100_000
        expectedPrice := 80_000 // 100.000 - 20%

        result := applyDiscount(basePrice, "VIP")

        assert.Equal(t, expectedPrice, result)
    })

    t.Run("regular member gets 5 percent discount", func(t *testing.T) {
        basePrice := 100_000
        expectedPrice := 95_000 // 100.000 - 5%

        result := applyDiscount(basePrice, "REGULAR")

        assert.Equal(t, expectedPrice, result)
    })

    t.Run("no discount for non member", func(t *testing.T) {
        // Non-members get no discount at all
        basePrice := 100_000

        result := applyDiscount(basePrice, "") // None → empty member type

        assert.Equal(t, basePrice, result)
    })

    t.Run("discount not applied to restricted products", func(t *testing.T) {
        // Certain products (e.g. gold, prepaid credit) must not be discounted
        // Compliance team requirement — see RFC-089
        basePrice := 100_000

        result := applyDiscount(basePrice, "VIP", "no_discount")

        assert.Equal(t, basePrice, result) // price unchanged even for VIP
    })
}

When engineers read these tests six months later, they immediately know: there’s a rule that no_discount category products are excluded, and there’s a reason for it (compliance). Without these descriptive tests, they’d have to dig through git history or ask someone who may no longer be on the team.


Building a Test-First Culture in the PR Context #

Tools and CI pipelines can force tests to exist, but can’t force them to be meaningful. A culture that encourages test-first — where tests are seen as an investment, not a burden — must be actively built.

Ways to build a test-first culture:

  1. Reviewers question meaningless tests
     → "Would this test fail if the discount logic changed?"
     → "Is there a test for the empty-input case?"
     This sends the signal that test quality matters as much
     as code quality.

  2. Bug fixes always come with a test reproducing the bug
     → Before the fix: write a test that fails due to the bug
     → After the fix: the test must pass
     → This test guards against the bug returning

  3. Normalize TDD for critical logic
     → Write the test first, then the implementation
     → This forces developers to think about behavior before implementation
     → Naturally produces more testable interfaces

  4. Make slow or flaky tests a team problem
     → Flaky tests = priority to fix, not to ignore
     → Slow tests = optimize, not skip
     → This keeps the CI pipeline trustworthy

  5. Celebrate when tests catch bugs in CI
     → "Our tests caught a regression before it reached production"
     is a success story worth sharing
     → This builds collective understanding of test value

Anti-Patterns to Avoid #

// ✗ Anti-pattern 1: tests without meaningful assertions
func TestCreateUser(t *testing.T) {
    user := CreateUser("[email protected]", "Ali")
    assert.NotNil(t, user) // ← always true if no exception
}

// ✓ Correct:
func TestCreateUserWithValidData(t *testing.T) {
    user := CreateUser("[email protected]", "Ali")
    assert.Equal(t, "[email protected]", user.Email)
    assert.Equal(t, "Ali", user.Name)
    assert.NotNil(t, user.ID)
    assert.NotNil(t, user.CreatedAt)
}

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 2: one test for all scenarios
func TestPayment(t *testing.T) {
    // Success test
    result := ProcessPayment(100000)
    assert.True(t, result.Success)

    // Failure test — in the same test!
    result = ProcessPayment(-1)
    assert.False(t, result.Success)

    // Edge case test
    result = ProcessPayment(0)
    assert.False(t, result.Success)
}

// ✓ Correct: one test, one scenario
func TestPaymentSucceedsWithValidAmount(t *testing.T) {
    result := ProcessPayment(100_000)
    assert.True(t, result.Success)
    assert.NotNil(t, result.TransactionID)
}

func TestPaymentFailsWithNegativeAmount(t *testing.T) {
    result := ProcessPayment(-1)
    assert.False(t, result.Success)
    assert.Equal(t, "INVALID_AMOUNT", result.Error)
}

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 3: tests depending on external state
func TestGetUser(t *testing.T) {
    // Depends on data that may or may not exist in the test database
    user := GetUserByEmail("[email protected]")
    assert.Equal(t, "Test User", user.Name)
}

// ✓ Correct: create the needed data within the test itself
func TestGetUserByEmail(t *testing.T) {
    // Arrange: create the needed user
    createdUser := CreateTestUser("[email protected]", "Test User")

    // Act
    foundUser := GetUserByEmail("[email protected]")

    // Assert
    assert.Equal(t, createdUser.ID, foundUser.ID)
    assert.Equal(t, "Test User", foundUser.Name)

    // Cleanup: remove the test data (or use transactional tests)
}

// ────────────────────────────────────────────────────────────────────────────

// ✗ Anti-pattern 4: skipping tests because "will fix later"
func TestCriticalPaymentLogic(t *testing.T) {
    t.Skip("TODO: fix this test")
}

// ✓ Correct: skipped tests must be a priority to fix
// If a test needs temporary skipping, create a ticket and include the ticket number
func TestConcurrentPayment(t *testing.T) {
    t.Skip("JIRA-789: flaky due to race condition in mock, fix this sprint")
}

Unit Test as Guard Checklist #

CI GUARD CONFIGURATION:
  □ CI pipeline runs automatically for every push to a PR branch
  □ Merges to the main branch are blocked if CI fails (branch protection rules)
  □ Unit tests run in a reasonable time (< 5 minutes for the entire suite)
  □ Linting and formatting run as part of the CI guard

TEST QUALITY:
  □ Every test has a meaningful assertion — can fail if behavior changes
  □ Every test focuses on one scenario (one test, one case)
  □ Tests use descriptive names (function name = what's tested)
  □ AAA pattern used: Arrange, Act, Assert
  □ Happy paths and main edge cases covered

  EDGE CASES THAT MUST BE CHECKED:
  □ Null/nil/empty input
  □ Boundary values
  □ Error paths and exception handling
  □ Concurrent access if relevant

DETERMINISM AND ISOLATION:
  □ Tests don't depend on real time (use mock clocks if needed)
  □ Tests don't depend on external database state (use in-memory or fixtures)
  □ Tests don't depend on execution order
  □ Tests don't leave state affecting other tests

WITHIN PRs:
  □ PRs changing business logic come with tests covering the change
  □ Bug fixes come with tests that fail before the fix and pass after
  □ Refactors: the entire test suite stays green (no behavior changes)
  □ No skipped tests without a reason and clear ticket number

Summary #

  • Unit tests as guards are shift-left quality — bugs caught at CI before code review are far cheaper than bugs found by reviewers, or worse, in production.
  • Tests and reviewers complement each other — reviewers can’t execute every scenario in their heads. Tests can. Both are needed: tests prove behavior, reviewers judge design.
  • Tests that can fail are more valuable than tests that always pass — tests without meaningful assertions, or only checking that functions don’t throw, protect nothing.
  • Coverage is an indicator, not a goal — 80% coverage with meaningless tests is more dangerous than 50% coverage with tests genuinely verifying critical behavior.
  • Critical paths must be covered with meaningful tests — business logic, security boundaries, critical error handling, and edge cases that could cause data inconsistency.
  • Tests must be fast and deterministic — slow or flaky guards will be worked around. Untrustworthy tests are worse than no tests.
  • One test, one scenario — tests testing many things at once are hard to read and hard to debug when failing. Make one test per scenario with descriptive names.
  • Tests are executable documentation — well-written tests explain to the next engineer what should happen and why, without needing to read the implementation code.
  • Bug fixes must always come with tests — write a test that fails due to the bug, fix the bug, make sure the test passes. This test guards against the bug returning.
  • Culture is built from code review — reviewers questioning test quality, not just its existence, build the collective understanding that meaningful tests are the standard, not optional.

← Previous: Code Review Ethics   Next: API: Fundamental →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact