1. 业务背景 如果组织代码使用 Monorepo 的架构则代码仓库中有很多项目, 以 Nx 框架为例, 有些项目是应用类, 有些工具类或者库, 每个项目都有单元测试, 进而有不同的代码覆盖率, 如何将多个项目的覆盖率报告按照项目分组并结合 Github PR 使用是本文的实践, 主要包含以下内容:
介绍两个脚本, 分别针对Istanbul
的json
和json-summary
报告格式进行二次统计, 进而生成自己的覆盖率报告, 达到按照项目分组和更高度自定义的需求
介绍两个 Gihub Workflow 的步骤
如何在 PR 中提交 Comment, 实现自定义覆盖率的报告在 PR 上显示
如何在流水线中检查阈值, 覆盖率低于阈值流水线失败, 这对 PR 质量要求严格的团队是必须要的特性
2. 脚本 覆盖率报告有很多格式, 常见的有 json/json-summary/clover/lcov/text 等, 具体可以查这里 查看详情. 对于 nodejs 编程比较友好是 json 格式, 以下是具体两种 json 格式的自定义统计报告, 报告以 Monorepo 中的应用程序分类统计 , 而不是整体统计.
2.1 json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 import * as fs from 'fs' import * as path from 'path' interface ReportType { coverageMap : { [path : string]: { path : string s : { [k : string]: number } f : { [k : string]: number } b : { [k : string]: number[] } } } } type AggregateReportItemType = { covered : number all : number } type AggregateResult = { branches : number statements : number functions : number } type CoverageResult = { path : string branches : AggregateReportItemType statements : AggregateReportItemType functions : AggregateReportItemType } function generateEmptyReport ( ): AggregateReportItemType { return { covered : 0 , all : 0 , } } function aggregateCoverage (coverages: CoverageResult[] ): AggregateResult { let statements = 0 let statementCount = 0 let branches = 0 let branchCount = 0 let functions = 0 let functionCount = 0 if (coverages.length === 0 ) { return { statements : 0 , branches : 0 , functions : 0 , } } coverages.forEach (coverage => { statements += coverage.statements .covered statementCount += coverage.statements .all branches += coverage.branches .covered branchCount += coverage.branches .all functions += coverage.functions .covered functionCount += coverage.functions .all }) if (statementCount === 0 ) { statements = 100 } else { statements = (statements / statementCount) * 100 } if (branchCount === 0 ) { branches = 100 } else { branches = (branches / branchCount) * 100 } if (functionCount === 0 ) { functions = 100 } else { functions = (functions / functionCount) * 100 } return { statements, branches, functions, } } function parseCoverage (json: ReportType ): CoverageResult [] { const paths = Object .keys (json.coverageMap ) return paths.map (path => { const coverage = json.coverageMap [path] const branches = generateEmptyReport () const statements = generateEmptyReport () const functions = generateEmptyReport () Object .keys (coverage.b ).forEach (id => { const result = coverage.b [id] result.forEach (r => { branches.covered += r > 0 ? 1 : 0 branches.all += 1 }) }) Object .keys (coverage.s ).forEach (id => { statements.covered += coverage.s [id] > 0 ? 1 : 0 statements.all += 1 }) Object .keys (coverage.f ).forEach (id => { functions.covered += coverage.f [id] > 0 ? 1 : 0 functions.all += 1 }) return { path, branches, statements, functions, } }) } const baseDir = path.resolve (__dirname, '../coverage/packages' )const paths : string[] = []fs.readdirSync (baseDir).forEach (app => { const report = `${baseDir} /${app} /report.json` if (fs.existsSync (report)) { paths.push (report) } }) if (paths.length ) { console .log ('# Coverage report' ) console .log ('|App|Statements|Branches|Functions|' ) console .log ('|---|---|---|---|' ) paths.forEach (path => { const coverages = parseCoverage (require (path)) const { statements, branches, functions } = aggregateCoverage (coverages) const app = path.split ('/' ).at (-2 ) console .log ( `|${app} |${statements.toFixed(2 )} %|${branches.toFixed(2 )} %|${functions.toFixed(2 )} %|` ) }) }
测试结果:
1 2 3 4 5 6 7 8 # Coverage report | App | Statements | Branches | Functions | | ---- | ---------- | -------- | --------- | | app1 | 100.00% | 100.00% | 100.00% | | app2 | 89.90% | 66.06% | 90.61% | | app3 | 100.00% | 100.00% | 100.00% | | lib1 | 87.83% | 66.67% | 93.33% |
2.2 json-summary 该方式是本人团队最后使用的方式, 具体脚本封装在 nx 自定义插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 import { Tree , readJsonFile } from "@nx/devkit" ;import * as fs from "fs" ;import * as path from "path" ;import { CodeCoverageReportGeneratorSchema } from "./schema" ;export type CoverageSummary = { total : { lines : { total : number ; covered : number ; skipped : number ; pct : number | "Unknown" ; }; statements : { total : number ; covered : number ; skipped : number ; pct : number | "Unknown" ; }; functions : { total : number ; covered : number ; skipped : number ; pct : number | "Unknown" ; }; branches : { total : number ; covered : number ; skipped : number ; pct : number | "Unknown" ; }; branchesTrue : { total : number ; covered : number ; skipped : number ; pct : number | "Unknown" ; }; }; }; export const formatCoverageValue = (v: number | "Unknown" ) => { if (v === "Unknown" ) { return `🔴 Unknown` ; } if (v < 65 ) { return `🔴 ${v.toFixed(2 )} %` ; } else if (v > 80 ) { return `🟢 ${v.toFixed(2 )} %` ; } else { return `🟡 ${v.toFixed(2 )} %` ; } }; export interface CodeCoverageReportGeneratorSchema { coverageDir : string ; reportPath : string ; limitTarget : number ; } export async function codeCoverageReportGenerator ( tree: Tree, options: CodeCoverageReportGeneratorSchema ) { const reportMarkdown : string [] = []; reportMarkdown.push ("# Coverage report" ); reportMarkdown.push ("|Project|Lines|Statements|Branches|Functions|" ); reportMarkdown.push ("|---|---|---|---|---|" ); for (const coverageDir of options.coverageDir ) { const baseDir = path.resolve (process.cwd (), coverageDir); const reportJsonPaths : string [] = []; fs.readdirSync (baseDir).forEach ((nxProject ) => { const report = `${baseDir} /${nxProject} /coverage-summary.json` ; if (fs.existsSync (report)) { reportJsonPaths.push (report); } }); const exitFile = path.resolve (process.cwd (), `${options.reportPath} .exit` ); fs.rmSync (exitFile, { force : true }); if (reportJsonPaths.length ) { reportJsonPaths.forEach ((reportJsonPath ) => { const coverages = readJsonFile (reportJsonPath) as CoverageSummary ; const { total : { lines, statements, branches, functions }, } = coverages; const app = reportJsonPath.split ("/" ).at (-2 ); reportMarkdown.push ( `|${app} |${formatCoverageValue(lines.pct)} |${formatCoverageValue( statements.pct )} |${formatCoverageValue(branches.pct)} |${formatCoverageValue( functions.pct )} |` ); if (+statements.pct < +options.limitTarget ) { const msg = `${app} statements: ${formatCoverageValue( statements.pct )} < ${options.limitTarget} ` ; const exitFile = path.resolve ( process.cwd (), `${options.reportPath} .exit` ); fs.writeFileSync (exitFile, `${msg} \n` , { flag : "a" }); } }); const reportFile = path.resolve (process.cwd (), options.reportPath ); fs.writeFileSync (reportFile, reportMarkdown.join ("\n" )); } } } export default codeCoverageReportGenerator;
测试结果如下, 比json
报告多了一个行覆盖:
1 2 3 4 5 6 7 8 # Coverage report | App | Lines | Statements | Branches | Functions | | ---- | ---------- | ---------- | ---------- | ---------- | | app1 | 🟢 100.00% | 🟢 100.00% | 🟢 100.00% | 🟢 100.00% | | app2 | 🟢 90.13% | 🟢 89.90% | 🟡 66.06% | 🟢 90.61% | | app3 | 🟢 100.00% | 🟢 100.00% | 🟢 100.00% | 🟢 100.00% | | lib1 | 🟢 89.22% | 🟢 87.82% | 🟡 66.66% | 🟢 93.33% |
3. 配合 Github Workflow 使用 这个功能主要是在 PR 运行完单元测试后例如上面的提到的脚本实现对覆盖率报告的收集, 并生成 markdown 文件, 这个 markdown 文件可以作为评论(Comment)写在 PR 上,方便查看覆盖率报告. 结合 2.2 中的使用 nx 生成器方式生产报告的方式如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 - name: Generate custom code coverage report run: | mkdir -p coverage/packages mkdir -p tmp/ yarn nx g nx-plugin:code-coverage-report --coverageDir coverage/packages --reportPath tmp/report.md - name: Submit custom code coverage report run: | if [ -f tmp/report.md ]; then commentId=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/nnsay/core/issues/${{ github.event.number } }/comments" | jq -r '.[]|select(.body|test("Coverage report"))|.id' ) if [ -n "$commentId " ]; then echo "existed comment id: $commentId " gh api \ --method DELETE \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "/repos/nnsay/core/issues/comments/$commentId " fi cat tmp/report.md gh pr comment ${{ github.event.number } } --body-file tmp/report.md fi env : GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN } }
3.2 覆盖率阈值检检查 1 2 3 4 5 6 - name: Check the coverage limit target run: | if [ -f tmp/report.md.exit ]; then cat tmp/report.md.exit exit 1 fi
‼️ 该步骤需要在生产自定义报告之后做, 步骤中的report.md.exit
就是自定义 nx 生成器中生成的文件, 开发者可以参考这个思路实现更复杂和符合业务需求的逻辑.
4. 总结 (© claude.ai)
可以按照项目分类统计覆盖率,而不是仓库整体统计,更加准确反映每个项目的测试质量。
可以自定义报告格式,生成 markdown 等格式,便于在 CI/CD 流程中使用。
可以在 PR 评论中实时展示覆盖率报告,帮助 review。
可以设置覆盖率阈值,不达标时流水线失败,保证代码质量。
借助代码生成器,可以使报告更易定制,也方便与 CI/CD 系统集成。
总体来说,自定义报告让开发团队更加关注测试质量,也为持续改进测试奠定基础。