QA How-To
Split Cypress Specs by Historical Duration
Learn to cypress split specs by historical duration with a runnable Node.js sharding script, CI matrix setup, timing updates, and imbalance checks in CI.
19 min read | 2,523 words
TL;DR
Collect each Cypress spec's elapsed time, sort specs from slowest to fastest, and repeatedly assign the next spec to the lightest CI shard. Commit or cache the resulting timing data, generate one manifest per runner, and invoke Cypress with the manifest's comma-separated spec list.
Key Takeaways
- Store one rolling duration per Cypress spec instead of splitting only by file count.
- Use longest-processing-time scheduling to place each slow spec on the currently lightest shard.
- Generate deterministic shard manifests before starting parallel CI jobs.
- Pass each manifest to cypress run through the supported --spec option.
- Merge fresh results carefully so failed or missing specs do not erase useful history.
- Measure estimated and actual shard spread to catch stale timing data and newly slow tests.
To cypress split specs by historical duration, record how long every spec took in earlier runs, then distribute the slowest specs one at a time to the currently lightest CI runner. This produces much better balance than dividing by file count when a five-minute checkout spec and a twenty-second settings spec would otherwise count as equals.
This tutorial builds that workflow with Cypress, TypeScript, and a small Node.js script you own. It fits into the broader Cypress modern test architecture complete guide, which explains where sharding, isolation, selectors, and CI orchestration belong in a maintainable test system.
You will create deterministic shard manifests, run one shard locally or in a CI matrix, update timing history, and detect imbalance before it silently makes the pipeline slow again. The approach is provider-neutral and uses Cypress's supported --spec CLI input rather than private runner internals.
What You Will Build
By the end, your repository will contain a small, auditable duration-based scheduler. You will be able to:
- discover Cypress end-to-end spec files from the filesystem;
- read historical milliseconds from a versioned JSON file;
- assign specs to any requested number of shards using longest-processing-time scheduling;
- write one JSON manifest per shard with files and estimated duration;
- run a selected manifest locally and in GitHub Actions;
- merge fresh measurements and report how evenly the shards finished.
The scheduler is intentionally independent of a CI vendor. GitHub Actions is the concrete example, but GitLab CI, CircleCI, Jenkins, and Buildkite can consume the same manifest files.
Prerequisites
Use Node.js 20 or 22 LTS, npm, Git, and Cypress 13 or newer. You need an existing Cypress end-to-end project whose specs match cypress/e2e/**/*.cy.{js,jsx,ts,tsx}. Install the packages used by the scripts:
npm install --save-dev cypress typescript tsx fast-glob
Add at least four specs so multiple shards have useful work. Confirm the suite can run without sharding:
npx cypress run
The examples use npm scripts and POSIX shell syntax in CI. PowerShell users can call the same TypeScript scripts with explicit arguments. Do not begin with timing optimization while basic tests are failing because of shared state. If tests depend on execution order or a previous user's session, first apply the patterns in Cypress test isolation for multi-user workflows.
Step 1: Define the Historical Duration File
Create cypress/timings/spec-durations.json. Keys are repository-relative spec paths, and values are elapsed milliseconds. Start with measured values if you have them. Otherwise use an empty object and let the fallback estimate handle the first run.
{
"cypress/e2e/auth/login.cy.ts": 48200,
"cypress/e2e/checkout/checkout.cy.ts": 186400,
"cypress/e2e/profile/settings.cy.ts": 61700,
"cypress/e2e/search/search.cy.ts": 93400
}
Use milliseconds throughout the workflow. A single unit avoids conversion mistakes, and integers keep diffs readable. Normalize every path to forward slashes so history written on Windows matches discovery on Linux. Do not put branch-specific absolute paths in this file.
Treat these numbers as scheduling estimates, not performance assertions. Browser startup, retries, network latency, and runner contention make individual executions noisy. A rolling estimate is useful even when it is not exact.
Verify the step: run node -e "const t=require('./cypress/timings/spec-durations.json'); console.log(Object.keys(t).length)". The command should print the number of recorded specs, such as 4, without a JSON parse error.
Step 2: Implement the Cypress Split Specs by Historical Duration Algorithm
Create scripts/cypress/split-by-duration.ts. This uses longest-processing-time scheduling: sort descending by estimated duration, then place each file on the shard with the smallest current total. The greedy algorithm is simple, deterministic, and effective for test scheduling.
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import fg from 'fast-glob';
type Timings = Record<string, number>;
type Shard = { index: number; estimatedMs: number; specs: string[] };
const shardCount = Number(process.argv[2] ?? '2');
if (!Number.isInteger(shardCount) || shardCount < 1) {
throw new Error('Shard count must be a positive integer');
}
const timingPath = 'cypress/timings/spec-durations.json';
const outputDir = 'cypress/shards';
const discovered = (await fg('cypress/e2e/**/*.cy.{js,jsx,ts,tsx}'))
.map((file) => file.split(path.sep).join('/'))
.sort();
if (discovered.length === 0) {
throw new Error('No Cypress specs found');
}
let timings: Timings = {};
try {
timings = JSON.parse(await readFile(timingPath, 'utf8')) as Timings;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const known = Object.values(timings).filter(
(value) => Number.isFinite(value) && value > 0,
);
const fallbackMs = known.length
? Math.round(known.reduce((sum, value) => sum + value, 0) / known.length)
: 60_000;
const estimates = discovered
.map((spec) => ({
spec,
durationMs:
Number.isFinite(timings[spec]) && timings[spec] > 0
? Math.round(timings[spec])
: fallbackMs,
}))
.sort((a, b) => b.durationMs - a.durationMs || a.spec.localeCompare(b.spec));
const shards: Shard[] = Array.from({ length: shardCount }, (_, index) => ({
index,
estimatedMs: 0,
specs: [],
}));
for (const item of estimates) {
const target = [...shards].sort(
(a, b) => a.estimatedMs - b.estimatedMs || a.index - b.index,
)[0];
target.specs.push(item.spec);
target.estimatedMs += item.durationMs;
}
await mkdir(outputDir, { recursive: true });
for (const shard of shards) {
shard.specs.sort();
await writeFile(
`${outputDir}/shard-${shard.index}.json`,
`${JSON.stringify(shard, null, 2)}\n`,
);
}
console.table(
shards.map((shard) => ({
shard: shard.index,
specs: shard.specs.length,
estimatedSeconds: Math.round(shard.estimatedMs / 1000),
})),
);
The average of known durations becomes the estimate for a newly discovered spec. This prevents an unknown file from being treated as free work. Alphabetic tie breakers and stable shard indexes make the output repeatable.
Verify the step: run npx tsx scripts/cypress/split-by-duration.ts 2. You should see a two-row table and two files under cypress/shards/. Open both manifests and confirm every discovered spec appears exactly once.
Step 3: Validate the Shard Manifests
A scheduler must fail early if a manifest loses or duplicates a spec. Create scripts/cypress/validate-shards.ts so CI checks the generated plan before expensive browser jobs start.
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import fg from 'fast-glob';
type Shard = { index: number; estimatedMs: number; specs: string[] };
const expected = (await fg('cypress/e2e/**/*.cy.{js,jsx,ts,tsx}'))
.map((file) => file.split(path.sep).join('/'))
.sort();
const manifestFiles = (await fg('cypress/shards/shard-*.json')).sort();
if (manifestFiles.length === 0) throw new Error('No shard manifests found');
const shards = await Promise.all(
manifestFiles.map(async (file) =>
JSON.parse(await readFile(file, 'utf8')) as Shard,
),
);
const assigned = shards.flatMap((shard) => shard.specs).sort();
const duplicates = assigned.filter((spec, index) => assigned[index - 1] === spec);
const missing = expected.filter((spec) => !assigned.includes(spec));
const unexpected = assigned.filter((spec) => !expected.includes(spec));
if (duplicates.length || missing.length || unexpected.length) {
throw new Error(JSON.stringify({ duplicates, missing, unexpected }, null, 2));
}
console.log(`Validated ${assigned.length} specs across ${shards.length} shards`);
This coverage check is separate from the balancing algorithm on purpose. If someone later changes glob patterns or output formatting, the invariant remains explicit: the union of manifests must equal the current spec set, with no duplicates.
Verify the step: run npx tsx scripts/cypress/validate-shards.ts. Expect Validated 4 specs across 2 shards, adjusted for your suite. Temporarily copy one spec into both manifests and confirm validation fails, then regenerate clean manifests.
Step 4: Run One Cypress Shard Locally
Create scripts/cypress/run-shard.ts. It reads a generated manifest and invokes the local Cypress binary through npx, avoiding shell interpolation of filenames.
import { readFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
type Shard = { index: number; estimatedMs: number; specs: string[] };
const index = Number(process.argv[2] ?? '0');
if (!Number.isInteger(index) || index < 0) {
throw new Error('Shard index must be a non-negative integer');
}
const manifest = JSON.parse(
await readFile(`cypress/shards/shard-${index}.json`, 'utf8'),
) as Shard;
if (manifest.specs.length === 0) {
console.log(`Shard ${index} has no specs`);
process.exit(0);
}
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(
command,
['cypress', 'run', '--spec', manifest.specs.join(',')],
{ stdio: 'inherit', env: process.env },
);
child.on('error', (error) => { throw error; });
child.on('exit', (code, signal) => {
if (signal) {
console.error(`Cypress terminated by ${signal}`);
process.exit(1);
}
process.exit(code ?? 1);
});
Cypress accepts a comma-separated glob or list through --spec. Passing the argument array directly to spawn avoids quoting bugs that occur when a shell command is assembled as one string. Empty shards exit successfully, which matters when the shard count exceeds the spec count.
Add convenient commands to the existing scripts object in package.json:
{
"scripts": {
"cy:split": "tsx scripts/cypress/split-by-duration.ts",
"cy:validate-shards": "tsx scripts/cypress/validate-shards.ts",
"cy:shard": "tsx scripts/cypress/run-shard.ts"
}
}
Verify the step: run npm run cy:split -- 2, then npm run cy:shard -- 0. Cypress should list only the specs found in shard-0.json, and the process exit code should match the test result.
Step 5: Generate and Consume Shards in GitHub Actions
Generate manifests once, upload them as an artifact, and let a matrix run each index. The example uses four runners and current major versions of the official checkout, setup-node, and artifact actions.
name: Cypress duration shards
on:
pull_request:
push:
branches: [main]
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run cy:split -- 4
- run: npm run cy:validate-shards
- uses: actions/upload-artifact@v4
with:
name: cypress-shards
path: cypress/shards
cypress:
needs: plan
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- uses: actions/download-artifact@v4
with:
name: cypress-shards
path: cypress/shards
- run: npm run cy:shard -- ${{ matrix.shard }}
All matrix jobs receive the same plan. Set fail-fast: false so one functional failure does not cancel other shards and destroy their timing signal. In a real app, start the server before cy:shard or use your existing start-and-wait command.
Verify the step: open the Actions run and inspect the four Cypress jobs. Each should download the artifact, log a different manifest index, and execute a disjoint list. The plan job must fail before the matrix if validation detects missing or duplicate specs.
Step 6: Collect Fresh Historical Timing Data
The most portable collection method is to time each spec invocation separately. It adds Cypress startup overhead per file, so use it in a scheduled calibration job rather than every pull request. Create scripts/cypress/measure-specs.ts:
import { mkdir, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { performance } from 'node:perf_hooks';
import path from 'node:path';
import fg from 'fast-glob';
const specs = (await fg('cypress/e2e/**/*.cy.{js,jsx,ts,tsx}'))
.map((file) => file.split(path.sep).join('/'))
.sort();
const results: Record<string, number> = {};
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
for (const spec of specs) {
const started = performance.now();
const exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn(command, ['cypress', 'run', '--spec', spec], {
stdio: 'inherit',
env: process.env,
});
child.on('error', reject);
child.on('exit', (code) => resolve(code ?? 1));
});
results[spec] = Math.round(performance.now() - started);
if (exitCode !== 0) console.warn(`${spec} failed with exit code ${exitCode}`);
}
await mkdir('cypress/timings', { recursive: true });
await writeFile(
'cypress/timings/latest-spec-durations.json',
`${JSON.stringify(results, null, 2)}\n`,
);
This records failed specs too because failure paths consume CI capacity and should influence scheduling. It does not replace the stable history immediately. Review a calibration artifact before merging it, particularly when infrastructure was degraded.
For large suites, capture per-spec results from your reporter in each normal shard and merge artifacts later. Reporter event formats differ, so keep that adapter isolated from the scheduler. The partitioner only needs a path-to-milliseconds map.
Verify the step: run npx tsx scripts/cypress/measure-specs.ts. Confirm latest-spec-durations.json contains every discovered spec and positive integer values. Compare one value with the elapsed time Cypress printed; small differences from process startup and shutdown are expected.
Step 7: Smooth Updates and Check Actual Balance
One anomalous run should not completely rewrite scheduling history. Merge old and new values with an exponentially weighted moving average. Create scripts/cypress/merge-timings.ts:
import { readFile, writeFile } from 'node:fs/promises';
type Timings = Record<string, number>;
const stablePath = 'cypress/timings/spec-durations.json';
const latestPath = 'cypress/timings/latest-spec-durations.json';
const oldData = JSON.parse(await readFile(stablePath, 'utf8')) as Timings;
const newData = JSON.parse(await readFile(latestPath, 'utf8')) as Timings;
const alpha = 0.3;
const merged: Timings = {};
for (const spec of Object.keys(newData).sort()) {
const fresh = newData[spec];
if (!Number.isFinite(fresh) || fresh <= 0) continue;
const previous = oldData[spec];
merged[spec] = Number.isFinite(previous) && previous > 0
? Math.round(alpha * fresh + (1 - alpha) * previous)
: Math.round(fresh);
}
await writeFile(stablePath, `${JSON.stringify(merged, null, 2)}\n`);
console.log(`Updated ${Object.keys(merged).length} timing records`);
An alpha of 0.3 lets recent behavior influence the plan without letting one noisy run dominate it. Adjust it deliberately: a larger alpha reacts faster, while a smaller alpha is steadier. Because the loop uses only newly discovered paths, deleted specs disappear from the stable file.
Track balance as slowest shard duration / average shard duration. A perfect result is 1. Do not enforce an unrealistically tight threshold because CI noise is normal. Start by reporting the ratio, inspect repeated regressions, then choose a project-specific alert threshold based on observed runs.
Verify the step: copy the latest measurement file, run npx tsx scripts/cypress/merge-timings.ts, and inspect the diff. Existing values should move 30 percent toward the fresh values, new specs should take their fresh values, and deleted paths should be absent. Regenerate shards and confirm their estimated totals remain reasonably close.
Cypress Split Specs by Historical Duration: Approach Comparison
Duration-based sharding is useful when spec runtimes vary significantly, but it is not the only option. Choose based on suite size, available infrastructure, and how much scheduling code your team wants to own.
| Approach | Balancing quality | Setup effort | Best fit | Main limitation |
|---|---|---|---|---|
| Equal file count | Low when durations vary | Low | Small, uniform suites | Treats every spec as equal |
| Historical duration script | High with fresh history | Medium | Provider-neutral CI and owned tooling | You maintain timing collection |
| Test-count split | Medium | Low to medium | Specs with similar test cost | Test counts ignore different workflows |
| Managed orchestration | High | Medium | Teams wanting hosted coordination and analytics | Depends on service configuration |
Do not confuse parallel execution with balanced execution. Four runners can still take almost as long as one slow shard if the longest specs land together. The goal is to reduce the makespan, which is the time until the final shard finishes.
Also remember the indivisible-work limit. If one spec takes twelve minutes, no file-level scheduler can make a shard shorter than twelve minutes. Split that spec along cohesive workflow boundaries, but preserve isolation and readability. Avoid creating tiny files solely to manipulate the scheduler.
Troubleshooting
Problem: A spec is executed twice -> Run the validation script and inspect glob overlap. Generate manifests from one canonical discovery pattern, and never append a second hand-written spec list in the CI command.
Problem: A new spec always lands on the wrong shard -> Confirm its normalized repository-relative path matches the history key. Until it has history, the scheduler uses the known-duration average. Run calibration or feed reporter data back after the first execution.
Problem: Estimated totals look equal but actual jobs do not -> Compare runner startup, application boot, downloads, retries, and network calls. Spec duration data cannot balance setup work that differs between jobs. Use identical runner images and cache policy before tuning the algorithm.
Problem: Cypress reports that no spec files were found -> Print the manifest in the job and check its working directory. The paths must be relative to the repository root where Cypress starts, and the --spec value must be passed as one comma-separated argument.
Problem: Timing history changes wildly on every run -> Collect several representative runs and lower the moving-average alpha. Separate unusually constrained scheduled runners from normal pull-request runners, and do not merge measurements from a known infrastructure incident.
Problem: One shard remains much slower -> Find its longest individual spec. If that file dominates the whole shard, split it by independent business workflows or move to finer-grained managed orchestration. Historical file-level balancing cannot divide a single spec.
Where To Go Next
Place duration sharding inside a complete architecture rather than treating it as a standalone trick. Use the Cypress modern test architecture guide to align configuration, isolation, data, reporting, and CI boundaries.
Next, strengthen the suite with Cypress test isolation for multi-user workflows. Independent specs can move between shards safely, while order-dependent specs make any scheduler unreliable. Improve locator abstractions with the Cypress query commands TypeScript tutorial, especially before splitting large specs into smaller workflow files.
If your team prefers hosted coordination, compare this owned scheduler with Cypress Cloud Smart Orchestration setup. Cloud orchestration can coordinate recorded runs dynamically, while the script in this tutorial stays portable and transparent. For broader pipeline design, review CI/CD testing strategy for QA teams and decide where calibration, pull-request shards, and full regression runs belong.
Interview Questions and Answers
Q: Why split Cypress specs by historical duration instead of file count?
File count assumes each spec costs the same. Historical duration estimates the work each runner receives, so a slow checkout spec can be balanced against several short specs. The slowest runner then tends to finish closer to the others.
Q: What algorithm works well for duration-based Cypress sharding?
Use longest-processing-time scheduling. Sort specs from slowest to fastest and assign each one to the shard with the smallest estimated total. It is deterministic, inexpensive, and typically produces a practical balance without a complex optimizer.
Q: How should a scheduler handle a new spec with no timing history?
Give it a nonzero fallback such as the average or median known spec duration. Treating it as zero overloads a shard with unknown work. Replace the fallback after its first representative measurement.
Q: Should failed spec durations be recorded?
Usually yes, because failed execution still occupies a runner and affects pipeline completion time. Keep the timing separate from pass or fail status, and reject measurements only when infrastructure made them unrepresentative.
Q: Why normalize spec paths?
Windows and Linux use different native separators. Normalizing repository-relative paths to forward slashes prevents a timing key collected on one platform from appearing unknown on another. It also creates stable JSON diffs.
Q: When is file-level duration sharding insufficient?
It is insufficient when one spec is much slower than the target shard time. A file is indivisible to this scheduler, so split the spec by independent workflows or use a system that schedules at a finer granularity.
Q: How do you prove that a shard plan is correct?
Compare the union of assigned specs with fresh filesystem discovery. Fail if any expected spec is missing, duplicated, or unexpected. Then report estimated totals and compare them with actual job durations over time.
Best Practices and Common Mistakes
- Do commit the small stable timing map when transparent review matters, or restore it from a reliable shared cache before planning.
- Do generate every shard from the same commit and discovery pattern.
- Do retain deterministic tie breakers so identical inputs produce identical manifests.
- Do report actual shard duration separately from test correctness.
- Do refresh timings on a schedule and after major suite restructuring.
- Do not use absolute paths, mixed separators, or seconds in some files and milliseconds in others.
- Do not erase stable history when one CI job fails to upload an artifact.
- Do not assume more runners always reduce time. Runner startup and the longest indivisible spec set the practical floor.
- Do not hide order dependencies with a fixed shard assignment. Fix isolation so any spec can execute on any runner.
Review timing changes like test infrastructure code. A sudden tenfold increase may reveal a real product regression, an added retry, a broken network stub, or merely a noisy runner. The scheduler should make those signals visible, not normalize them without review.
Conclusion
To cypress split specs by historical duration successfully, maintain a clean path-to-duration map, assign slowest specs to the lightest shard, validate complete coverage, and run each manifest through Cypress's supported --spec option. Smooth new measurements so the plan adapts without reacting wildly to one run.
Start with two or four shards, record estimated and actual completion times, and inspect the last runner to finish. Once isolation is solid and timing history is fresh, this small scheduling layer can cut wasted parallel capacity while remaining portable across CI providers.
Interview Questions and Answers
Why is historical duration better than round-robin Cypress spec assignment?
Round-robin assignment considers position but not cost, so multiple slow specs can still overload one runner. Historical duration approximates workload directly. Sorting longest first and assigning to the lightest shard reduces the chance that expensive specs accumulate together.
Describe longest-processing-time scheduling for Cypress specs.
First sort all specs by estimated duration in descending order. Then assign each spec to the shard whose current estimated total is smallest. Add the duration to that shard and repeat until every spec has exactly one assignment.
How would you validate a generated Cypress shard plan?
Discover the expected specs from the filesystem and flatten all generated manifests. Compare the two sets and fail on missing, duplicate, or unexpected paths. I would also assert valid shard indexes and positive duration estimates.
How do you prevent noisy CI runs from destabilizing timing history?
Use a rolling statistic such as an exponentially weighted moving average and collect measurements under representative conditions. Preserve stable history if artifacts are incomplete or infrastructure is degraded. Review large timing changes instead of merging them blindly.
What is the lower bound on file-level Cypress shard time?
The longest individual spec is an important lower bound because a file-level scheduler cannot divide it. Job setup and application startup add another fixed cost. If one spec dominates, I would split it by isolated business workflows or use finer-grained orchestration.
How do retries affect historical duration sharding?
Retries increase the real capacity consumed by flaky specs, so elapsed duration should usually include them. That can balance the pipeline while also exposing a quality problem. I would track flake and retry metrics separately so scheduling does not become a substitute for fixing instability.
What would you monitor after introducing duration-based sharding?
I would monitor the slowest job, average job duration, slowest-to-average ratio, per-spec drift, missing timing coverage, and total pipeline makespan. I would also separate browser execution from setup time to identify problems the spec scheduler cannot solve.
Frequently Asked Questions
Can Cypress split specs by historical duration without Cypress Cloud?
Yes. A Node.js script can read past spec durations, create balanced manifests, and pass each manifest to `cypress run --spec`. You are responsible for collecting history, storing it, and coordinating CI jobs.
How many Cypress shards should I use?
Start with a shard count that matches available CI concurrency and leaves several specs per shard. Measure total pipeline time because runner startup and application boot can erase the benefit of excessive parallelism.
What duration should a new Cypress spec receive?
Use a nonzero fallback based on known history, such as the mean or median spec duration. After the new spec runs under representative conditions, replace the fallback through your normal timing merge.
Should Cypress timing history be committed to Git?
A small timing JSON file is reasonable to commit because plans become reproducible and changes are reviewable. Large or frequently updated datasets can live in a durable CI cache or artifact store, provided the planning job restores them reliably.
How often should historical Cypress durations be updated?
Update them on a regular calibration schedule and after meaningful suite changes. A rolling average from representative runs is safer than replacing stable history with every noisy pull-request result.
Why is one duration-balanced shard still slow?
The shard may contain one indivisible long spec, or its runner may have slower setup, retries, or external dependencies. Compare per-spec time with total job time, then split a dominant spec only along independent workflow boundaries.
Can the same shard manifests run in GitLab CI or Jenkins?
Yes. The manifests are ordinary JSON and the runner script is provider-neutral. Configure the provider's parallel jobs to use distinct zero-based shard indexes and make the generated manifests available to each job.
Related Guides
- Create Cypress Query Commands with TypeScript: Cypress Query Commands TypeScript Tutorial
- How to Use Cypress parallelization (2026)
- Accessibility testing with Cypress (2026)
- Allure report in CI: Step by Step (2026)
- Autoscale Selenium Grid on Kubernetes Step by Step
- Azure DevOps test pipelines: Step by Step (2026)