Quality Gates
What Is a Quality Gate?
A quality gate is a checkpoint in your pipeline that blocks progression if criteria are not met. It is the automated equivalent of a human approver, but faster, more consistent, and impossible to forget.
A gate that can be bypassed is not a gate. Configure gates as required checks on your main branch protection rules.
Common Quality Gates
All Tests Pass
The most basic and most important gate. A single test failure blocks the merge or deploy.
# GitHub branch protection: Require status checks to pass
# Settings > Branches > Branch protection rules > Require status checks
# Select: "unit-tests", "integration-tests", "browser-tests"
Why this gate matters: If you allow merging with failing tests, developers quickly learn that test failures are ignorable. Within weeks, nobody trusts the test suite, and the pipeline becomes decoration.
Code Coverage Threshold
Require that coverage does not drop below a baseline.
# Using a coverage check action
- uses: codecov/codecov-action@v4
with:
fail_ci_if_error: true
flags: unittests
# Or enforce in the test command itself
- run: npx jest --coverage --coverageThreshold='{"global":{"branches":75,"functions":80,"lines":80}}'
Better approach: Rather than enforcing an absolute threshold (e.g., "80% overall"), require that new code is covered. A legacy codebase at 60% coverage should not block PRs that add well-tested new features.
# Codecov configuration (codecov.yml)
coverage:
status:
patch:
default:
target: 90% # New code must be 90% covered
project:
default:
target: auto # Overall coverage must not decrease
Security Scanning
Integrate SAST (Static Application Security Testing) and DAST (Dynamic Application Security Testing) scanners and fail on findings above a threshold.
# Example: Snyk security scan
- uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high # Fail on high/critical vulnerabilities
Gate levels:
- Critical/High vulnerabilities: Block the merge. These must be fixed before shipping.
- Medium vulnerabilities: Warn but allow merge. Create a follow-up ticket.
- Low vulnerabilities: Informational only. Track but do not block.
Performance Budgets
Enforce performance standards to prevent gradual degradation.
# Lighthouse CI
- run: npx lhci autorun
env:
LHCI_BUILD_CONTEXT__CURRENT_HASH: ${{ github.sha }}
# lighthouse-ci configuration
# lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.95 }],
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
},
},
},
};
Other performance gates:
- Bundle size limits: Fail if the JavaScript bundle exceeds a threshold
- API response time p95: Fail if the 95th percentile response time exceeds the budget
- Image optimization: Fail if unoptimized images are added
Lint and Format Checks
Enforce code style so reviews focus on logic, not formatting.
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint # ESLint
- run: npm run format:check # Prettier --check
- run: npm run typecheck # tsc --noEmit
Why gate on formatting: Without automated enforcement, code reviews devolve into arguments about semicolons and indentation. Automate the trivial decisions so humans focus on architecture and logic.
Configuring Gates in GitHub
Branch Protection Rules
- Go to Settings > Branches > Branch protection rules
- Add a rule for
main - Enable "Require status checks to pass before merging"
- Select the specific checks that must pass (e.g.,
unit-tests,lint,security-scan) - Enable "Require branches to be up to date before merging" (ensures the PR is tested against the latest main)
- Enable "Require pull request reviews before merging" (human gate)
Required vs Optional Checks
Not every check needs to be required. Use required checks for gates that must never be bypassed, and optional checks for informational feedback.
| Check | Required? | Rationale |
|---|---|---|
| Unit tests | Yes | Broken logic must not reach main |
| Lint / format | Yes | Consistent code style is non-negotiable |
| Integration tests | Yes | API and database issues must be caught |
| Browser tests | Yes (on main) | UI regressions must be caught before deploy |
| Coverage (patch) | Yes | New code must be tested |
| Coverage (overall) | No | Legacy code coverage should improve over time, not block PRs |
| Security scan | Yes (high/critical) | Critical vulnerabilities must not ship |
| Performance budget | No (initially) | Start as informational, promote to required once budgets are stable |
Common Pipeline Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Tests only run on main | Bugs discovered after merge, harder to fix | Run tests on every PR |
| No artifact collection | Failures require re-running the entire pipeline to debug | Always upload reports, screenshots, logs |
| Hardcoded secrets | Credentials in YAML files visible in repo history | Use platform secret stores |
| Monolithic pipeline | One job runs everything sequentially for 40 minutes | Split into parallel jobs with dependencies |
| Ignoring flaky tests | Tests are allow_failure: true and nobody investigates |
Quarantine flaky tests, track and fix them |
| No caching | Every run installs dependencies from scratch | Cache based on lockfile hash |
| Bypassable gates | "Admin override" used routinely instead of exceptionally | Require admin approval for overrides; track bypass frequency |
| Too many gates | Every PR triggers 45 minutes of checks | Tier gates by trigger: fast on push, thorough on PR, full on merge |
Pipeline Configuration Tips for QA
Environment Variables for Test Configuration
env:
TEST_TIMEOUT: 30000 # Per-test timeout in milliseconds
RETRY_COUNT: 2 # Retries for infrastructure flakes
HEADLESS: true # Headless browser mode
SLOW_MO: 0 # No artificial delay
WORKERS: 4 # Parallel test workers
CI: true # Signal to test framework that we are in CI
Retry Logic for Flaky Infrastructure
Retries should handle infrastructure flakes (network timeouts, container startup delays), not mask test bugs.
- run: npx playwright test --retries=2
timeout-minutes: 30
continue-on-error: false
If a test consistently needs retries, quarantine it and fix the root cause.
Notifications
Send Slack/Teams messages on pipeline failure so the team responds quickly:
notify:
needs: [unit-tests, browser-tests]
if: failure()
runs-on: ubuntu-latest
steps:
- uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Pipeline failed on ${{ github.ref_name }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Hands-On Exercise
- List all the quality gates currently in your pipeline. Are any missing from the common gates above?
- Check your branch protection rules. Are all critical checks marked as required?
- Intentionally try to merge a PR with a failing required check. Verify it is blocked.
- Add a coverage gate that requires new code to be at least 80% covered
- Add a security scan and configure it to fail on high-severity findings
- Set up failure notifications to your team's Slack channel
Interview Talking Point: "I treat the CI/CD pipeline as part of the test infrastructure, not just a deployment tool. I structure pipelines with unit tests as a fast gate on every push, integration tests on PRs, and full browser suites before deploy -- each stage providing progressively deeper confidence. I optimize with dependency caching, test sharding across parallel runners, and selective execution based on changed paths. When a pipeline takes more than 15 minutes for the PR feedback loop, I treat that as a bug to fix. I configure quality gates as required checks on branch protection rules -- all tests pass, coverage does not decrease on new code, no critical security vulnerabilities -- so the pipeline is a reliable safety net, not just decoration."