Skip to content

Instantly share code, notes, and snippets.

@azakordonets
Created November 10, 2022 19:24
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save azakordonets/7b53c1309403ad2196da4e2d916589f0 to your computer and use it in GitHub Desktop.
Save azakordonets/7b53c1309403ad2196da4e2d916589f0 to your computer and use it in GitHub Desktop.
This setup helps to run Jest tests on Gitlab in parallel
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+\%)/'
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)
/**
* 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
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