DEV Community

DIEGO FABRIZIO ANDIA NAVARRO
DIEGO FABRIZIO ANDIA NAVARRO

Posted on

Choosing a Testing Orchestration Tool: A Loyalty-Discount Module Tested Across GitHub Actions, GitLab CI/CD, Jenkins, and CircleCI

Abstract

Picking a CI tool is often treated as a branding decision, when it should be an engineering one. This article compares GitHub Actions, GitLab CI/CD, Jenkins, and CircleCI as orchestrators for an automated test suite, using a small but realistic loyalty-discount calculator from a subscription e-commerce app as the shared example. The module and its eight tests were written and executed locally first — reaching 100% line, branch, and function coverage — before being wrapped in four equivalent pipeline definitions. The comparison looks at configuration shape, execution model, reuse mechanisms, and operational cost rather than marketing claims. The conclusion is unsurprising but easy to forget under tool-shopping pressure: the orchestration layer should stay thin and replaceable, while the actual safety net — the test suite — should stay portable across whichever platform sits on top of it.

Why "testing management" really means "test orchestration"

None of these four platforms write assertions or decide what "correct" behavior means. What they do is consistently answer one question for every change to a repository: did the existing checks still pass, and can everyone on the team see the answer without running anything locally? That single guarantee is what turns a test suite from a developer's personal habit into a team-wide quality gate.

The real-world example: a loyalty discount calculator

Picture a coffee-subscription app with three loyalty tiers:

  • New members get no discount.
  • Silver members (5+ previous orders) get 10% off.
  • Gold members (20+ previous orders) get 20% off.
  • The discount is capped at $50 regardless of order size.
  • Negative subtotals, unknown tiers, or an invalid order count must be rejected.
// loyaltyDiscount.js
const TIER_RULES = {
  new: { minOrders: 0, rate: 0 },
  silver: { minOrders: 5, rate: 0.10 },
  gold: { minOrders: 20, rate: 0.20 },
};

const MAX_DISCOUNT_VALUE = 50;

function calculateLoyaltyDiscount(subtotal, tier, ordersCount) {
  if (!Number.isFinite(subtotal) || subtotal < 0) {
    throw new TypeError("Subtotal must be a non-negative number");
  }
  if (!Object.hasOwn(TIER_RULES, tier)) {
    throw new RangeError("Tier must be one of: new, silver, gold");
  }
  if (!Number.isInteger(ordersCount) || ordersCount < 0) {
    throw new TypeError("ordersCount must be a non-negative integer");
  }

  const rule = TIER_RULES[tier];
  if (ordersCount < rule.minOrders) {
    throw new RangeError(
      `${ordersCount} previous orders is not enough for the "${tier}" tier`
    );
  }

  return Math.min(subtotal * rule.rate, MAX_DISCOUNT_VALUE);
}

module.exports = { calculateLoyaltyDiscount, MAX_DISCOUNT_VALUE };
Enter fullscreen mode Exit fullscreen mode

The risk here isn't exotic — it's the same kind of off-by-one boundary bug that quietly costs real money: a customer with exactly 5 orders should already be Silver, and a $1,000 Gold order should still cap out at a $50 discount, not $200.

One of the eight tests checks that cap directly:

test("discount is capped at $50 even for large orders", () => {
  assert.equal(calculateLoyaltyDiscount(1000, "gold", 25), MAX_DISCOUNT_VALUE);
});
Enter fullscreen mode Exit fullscreen mode

Running the suite locally with Node's built-in test runner gives an honest, unedited result:

$ npm test
ok 1 - new members receive no discount
ok 2 - silver members get 10% off once they have 5+ orders
ok 3 - gold members get 20% off once they have 20+ orders
ok 4 - discount is capped at $50 even for large orders
ok 5 - rejects a tier that doesn't have enough orders yet
ok 6 - rejects a negative subtotal
ok 7 - rejects an unknown tier
ok 8 - rejects a negative or non-integer ordersCount
# tests 8
# pass 8
# fail 0
Enter fullscreen mode Exit fullscreen mode

