DEV Community

Cover image for Your duplication report says 0%. Your Cucumber steps disagree.
Luiz Figueiredo
Luiz Figueiredo

Posted on

Your duplication report says 0%. Your Cucumber steps disagree.

Every Cucumber suite I've reviewed grows the same way. A new scenario needs a step, nobody is quite sure whether a matching one already exists, and so a slightly different one gets added. Multiply that by a few engineers and a few months, and the step library quietly fills up with near-copies.

After years of pointing this out in code reviews, I built a tool to do it for me. This post covers why I built CukeDedup, what it checks, and what running it against 92 open-source repositories taught me about my own assumptions.

Three ways to log in

Here's a pattern I've repeatedly encountered:

Given('the user is logged in', async () => {
  await login();
});

Given('the user is authenticated', async () => {
  await login();
});

Given('an authenticated user', async () => {
  await login();
});
Enter fullscreen mode Exit fullscreen mode

All three can work. A generic copy-paste detector might report no duplicated blocks. And yet, do you really need three of them?

Meet CukeDedup

CukeDedup is an open-source static analyzer that finds duplicate, ambiguous, reusable and unused Cucumber step definitions. No config file, one command:

npx cuke-dedup .
Enter fullscreen mode Exit fullscreen mode

Running npx cuke-dedup . on a small project: two duplicate-handler errors, one parameterization candidate and one unused step definition

It works with Cucumber.js, Playwright-BDD, Cypress Cucumber, Jest-Cucumber and the Vitest Cucumber Plugin, and reads step definitions written in JavaScript or TypeScript.

Why I built it

As a frontend lead, I helped introduce end-to-end testing. We picked Cucumber and Gherkin on purpose: I wanted QA and product people to read, write and argue about scenarios without touching the automation code.

That collaboration model worked in my experience. But an old problem remained: writing a new test is easy, keeping a large suite organized is not.

People pick up Playwright or Cypress quickly. What takes time is knowing what already exists. Before adding a step, you need to know whether a similar one is already there, or whether two existing ones could become one parameterized step. Experienced engineers can often spot this quickly. Someone new to the codebase may not, so the first time they hear about it is in code review.

As the reviewer, that's where my time went. I'm a big believer in linters for exactly this reason: if a rule matters, a tool should check it before the pull request, not a person after it. It's faster for the author, less tedious for the reviewer, and rules stop depending on who happens to review.

Code duplication is not behavior duplication

On one project I combined ESLint, Danger.js and jscpd. jscpd was great at catching copy-pasted code. It could not catch this:

When('I create a red project', async () => {
  await createProject('red');
});

When('I create a blue project', async () => {
  await createProject('blue');
});
Enter fullscreen mode Exit fullscreen mode

which might be better expressed as:

When('I create a {word} project', async color => {
  await createProject(color);
});
Enter fullscreen mode Exit fullscreen mode

The opposite happens too: two steps can read almost the same and still mean different things in your domain. Generic duplicate detectors look at code. I needed something that looks at both the step text and the implementation behind it.

It started as a pile of scripts on my machine. When I joined the effort to build a new E2E setup at my current job, I knew I'd hit the same problems again, so I turned the scripts into a proper tool. I wanted it open source from day one, and I wanted to finally build something non-trivial in Rust: a tool you run all the time has to be fast and light on memory.

Three things I cared about

1. Zero config. jscpd gets this right: you type jscpd . and you learn something. Nobody looking for duplicates wants to learn a new configuration format first. CukeDedup respects .gitignore, recognizes common project layouts and the Cucumber.js, Playwright-BDD and Cypress configs. Configuration exists for exceptions, not as an entry fee.

2. Evidence, not verdicts. "These two look similar" isn't useful. Findings include source locations, relevant matcher or handler evidence, and a suggested action when applicable. The decision stays with you: similar code can represent different domain concepts, and not every abstraction is a good one. To be clear, it doesn't understand English. It doesn't know logged in means authenticated; it can report that their handlers are equivalent under its supported static comparison rules.

3. Saying "I don't know." Static analysis can't resolve everything: dynamic config, wrappers, imports it can't follow. When that happens CukeDedup marks the run as incomplete instead of reporting a clean result. Zero findings is not zero problems, and --fail-on-incomplete lets CI treat that difference seriously.

The day-to-day loop is the same command again:

A git diff merging the red and blue project steps into one {word} step, then cuke-dedup . running again with 0 findings

What 92 open-source repos taught me

