Created
November 10, 2022 19:24
-
-
Save azakordonets/7b53c1309403ad2196da4e2d916589f0 to your computer and use it in GitHub Desktop.
This setup helps to run Jest tests on Gitlab in parallel
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
stages: | |
- prepare | |
- build | |
- test | |
- post_test | |
unit tests: | |
timeout: 20 minutes | |
stage: test | |
variables: | |
CI_NODE_INDEX: $CI_NODE_INDEX | |
CI_NODE_TOTAL: $CI_NODE_TOTAL | |
parallel: 5 | |
script: | |
- npm run test:ci | |
artifacts: | |
name: test-reports/ | |
paths: | |
- test-reports/ | |
cache: | |
key: jest | |
paths: | |
- .tmp/cache/jest/ | |
# ========================= | |
# post test | |
# ========================= | |
test report: | |
stage: post_test | |
needs: | |
- unit tests | |
script: | |
- npm run test:ci:test-report | |
artifacts: | |
paths: | |
- test-reports/ | |
reports: | |
junit: test-reports/test-results.xml | |
coverage: | |
stage: post_test | |
needs: | |
- unit tests | |
script: | |
- npm run test:ci:coverage-report | |
artifacts: | |
name: test-coverage | |
paths: | |
- test-reports/ | |
reports: | |
coverage_report: | |
coverage_format: cobertura | |
path: test-reports/cobertura-coverage.xml | |
coverage: '/Total Coverage: (\d+\.\d+\%)/' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { create } = require('istanbul-reports') | |
const { createCoverageMap } = require('istanbul-lib-coverage') | |
const { createContext } = require('istanbul-lib-report') | |
const { resolve } = require('path') | |
const { sync } = require('glob') | |
const { GLOBAL_THRESHOLDS } = require('../../jest.config') | |
const coverageMap = createCoverageMap() | |
const REPORTS_FOLDER = 'test-reports' | |
const coverageDir = resolve(__dirname, `../../${REPORTS_FOLDER}`) | |
const reportFiles = sync(`${coverageDir}/*/coverage/coverage-final.json`) | |
const COVERAGE_TYPES = ['lines', 'statements', 'functions', 'branches'] | |
/* eslint-disable no-console */ | |
// Normalize coverage report generated by jest that has additional "data" key | |
// https://github.com/facebook/jest/issues/2418#issuecomment-423806659 | |
const normalizeReport = report => { | |
const normalizedReport = Object.assign({}, report) | |
Object.entries(normalizedReport).forEach(([k, v]) => { | |
if (v.data) normalizedReport[k] = v.data | |
}) | |
return normalizedReport | |
} | |
/** | |
* prepare unit test coverage files and merge to single test context to build a report | |
*/ | |
reportFiles | |
.map(reportFile => { | |
console.log('Found report file: ' + reportFile) | |
return require(reportFile) | |
}) | |
.map(normalizeReport) | |
.forEach(report => coverageMap.merge(report)) | |
const context = createContext({ | |
coverageMap: coverageMap, | |
dir: REPORTS_FOLDER, | |
}) | |
/** | |
* create and output a Cobertura report for MR coverage visualization | |
* https://docs.gitlab.com/ee/ci/testing/test_coverage_visualization.html | |
*/ | |
create('cobertura', {}).execute(context) | |
console.log( | |
`Cobertura coverage report generated and outputted to ${coverageDir}` | |
) | |
/** | |
* create coverage summary and check for met threshold gates, job should | |
* fail if any defined thresholds are not met | |
*/ | |
// create json coverage summary, which also prints summary to terminal | |
create('json-summary', {}).execute(context) | |
const coverageSummary = require(`${coverageDir}/coverage-summary.json`) | |
/** | |
* print results to terminal for easy visibility of coverage summary, text-summary | |
* will print out directly a coverage summary of each type, and then print a custom | |
* total coverage line for use to display total % in merge requests | |
*/ | |
create('text-summary', {}).execute(context) | |
const totalSum = COVERAGE_TYPES.map( | |
type => coverageSummary.total[type].pct | |
).reduce((total, percent) => total + percent, 0) | |
const avgCoverage = totalSum / COVERAGE_TYPES.length | |
console.debug(` | |
========= Total Coverage ============== | |
Total Coverage: ${avgCoverage.toFixed(2)}% | |
======================================= | |
`) | |
/** | |
* use the JSON summary report to do checks against coverage thresholds that | |
* cannot be used in parallelization. check each type and determine if threshold | |
* is met, otherwise fail this job | |
*/ | |
const checkCoverageAgainstThresholds = coverageSummary => { | |
const total = coverageSummary?.total || {} | |
return COVERAGE_TYPES.map(type => { | |
// If the threshold is a number use it, otherwise lookup the threshold type | |
var threshold = GLOBAL_THRESHOLDS[type] | |
// Check for no threshold | |
if (!threshold) { | |
return { | |
type, | |
skipped: true, | |
failed: false, | |
} | |
} | |
const value = total[type]?.pct | |
return { | |
type, | |
required: threshold, | |
value, | |
failed: value < threshold, | |
} | |
}) | |
} | |
const coverageThresholds = checkCoverageAgainstThresholds(coverageSummary) | |
const failedCoverageTypes = coverageThresholds.filter(type => type.failed) | |
if (failedCoverageTypes.length > 0) { | |
failedCoverageTypes.forEach(({ type, value, required }) => { | |
console.error( | |
'\x1b[31m%s\x1b[0m', | |
`Global coverage threshold for ${type} (${required}%) not met: ${value}%` | |
) | |
}) | |
console.log(`❌ Coverage thresholds have not been met`) | |
process.exit(1) | |
} | |
console.log(`✅ Coverage thresholds met`) | |
process.exit(0) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Custom Jest test sequencer, taken from Gitlab's codebase. Used to separate | |
* and run Jest test suites in parallel based on available job nodes. | |
* Structure is based on the default sequencer from Jest. | |
*/ | |
const Sequencer = require('@jest/test-sequencer').default | |
/* eslint-disable no-console */ | |
class ParallelCiSequencer extends Sequencer { | |
constructor() { | |
super() | |
this.ciNodeIndex = Number(process.env.CI_NODE_INDEX || '1') | |
this.ciNodeTotal = Number(process.env.CI_NODE_TOTAL || '1') | |
} | |
sort(tests) { | |
const sortedTests = this.sortByPath(tests) | |
const testsForThisRunner = this.distributeAcrossCINodes(sortedTests) | |
console.log(`CI_NODE_INDEX: ${this.ciNodeIndex}`) | |
console.log(`CI_NODE_TOTAL: ${this.ciNodeTotal}`) | |
console.log(`Total number of tests: ${tests.length}`) | |
console.log( | |
`Total number of tests for this runner: ${testsForThisRunner.length}` | |
) | |
return testsForThisRunner | |
} | |
sortByPath(tests) { | |
return tests.sort((test1, test2) => { | |
if (test1.path < test2.path) { | |
return -1 | |
} | |
if (test1.path > test2.path) { | |
return 1 | |
} | |
return 0 | |
}) | |
} | |
distributeAcrossCINodes(tests) { | |
return tests.filter((test, index) => { | |
return index % this.ciNodeTotal === this.ciNodeIndex - 1 | |
}) | |
} | |
} | |
module.exports = ParallelCiSequencer |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const aliasList = require('./internals/webpack/aliasList') | |
const IS_PARALLEL_TESTS = process.env.CI_NODE_INDEX && process.env.CI_NODE_TOTAL | |
const GLOBAL_THRESHOLDS = { | |
branches: 76, | |
functions: 81, | |
statements: 84, | |
lines: 84, | |
} | |
module.exports = { | |
/** | |
* for pipeline test steps, include junit report for Gitlab report artifacts | |
* and custom coverage reporter for regex of coverage %'s | |
*/ | |
reporters: [ | |
'default', | |
[ | |
'jest-junit', | |
{ | |
outputDirectory: IS_PARALLEL_TESTS | |
? `<rootDir>/test-reports/test-${process.env.CI_NODE_INDEX}-${process.env.CI_NODE_TOTAL}` | |
: '<rootDir>/test-reports', | |
outputName: 'test-results.xml', | |
}, | |
], | |
], | |
maxWorkers: 4, | |
collectCoverageFrom: ['src/**/*.js'], | |
coveragePathIgnorePatterns: [ | |
'/node_modules/(?!intl-messageformat|intl-messageformat-parser).+\\.js$', | |
'/testing/', | |
], | |
/** | |
* for pipeline test steps, include cobertura reporter for Gitlab coverage report | |
* and json-summary for custom coverage reporter | |
*/ | |
coverageReporters: [ | |
'json', | |
'lcov', | |
['cobertura', { file: 'coverage-results.xml' }], | |
// `json-summary`'s `coverage-summary.json` output needed for custom coverage reporter | |
'json-summary', | |
/** | |
* `text-summary` removes giant coverage matrix from terminal print-out, | |
* replace with "text" if you wish to see this locally | |
*/ | |
'text-summary', | |
], | |
// remove coverage thresholds for cicd parallel tests as they will be incomplete suites | |
coverageThreshold: IS_PARALLEL_TESTS | |
? {} | |
: { | |
global: GLOBAL_THRESHOLDS, | |
}, | |
coverageDirectory: IS_PARALLEL_TESTS | |
? `<rootDir>/test-reports/test-${process.env.CI_NODE_INDEX}-${process.env.CI_NODE_TOTAL}/coverage` | |
: '<rootDir>/test-reports/coverage', | |
cacheDirectory: '<rootDir>/.tmp/cache/jest', | |
// export for use in custom coverage threshold gates when parallel test | |
GLOBAL_THRESHOLDS, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment