Skip to content

Commit

Permalink
Added detailed test coverage report. (#513)
Browse files Browse the repository at this point in the history
* Added detailed test coverage report.

Signed-off-by: dblock <[email protected]>
  • Loading branch information
dblock authored Aug 15, 2024
1 parent a322160 commit 3b02ba3
Show file tree
Hide file tree
Showing 18 changed files with 253 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
## Spec Test Coverage Analysis
{{with .test_coverage}}

| Total | Tested |
|-------------------|----------------------------------------------------------|
| {{.paths_count}} | {{.evaluated_paths_count}} ({{.evaluated_paths_pct}} %) |
| Total | Tested |
|------------------------------|---------------------------------------------------------------|
| {{.total_operations_count}} | {{.evaluated_operations_count}} ({{.evaluated_paths_pct}} %) |

{{end}}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Added `read_time`, `write_time`, `queue_size` and `io_time_in_millis` to `IoStatDevice` ([#483](https://github.com/opensearch-project/opensearch-api-specification/pull/483))
- Added `total_rejections_breakup` to `ShardIndexingPressureStats` ([#483](https://github.com/opensearch-project/opensearch-api-specification/pull/483))
- Added `cancelled_task_percentage` and `current_cancellation_eligible_tasks_count` to `ShardSearchBackpressureTaskCancellationStats` ([#483](https://github.com/opensearch-project/opensearch-api-specification/pull/483))
- Added detailed test coverage report ([#513](https://github.com/opensearch-project/opensearch-api-specification/pull/513))

### Changed

Expand Down
32 changes: 32 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
- [Warnings](#warnings)
- [multiple-paths-detected](#multiple-paths-detected)
- [Suppressing Warnings](#suppressing-warnings)
- [Collecting Test Coverage](#collecting-test-coverage)
- [Coverage Summary](#coverage-summary)
- [Coverage Report](#coverage-report)
<!-- TOC -->

# Spec Testing Guide
Expand Down Expand Up @@ -318,3 +321,32 @@ The test runner may generate warnings that can be suppressed with `warnings:` at
parameters:
index: movies
```
## Collecting Test Coverage
### Coverage Summary
The test tool can generate a test coverage summary using `--coverage <path>` with the number of evaluated verb + path combinations, a total number of paths and the % of paths tested.
```json
{
"evaluated_operations_count": 214,
"operations_count": 550,
"evaluated_paths_pct": 38.91
}
```
The report is then used by the [test-spec.yml](.github/workflows/test-spec.yml) workflow, uploaded with every run, combined across various versions of OpenSearch, and reported as a comment on each pull request.
### Coverage Report
The test tool can display detailed and hierarchal test coverage with `--coverage-report`. This is useful to identify untested paths. For example, the [put_alias.yaml](tests/default/indices/aliases/put_alias.yaml) test exercises `PUT /_alias/{name}`, but not the other verbs. The report produces the following output with the missing ones.
```
/_alias (4)
GET /_alias
/{name} (3)
GET /_alias/{name}
POST /_alias/{name}
HEAD /_alias/{name}
```
4 changes: 4 additions & 0 deletions tools/src/tester/ChapterEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ export default class ChapterEvaluator {

var result: ChapterEvaluation = {
title: chapter.synopsis,
operation: {
method: chapter.method,
path: chapter.path
},
path: `${chapter.method} ${chapter.path}`,
overall: { result: overall_result(evaluations) },
request: { parameters: params, request },
Expand Down
28 changes: 26 additions & 2 deletions tools/src/tester/ResultLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
* compatible open source license.
*/

import { type ChapterEvaluation, type Evaluation, Result, type StoryEvaluation } from './types/eval.types'
import { type ChapterEvaluation, type Evaluation, Operation, Result, type StoryEvaluation } from './types/eval.types'
import { overall_result } from './helpers'
import * as ansi from './Ansi'
import TestResults from './TestResults'
import _ from 'lodash'

export interface ResultLogger {
log: (evaluation: StoryEvaluation) => void
Expand Down Expand Up @@ -43,7 +44,30 @@ export class ConsoleResultLogger implements ResultLogger {

log_coverage(results: TestResults): void {
console.log()
console.log(`Tested ${results.evaluated_paths_count()}/${results.spec_paths_count()} paths.`)
console.log(`Tested ${results.evaluated_operations().length}/${results.operations().length} paths.`)
}

log_coverage_report(results: TestResults): void {
console.log()
console.log(`${results.unevaluated_operations().length} paths remaining.`)
const groups = _.groupBy(results.unevaluated_operations(), (operation) => operation.path.split('/')[1])
Object.entries(groups).forEach(([root, operations]) => {
this.#log_coverage_group(root, operations)
});
}

#log_coverage_group(key: string, operations: Operation[], index: number = 2): void {
if (operations.length == 0 || key == undefined) return
console.log(`${' '.repeat(index)}/${key} (${operations.length})`)
const current_level_operations = operations.filter((operation) => operation.path.split('/').length == index)
current_level_operations.forEach((operation) => {
console.log(`${' '.repeat(index + 2)}${operation.method} ${operation.path}`)
})
const next_level_operations = operations.filter((operation) => operation.path.split('/').length > index)
const subgroups = _.groupBy(next_level_operations, (operation) => operation.path.split('/')[index])
Object.entries(subgroups).forEach(([root, operations]) => {
this.#log_coverage_group(root, operations, index + 1)
});
}

#log_story ({ result, full_path, display_path, message, warnings }: StoryEvaluation): void {
Expand Down
4 changes: 2 additions & 2 deletions tools/src/tester/StoryEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export default class StoryEvaluator {
return chapter.path
}))
const normalized_paths = _.map(paths, (path) => path.replaceAll(/\/\{[^}]+}/g, '').replaceAll('//', '/'))
const paths_counts: Record<string, number> = Object.assign((_.values(_.groupBy(normalized_paths)).map(p => { return { [p[0]] : p.length } })))
if (paths_counts.length > 1) {
const operations_counts: Record<string, number> = Object.assign((_.values(_.groupBy(normalized_paths)).map(p => { return { [p[0]] : p.length } })))
if (operations_counts.length > 1) {
return `Multiple paths detected, please group similar tests together and move paths not being tested to prologues or epilogues.\n ${_.join(_.uniq(paths), "\n ")}\n`
}
}
Expand Down
46 changes: 35 additions & 11 deletions tools/src/tester/TestResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,59 @@

import _ from "lodash";
import MergedOpenApiSpec from "./MergedOpenApiSpec";
import { StoryEvaluations } from "./types/eval.types";
import { Operation, StoryEvaluations } from "./types/eval.types";
import { SpecTestCoverage } from "./types/test.types";
import { write_json } from "../helpers";

export default class TestResults {
protected _spec: MergedOpenApiSpec
protected _evaluations: StoryEvaluations
protected _evaluated_operations?: Operation[]
protected _unevaluated_operations?: Operation[]
protected _operations?: Operation[]

constructor(spec: MergedOpenApiSpec, evaluations: StoryEvaluations) {
this._spec = spec
this._evaluations = evaluations
}

evaluated_paths_count(): number {
return _.uniq(_.compact(_.flatten(_.map(this._evaluations.evaluations, (evaluation) =>
_.map(evaluation.chapters, (chapter) => chapter.path)
)))).length
evaluated_operations(): Operation[] {
if (this._evaluated_operations !== undefined) return this._evaluated_operations
this._evaluated_operations = _.uniq(_.compact(_.flatMap(this._evaluations.evaluations, (evaluation) =>
_.map(evaluation.chapters, (chapter) => chapter.operation)
)))
return this._evaluated_operations
}

spec_paths_count(): number {
return Object.values(this._spec.paths()).reduce((acc, methods) => acc + methods.length, 0);
unevaluated_operations(): Operation[] {
if (this._unevaluated_operations !== undefined) return this._unevaluated_operations
this._unevaluated_operations = this.operations().filter((operation) =>
!_.find(this.evaluated_operations(),
(op) =>
operation.method == op.method &&
operation.path == op.path
)
)
return this._unevaluated_operations
}

operations(): Operation[] {
if (this._operations !== undefined) return this._operations
this._operations = _.uniq(Object.entries(this._spec.paths()).flatMap(([path, path_item]) => {
return Object.values(path_item).map((method) => {
return { method: method.toUpperCase(), path }
})
}))

return this._operations
}

test_coverage(): SpecTestCoverage {
return {
evaluated_paths_count: this.evaluated_paths_count(),
paths_count: this.spec_paths_count(),
evaluated_paths_pct: this.spec_paths_count() > 0 ? Math.round(
this.evaluated_paths_count() / this.spec_paths_count() * 100 * 100
evaluated_operations_count: this.evaluated_operations().length,
total_operations_count: this.operations().length,
evaluated_paths_pct: this.operations().length > 0 ? Math.round(
this.evaluated_operations().length / this.operations().length * 100 * 100
) / 100 : 0,
}
}
Expand Down
2 changes: 2 additions & 0 deletions tools/src/tester/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const command = new Command()
.addOption(AWS_REGION_OPTION)
.addOption(AWS_SERVICE_OPTION)
.addOption(new Option('--coverage <path>', 'path to write test coverage results to'))
.addOption(new Option('--coverage-report', 'display a detailed test coverage report'))
.allowExcessArguments(false)
.parse()

Expand All @@ -82,6 +83,7 @@ runner.run(opts.testsPath, spec.api_version(), opts.opensearchDistribution, opts

const test_results = new TestResults(spec, results)
result_logger.log_coverage(test_results)
if (opts.coverageReport) result_logger.log_coverage_report(test_results)
if (opts.coverage !== undefined) {
console.log(`Writing ${opts.coverage} ...`)
test_results.write_coverage(opts.coverage)
Expand Down
6 changes: 6 additions & 0 deletions tools/src/tester/types/eval.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export interface StoryFile {
story: Story
}

export interface Operation {
method: string
path: string
}

export interface StoryEvaluation {
result: Result
display_path: string
Expand All @@ -36,6 +41,7 @@ export interface StoryEvaluations {
export interface ChapterEvaluation {
title: string,
overall: Evaluation,
operation?: Operation,
path?: string,
request?: {
parameters?: Record<string, Evaluation>
Expand Down
4 changes: 2 additions & 2 deletions tools/src/tester/types/test.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export interface SpecTestCoverage {
paths_count: number
evaluated_paths_count: number,
total_operations_count: number
evaluated_operations_count: number,
evaluated_paths_pct: number
}
45 changes: 44 additions & 1 deletion tools/tests/tester/ResultLogger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ describe('ConsoleResultLogger', () => {
{
title: 'title',
overall: { result: Result.PASSED },
path: 'path'
path: 'path',
operation: {
method: 'GET',
path: '/_nodes/{id}'
}
}
]
}] })
Expand All @@ -79,6 +83,45 @@ describe('ConsoleResultLogger', () => {
])
})

test('log_coverage_report', () => {
const spec = new MergedOpenApiSpec('tools/tests/tester/fixtures/specs/complete')
const test_results = new TestResults(spec, { evaluations: [{
result: Result.PASSED,
display_path: 'path',
full_path: 'path',
description: 'description',
chapters: [
{
title: 'title',
overall: { result: Result.PASSED },
path: 'GET /_nodes/{id}',
operation: {
method: 'GET',
path: '/_nodes/{id}'
}
}
]
}] })

logger.log_coverage_report(test_results)

expect(log.mock.calls).not.toContain(["GET /_nodes/{id}"])
expect(log.mock.calls).toEqual([
[],
["5 paths remaining."],
[" /_nodes (1)"],
[" /{id} (1)"],
[" POST /_nodes/{id}"],
[" /cluster_manager (2)"],
[" GET /cluster_manager"],
[" POST /cluster_manager"],
[" /index (1)"],
[" GET /index"],
[" /nodes (1)"],
[" GET /nodes"]
])
})

test('with retries', () => {
logger.log({
result: Result.PASSED,
Expand Down
40 changes: 32 additions & 8 deletions tools/tests/tester/TestResults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,62 @@ describe('TestResults', () => {

const evaluations = [{
result: Result.PASSED,
display_path: 'path',
display_path: 'PUT /{index}',
full_path: 'full_path',
description: 'description',
message: 'message',
chapters: [{
title: 'title',
operation: {
method: 'PUT',
path: '/{index}'
},
overall: {
result: Result.PASSED
},
path: 'path'
path: 'PUT /{index}'
}],
epilogues: [],
prologues: []
}]

const test_results = new TestResults(spec, { evaluations })

test('evaluated_paths_count', () => {
expect(test_results.evaluated_paths_count()).toEqual(1)
test('unevaluated_operations', () => {
expect(test_results.unevaluated_operations()).toEqual([
{ method: "GET", path: "/_nodes/{id}" },
{ method: "POST", path: "/_nodes/{id}" },
{ method: "GET", path: "/cluster_manager" },
{ method: "POST", path: "/cluster_manager" },
{ method: "GET", path: "/index" },
{ method: "GET", path: "/nodes" }
])
})

test('evaluated_operations', () => {
expect(test_results.evaluated_operations()).toStrictEqual([
{ method: 'PUT', path: '/{index}' }
])
})

test('spec_paths_count', () => {
expect(test_results.spec_paths_count()).toEqual(6)
test('operations', () => {
expect(test_results.operations()).toEqual([
{ method: "GET", path: "/_nodes/{id}" },
{ method: "POST", path: "/_nodes/{id}" },
{ method: "GET", path: "/cluster_manager" },
{ method: "POST", path: "/cluster_manager" },
{ method: "GET", path: "/index" },
{ method: "GET", path: "/nodes" }
])
})

test('write_coverage', () => {
const filename = 'coverage.json'
test_results.write_coverage(filename)
expect(JSON.parse(fs.readFileSync(filename, 'utf8'))).toEqual({
evaluated_paths_count: 1,
evaluated_operations_count: 1,
evaluated_paths_pct: 16.67,
paths_count: 6
total_operations_count: 6
})
fs.unlinkSync(filename)
})
Expand Down
3 changes: 3 additions & 0 deletions tools/tests/tester/fixtures/evals/error/chapter_error.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ chapters:
overall:
result: ERROR
path: DELETE /{index}
operation:
method: DELETE
path: /{index}
request:
parameters: {}
request:
Expand Down
3 changes: 3 additions & 0 deletions tools/tests/tester/fixtures/evals/error/output_error.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ chapters:
overall:
result: ERROR
path: GET /_cat/health
operation:
method: GET
path: /_cat/health
retries: 3
request:
parameters:
Expand Down
Loading

0 comments on commit 3b02ba3

Please sign in to comment.