A tool tested only on examples you wrote yourself will always look correct. So I ran it against 92 public JavaScript/TypeScript repositories that use Cucumber-style tests. This was not a representative sample of the ecosystem; it was to meet code I'd never seen. Many of the most useful discoveries were limitations in CukeDedup itself, rather than confirmed defects in the projects I scanned.

  • Consistent naming looks like duplication. In one project, every near-duplicate-step finding had matcher similarity above 0.90, because the team used a tidy naming convention for steps acting on different elements. Text similarity alone wasn't enough, so I raised the required handler similarity from 0.50 to 0.70 (configurable). Findings for that rule dropped by about half in the measured corpus, while the expected cases in the regression suite continued to pass. That is a reduction in findings, not a measured 50% improvement in precision.
  • Erasing literals makes unrelated code look alike. Tiny handlers that just call cy.log(...) were being suggested as parameterization candidates. Requiring compatible matchers, no negation conflict (not, un-…) and a single contiguous difference reduced that rule's findings by about 40% in the measured corpus, with expected regression cases preserved. I don't want a tool that pushes abstraction for its own sake.
  • "Ambiguous" was really a scope problem. Two projects produced 2,263 and 1,563 ambiguous-step findings when scanned at a root that combined separate runtime suites. They ran their suites as separate runtime units; I was analyzing them as one registry. When the affected suites were scanned within their actual runtime boundaries, those cross-suite ambiguities disappeared. The fix wasn't an algorithm, it was understanding how the tests actually run.
  • A bundled jQuery made it hang for 20 minutes. File size wasn't the cause: an 89 KB real file hung while a 1 MB synthetic one finished in seconds. The culprit was superlinear work in the analysis itself. After the algorithmic fix, that jQuery input took 0.6 seconds in the recorded measurement. Separately, files excluded by the generated-content filter are reported, and the run is marked incomplete.

When an issue can be isolated, I try to turn it into a minimal regression case in a corpus of expected behavior, with both "must find" and "must not find" cases. It describes what the tool should do rather than freezing whatever it currently does, and I deliberately break the code to check that each test actually fails. A test that stays green when you break things isn't protecting anything.

Using it on a real project

Install from npm (prebuilt native binaries) or build from source with Cargo:

npm install --save-dev cuke-dedup
# or
cargo install cuke-dedup
Enter fullscreen mode Exit fullscreen mode

Reports come as terminal output, HTML (searchable, side-by-side comparison), JSON, JSONL and SARIF for GitHub code scanning.

CukeDedup HTML report showing two step definitions side by side, with the differing words highlighted and the same login handler

Legacy suites: start with a baseline. Nobody can clean up years of duplication before adopting a tool. Record what exists today, then configure the gate to fail on new findings:

cuke-dedup . --baseline baseline.json --update-baseline
cuke-dedup . --baseline baseline.json --fail-on-new
Enter fullscreen mode Exit fullscreen mode

Recording a baseline, adding one new step, then cuke-dedup with --fail-on-new reporting only the new duplicate

The first goal isn't paying off the debt. It's not adding to it.

In CI, there's a GitHub Action that downloads the native binary and verifies its checksum and build provenance before running:

# .github/workflows/cuke-dedup.yml
name: CukeDedup
on: pull_request

permissions:
  contents: read

jobs:
  cuke-dedup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          persist-credentials: false
      - uses: figueiredoluiz/cuke-dedup@v0.12.0
        with:
          threshold: 5
Enter fullscreen mode Exit fullscreen mode

What about AI agents?

Coding agents are now good at writing tests and proposing refactors. That makes cheap, deterministic guardrails more valuable, not less. There's no reason to have an agent read hundreds of files to find a duplicate the static analyzer can identify without invoking a model. Let the tool produce the evidence and let the agent spend its effort on the judgment calls.

CukeDedup can stream findings as JSONL, and ships an Agent Skill that tells compatible agents to treat findings as evidence to investigate, not as orders to merge everything that looks similar:

npx skills add figueiredoluiz/cuke-dedup --skill cuke-dedup
Enter fullscreen mode Exit fullscreen mode

What's next

At the time of writing, CukeDedup is at v0.12.0. JavaScript and TypeScript are the main focus, and Cucumber-Ruby support is available as an experimental opt-in. Next up: more frameworks, possibly Java and PHP, fewer false positives, and publishing what I learn from scanning open-source suites.

I'd love for this to become a community project rather than just mine. False-positive reports, step-definition patterns it doesn't understand yet, feature ideas and pull requests are all welcome. Even a case where it gets things wrong helps.

I'm curious: how does your team keep step definitions from multiplying? Naming conventions, review checklists, a shared steps library, or just living with it? Tell me in the comments.

CukeDedup is an independent project, not affiliated with or endorsed by the Cucumber project.

Top comments (0)