And the coverage run confirms the business logic is fully exercised, not just superficially tested:

$ npm run test:coverage
file                | line %  | branch % | funcs %
loyaltyDiscount.js  | 100.00  | 100.00   | 100.00
all files           | 100.00  | 100.00   | 100.00
Enter fullscreen mode Exit fullscreen mode

The complete project — module, tests, and all four pipeline files below — is packaged and ready to push as-is to a public GitHub or GitLab repository: [link to your repo here]

GitHub Actions

# .github/workflows/test.yml
name: Automated tests

on:
  push:
    branches: ["main"]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm run test:coverage
Enter fullscreen mode Exit fullscreen mode

The matrix runs the same eight tests on two Node.js versions with no extra code. Hosted runners mean nobody on the team maintains a build server, and the pull_request trigger surfaces failures before a merge, not after.

GitLab CI/CD

# .gitlab-ci.yml
stages:
  - test

test-node:
  stage: test
  image: node:22
  script:
    - npm run test:coverage
Enter fullscreen mode Exit fullscreen mode

Five lines is enough because the official Node image already has everything the suite needs. The advantage shows up once the project grows: stages, artifacts, environments, and security scanning all live in the same file and the same platform as the rest of the DevOps lifecycle.

Jenkins

// Jenkinsfile
pipeline {
  agent {
    docker { image 'node:22' }
  }
  stages {
    stage('Test') {
      steps {
        sh 'npm run test:coverage'
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Functionally identical output to the two pipelines above, but the agent runs wherever the team points it — a private network, specialized hardware, an on-prem cluster. That freedom is the entire reason Jenkins still exists in 2026: it doesn't assume your infrastructure looks like anyone else's.

CircleCI

# .circleci/config.yml
version: 2.1

orbs:
  node: circleci/node@5.1.0

jobs:
  test:
    docker:
      - image: cimg/node:22.0
    steps:
      - checkout
      - node/install-packages:
          pkg-manager: npm
      - run:
          name: Run tests with coverage
          command: npm run test:coverage

workflows:
  test-and-build:
    jobs:
      - test
Enter fullscreen mode Exit fullscreen mode

The node orb hides the boilerplate of caching node_modules, which is normally hand-rolled in the other three tools. CircleCI's strength is exactly this: a CI-first product, unattached to any single source-code host.

Comparison

Criterion GitHub Actions GitLab CI/CD Jenkins CircleCI
Config file .github/workflows/*.yml .gitlab-ci.yml Jenkinsfile .circleci/config.yml
Best fit Repos already on GitHub Repos already on GitLab Custom/self-managed infra Teams wanting dedicated cloud CI
Hosted runners Yes Yes Uncommon by default Yes
Reuse model Marketplace actions Includes/components Plugins/shared libraries Orbs
Setup effort Low Low Medium–high Low–medium
Who maintains the runner GitHub GitLab Your team CircleCI

Bitbucket Pipelines, TeamCity, Travis CI, Tekton, and Harness solve the same orchestration problem for different ecosystems — Bitbucket-hosted code, on-prem enterprise servers, Kubernetes-native pipelines, and delivery governance, respectively. None of them changes what's actually being tested.

What actually decides the right tool

  1. Where does the code already live? Native integration removes a layer of permissions and context-switching.
  2. Who owns the runners — a vendor, or your own team? Self-hosting isn't free just because the software is.
  3. How fast is the feedback loop, including queue time and caching?
  4. Can the pipeline reproduce the constraints of production (containers, private networks, compliance)?
  5. Is the test command itself portable? npm run test:coverage works identically in all four pipelines above — that's not an accident, it's the design goal.

Conclusion

For this module, GitHub Actions is the lowest-friction choice simply because the repository already lives on GitHub — not because it is objectively "better" than the alternatives. The real takeaway is structural: keep business logic tests behind one portable command, and treat the CI configuration as a thin, swappable layer on top of it. A team that does this can move from GitHub Actions to GitLab CI/CD, Jenkins, or CircleCI in an afternoon, because the only thing that ever changes is who presses "run."

Top comments (0)