├── .editorconfig
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .husky
└── pre-commit
├── .markdown-doctest-setup.js
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── jest.config.ts
├── package-lock.json
├── package.json
├── rollup.config.ts
├── spec
├── e2e.spec.ts
├── expected_report.xml
├── test_case.spec.ts
└── test_suite.spec.ts
├── src
├── builder.ts
├── factory.ts
├── index.ts
├── test_case.ts
├── test_group.ts
├── test_node.ts
├── test_suite.ts
└── test_suites.ts
├── tsconfig.json
└── tslint.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig format: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | charset = utf-8
8 | end_of_line = lf
9 | indent_size = 2
10 | indent_style = space
11 |
12 | [*.md]
13 | indent_size = 4
14 |
15 | [*.{js,json,md}]
16 | insert_final_newline = true
17 | trim_trailing_whitespace = true
18 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | node-version: [16.x, 18.x, 20.x, 22.x]
12 |
13 | steps:
14 | - uses: actions/checkout@v1
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v1
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - name: npm install, build, and test
20 | run: |
21 | npm install
22 | npm test
23 | env:
24 | CI: true
25 | - name: Upload coverage reports to Codecov
26 | uses: codecov/codecov-action@v4.0.1
27 | with:
28 | token: ${{ secrets.CODECOV_TOKEN }}
29 | if: matrix.node-version == '22.x'
30 |
31 | formatting:
32 | runs-on: ubuntu-latest
33 |
34 | steps:
35 | - uses: actions/checkout@v1
36 | - uses: actions/setup-node@v1
37 | with:
38 | node-version: 20.x
39 | - name: npm install, check formatting
40 | run: |
41 | npm install
42 | npm run check
43 |
44 | doctest:
45 | runs-on: ubuntu-latest
46 |
47 | steps:
48 | - uses: actions/checkout@v1
49 | - uses: actions/setup-node@v1
50 | with:
51 | node-version: 20.x
52 | - name: npm install, run doctest
53 | run: |
54 | npm install
55 | npm run doctest
56 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | build/
3 | dist/
4 | coverage/
5 | .nvmrc
6 | test-report.xml
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm run check
2 | npm run build
3 | npm run test
4 | npm run doctest
--------------------------------------------------------------------------------
/.markdown-doctest-setup.js:
--------------------------------------------------------------------------------
1 | // .markdown-doctest-setup.js
2 | module.exports = {
3 | require: {
4 | 'junit-report-builder': require('./dist/index.js'),
5 | },
6 | globals: {
7 | builder: require('./dist/index.js'),
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | build/
2 | spec/
3 | .gitignore
4 | .travis.yml
5 | .editorconfig
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "all",
4 | "singleQuote": true
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 David Pärsson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # junit-report-builder
2 |
3 | [](https://github.com/davidparsson/junit-report-builder/actions?query=workflow%3ACI)
4 | [](https://www.npmjs.com/package/junit-report-builder)
5 |
6 | A project aimed at making it easier to build [Jenkins](http://jenkins-ci.org/) compatible XML based JUnit reports.
7 |
8 | ## Installation
9 |
10 | To install the latest version, run:
11 |
12 | npm install junit-report-builder --save
13 |
14 | ## Usage
15 |
16 | ```JavaScript
17 | import builder from 'junit-report-builder';
18 |
19 | // Create a test suite
20 | let suite = builder.testSuite().name('My suite');
21 |
22 | // Create a test case
23 | let firstTestCase = suite.testCase()
24 | .className('my.test.Class')
25 | .name('My first test');
26 |
27 | // Create another test case which is marked as failed
28 | let secondTestCase = suite.testCase()
29 | .className('my.test.Class')
30 | .name('My second test')
31 | .failure();
32 |
33 | builder.writeTo('test-report.xml');
34 | ```
35 |
36 | This will create `test-report.xml` containing the following:
37 |
38 | ```XML
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | ```
49 |
50 | If you want to create another report file, start by getting a new
51 | builder instance like this:
52 |
53 | ```JavaScript
54 | // Each builder produces a single report file
55 | let anotherBuilder = builder.newBuilder();
56 | ```
57 |
58 | CommonJS is also supported:
59 |
60 | ```JavaScript
61 | let builder = require('junit-report-builder');
62 | ```
63 |
64 | Please refer to the [e2e.spec.ts](spec/e2e.spec.ts) for more details on the usage.
65 |
66 | ## License
67 |
68 | [MIT](LICENSE)
69 |
70 | ## Changelog
71 |
72 | ### 5.1.1
73 |
74 | - Change `markdown-doctest` from a dependency to a dev dependency.
75 |
76 | ### 5.1.0
77 |
78 | - Add support for multiline properties, where the value is stored as text content instead of in the `value` attribute. Thanks to [Sebastian Sauer](https://github.com/sebastian-sauer).
79 |
80 | ### 5.0.0
81 |
82 | - A re-release of 4.0.1 since that version broke the CommonJS API.
83 | - Remove an internal type from the public API.
84 |
85 | ### 4.0.1 (deprecated)
86 |
87 | - _Deprecated, since the CommonJS API was accidentally changed in 4.0.1. Re-released as 5.0.0._
88 | - Re-introduce CommonJS support, while keeping the ES module support. Thanks to [Harel Mazor](https://github.com/HarelM) and [Simeon Cheeseman](https://github.com/SimeonC).
89 | - Export all public types from `index.d.ts`. Thanks to [Harel Mazor](https://github.com/HarelM) and [Simeon Cheeseman](https://github.com/SimeonC).
90 | - Full typing support for TypeScript. Thanks to [Harel Mazor](https://github.com/HarelM).
91 |
92 | ### 4.0.0 (deprecated)
93 |
94 | - _Deprecated, since the CommonJS support was accidentally dropped. This is fixed again in 4.0.1._
95 | - Dropped support for node.js 14, 12, 10 and 8.
96 | - Full typing support for TypeScript. Thanks to [Harel Mazor](https://github.com/HarelM).
97 |
98 | ### 3.2.1
99 |
100 | - Update documentation.
101 |
102 | ### 3.2.0
103 |
104 | - Support name and test count attributes for the root test suites element. Thanks to [Simeon Cheeseman](https://github.com/SimeonC).
105 | - Describe parameter types and return types with JSDoc. Thanks to [Simeon Cheeseman](https://github.com/SimeonC).
106 |
107 | ### 3.1.0
108 |
109 | - Add support for generic properties for test cases. Thanks to [Pietro Ferrulli](https://github.com/Pi-fe).
110 | - Bump dependencies
111 |
112 | ### 3.0.1
113 |
114 | - Bump dependencies: lodash, make-dir, date-format, minimist
115 |
116 | ### 3.0.0
117 |
118 | - Properly prevent invalid characters from being included in the XML files.
119 | - Dropped support for node.js 4 and 6
120 |
121 | ### 2.1.0
122 |
123 | - Added support for adding a `file` attribute to a test case. Thanks to [Ben Holland](https://github.com/hollandben).
124 |
125 | ### 2.0.0
126 |
127 | - Replace mkdirp by make-dir to resolve [npm advisory 1179](https://www.npmjs.com/advisories/1179).
128 | - Dropped support for node.js 0.10.x and 0.12.x
129 |
130 | ### 1.3.3
131 |
132 | - Updated lodash to a version without known vulnerabilities.
133 |
134 | ### 1.3.2
135 |
136 | - Added support for emitting the type attribute for error and failure elements of test cases
137 | - Added support for emitting cdata/content for the error element of a test case
138 |
139 | Thanks to [Robert Turner](https://github.com/rturner-edjuster).
140 |
141 | ### 1.3.1
142 |
143 | - Update dependencies to versions without known vulnerabilities.
144 |
145 | ### 1.3.0
146 |
147 | - Support [attaching files to tests](http://kohsuke.org/2012/03/13/attaching-files-to-junit-tests/). Thanks to [anto-wahanda](https://github.com/anto-wahanda).
148 |
149 | ### 1.2.0
150 |
151 | - Support creating XML with emojis. Thanks to [ischwarz](https://github.com/ischwarz).
152 |
153 | ### 1.1.1
154 |
155 | - Changed `date-format` to be a dependency. Previously it was incorrectly set to be a devDependency. Thanks to [georgecrawford](https://github.com/georgecrawford).
156 |
157 | ### 1.1.0
158 |
159 | - Added attributes for test count, failure count, error count and skipped test count to testsuite elements
160 | - Added ability to attach standard output and standard error logs to test cases
161 | - Added ability set execution time for test suites
162 | - Added ability set timestamp for test suites
163 |
164 | ### 1.0.0
165 |
166 | - Simplified API by making the index module export a builder instance
167 |
168 | ### 0.0.2
169 |
170 | - Corrected example in readme
171 |
172 | ### 0.0.1
173 |
174 | - Initial release
175 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | import type { Config } from 'jest';
7 |
8 | const config: Config = {
9 | // All imported modules in your tests should be mocked automatically
10 | // automock: false,
11 |
12 | // Stop running tests after `n` failures
13 | // bail: 0,
14 |
15 | // The directory where Jest should store its cached dependency information
16 | // cacheDirectory: "/tmp/jest_rs",
17 |
18 | // Automatically clear mock calls, instances, contexts and results before every test
19 | clearMocks: true,
20 |
21 | // Indicates whether the coverage information should be collected while executing the test
22 | collectCoverage: true,
23 |
24 | // An array of glob patterns indicating a set of files for which coverage information should be collected
25 | // collectCoverageFrom: undefined,
26 |
27 | // The directory where Jest should output its coverage files
28 | coverageDirectory: 'coverage',
29 |
30 | // An array of regexp pattern strings used to skip coverage collection
31 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
32 |
33 | // Indicates which provider should be used to instrument code for coverage
34 | coverageProvider: 'v8',
35 |
36 | // A list of reporter names that Jest uses when writing coverage reports
37 | // coverageReporters: [
38 | // "json",
39 | // "text",
40 | // "lcov",
41 | // "clover"
42 | // ],
43 |
44 | // An object that configures minimum threshold enforcement for coverage results
45 | // coverageThreshold: undefined,
46 |
47 | // A path to a custom dependency extractor
48 | // dependencyExtractor: undefined,
49 |
50 | // Make calling deprecated APIs throw helpful error messages
51 | // errorOnDeprecated: false,
52 |
53 | // The default configuration for fake timers
54 | // fakeTimers: {
55 | // "enableGlobally": false
56 | // },
57 |
58 | // Force coverage collection from ignored files using an array of glob patterns
59 | // forceCoverageMatch: [],
60 |
61 | // A path to a module which exports an async function that is triggered once before all test suites
62 | // globalSetup: undefined,
63 |
64 | // A path to a module which exports an async function that is triggered once after all test suites
65 | // globalTeardown: undefined,
66 |
67 | // A set of global variables that need to be available in all test environments
68 | // globals: {},
69 |
70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
71 | // maxWorkers: "50%",
72 |
73 | // An array of directory names to be searched recursively up from the requiring module's location
74 | // moduleDirectories: [
75 | // "node_modules"
76 | // ],
77 |
78 | // An array of file extensions your modules use
79 | // moduleFileExtensions: [
80 | // "js",
81 | // "mjs",
82 | // "cjs",
83 | // "jsx",
84 | // "ts",
85 | // "tsx",
86 | // "json",
87 | // "node"
88 | // ],
89 |
90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
91 | // moduleNameMapper: {},
92 |
93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
94 | // modulePathIgnorePatterns: [],
95 |
96 | // Activates notifications for test results
97 | // notify: false,
98 |
99 | // An enum that specifies notification mode. Requires { notify: true }
100 | // notifyMode: "failure-change",
101 |
102 | // A preset that is used as a base for Jest's configuration
103 | preset: 'ts-jest',
104 |
105 | // Run tests from one or more projects
106 | // projects: undefined,
107 |
108 | // Use this configuration option to add custom reporters to Jest
109 | // reporters: undefined,
110 |
111 | // Automatically reset mock state before every test
112 | // resetMocks: false,
113 |
114 | // Reset the module registry before running each individual test
115 | // resetModules: false,
116 |
117 | // A path to a custom resolver
118 | // resolver: undefined,
119 |
120 | // Automatically restore mock state and implementation before every test
121 | // restoreMocks: false,
122 |
123 | // The root directory that Jest should scan for tests and modules within
124 | // rootDir: undefined,
125 |
126 | // A list of paths to directories that Jest should use to search for files in
127 | // roots: [
128 | // ""
129 | // ],
130 |
131 | // Allows you to use a custom runner instead of Jest's default test runner
132 | // runner: "jest-runner",
133 |
134 | // The paths to modules that run some code to configure or set up the testing environment before each test
135 | // setupFiles: [],
136 |
137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
138 | // setupFilesAfterEnv: [],
139 |
140 | // The number of seconds after which a test is considered as slow and reported as such in the results.
141 | // slowTestThreshold: 5,
142 |
143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
144 | // snapshotSerializers: [],
145 |
146 | // The test environment that will be used for testing
147 | // testEnvironment: "jest-environment-node",
148 |
149 | // Options that will be passed to the testEnvironment
150 | // testEnvironmentOptions: {},
151 |
152 | // Adds a location field to test results
153 | // testLocationInResults: false,
154 |
155 | // The glob patterns Jest uses to detect test files
156 | testMatch: ['**/*.spec.ts'],
157 |
158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
159 | // testPathIgnorePatterns: [
160 | // "/node_modules/"
161 | // ],
162 |
163 | // The regexp pattern or array of patterns that Jest uses to detect test files
164 | // testRegex: [],
165 |
166 | // This option allows the use of a custom results processor
167 | // testResultsProcessor: undefined,
168 |
169 | // This option allows use of a custom test runner
170 | // testRunner: "jest-circus/runner",
171 |
172 | // A map from regular expressions to paths to transformers
173 | // transform: undefined,
174 |
175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
176 | // transformIgnorePatterns: [
177 | // "/node_modules/",
178 | // "\\.pnp\\.[^\\/]+$"
179 | // ],
180 |
181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
182 | // unmockedModulePathPatterns: undefined,
183 |
184 | // Indicates whether each individual test should be reported during the run
185 | // verbose: undefined,
186 |
187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
188 | // watchPathIgnorePatterns: [],
189 |
190 | // Whether to use watchman for file crawling
191 | // watchman: true,
192 | };
193 |
194 | export default config;
195 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "junit-report-builder",
3 | "version": "5.1.1",
4 | "description": "Aimed at making it easier to build Jenkins compatible JUnit XML reports in plugins for testing frameworks",
5 | "main": "./dist/index.js",
6 | "module": "./dist/index.es.js",
7 | "scripts": {
8 | "prepublish": "npm run build",
9 | "check": "prettier --check .",
10 | "format": "prettier --write .",
11 | "test": "jest",
12 | "release": "release-it",
13 | "build": "rollup --configPlugin @rollup/plugin-typescript -c rollup.config.ts",
14 | "lint": "tslint -c tslint.json src/**/*.ts",
15 | "prepare": "husky",
16 | "doctest": "markdown-doctest"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git://github.com/davidparsson/junit-report-builder.git"
21 | },
22 | "keywords": [
23 | "junit",
24 | "xunit",
25 | "report",
26 | "builder"
27 | ],
28 | "author": {
29 | "name": "David Pärsson",
30 | "email": "david@parsson.se"
31 | },
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/davidparsson/junit-report-builder/issues"
35 | },
36 | "homepage": "https://github.com/davidparsson/junit-report-builder",
37 | "engines": {
38 | "node": ">=16"
39 | },
40 | "devDependencies": {
41 | "@rollup/plugin-commonjs": "^26.0.1",
42 | "@rollup/plugin-node-resolve": "^15.2.3",
43 | "@rollup/plugin-typescript": "^11.1.6",
44 | "@types/jest": "^29.5.12",
45 | "@types/lodash": "^4.17.0",
46 | "@types/node": "^20.12.7",
47 | "@types/rimraf": "^4.0.5",
48 | "husky": "^9.1.4",
49 | "jest": "^29.7.0",
50 | "markdown-doctest": "^1.1.0",
51 | "prettier": "^3.0.3",
52 | "release-it": "^16.2.1",
53 | "rimraf": "^2.7.1",
54 | "rollup": "^4.19.1",
55 | "ts-jest": "^29.1.2",
56 | "ts-node": "^10.9.2",
57 | "tslint": "^6.1.3",
58 | "typescript": "^5.4.5"
59 | },
60 | "dependencies": {
61 | "lodash": "^4.17.21",
62 | "make-dir": "^3.1.0",
63 | "xmlbuilder": "^15.1.1"
64 | },
65 | "release-it": {
66 | "github": {
67 | "release": false
68 | },
69 | "hooks": {
70 | "before:init": [
71 | "npm run build",
72 | "npm test",
73 | "npm run check",
74 | "npm run doctest"
75 | ]
76 | }
77 | },
78 | "files": [
79 | "./dist/*"
80 | ],
81 | "typings": "./dist/index.d.ts"
82 | }
83 |
--------------------------------------------------------------------------------
/rollup.config.ts:
--------------------------------------------------------------------------------
1 | import commonjs from '@rollup/plugin-commonjs';
2 | import noderesolve from '@rollup/plugin-node-resolve';
3 | import typescript from '@rollup/plugin-typescript';
4 |
5 | const config = [
6 | {
7 | input: ['src/index.ts'],
8 | output: {
9 | file: 'dist/index.js',
10 | format: 'cjs',
11 | },
12 | plugins: [noderesolve(), commonjs(), typescript()],
13 | },
14 | {
15 | input: ['src/index.ts'],
16 | output: {
17 | file: 'dist/index.es.js',
18 | format: 'es',
19 | },
20 | plugins: [commonjs(), typescript()],
21 | },
22 | ];
23 |
24 | export default config;
25 |
--------------------------------------------------------------------------------
/spec/e2e.spec.ts:
--------------------------------------------------------------------------------
1 | import defaultBuilder, { type Builder, TestCase, TestSuite } from '../dist';
2 | //@ts-ignore
3 | import rmdir from 'rimraf';
4 | import fs from 'fs';
5 |
6 | describe('JUnit Report builder', () => {
7 | let builder: Builder;
8 | beforeEach(() => (builder = defaultBuilder.newBuilder()));
9 |
10 | beforeAll((done) =>
11 | rmdir('build/tmp/test_resources', (error: any) => {
12 | if (error) {
13 | throw new Error(error);
14 | }
15 | done();
16 | }),
17 | );
18 |
19 | const reportWith = (content: string) => '\n' + content;
20 |
21 | it('should produce a report identical to the expected one', () => {
22 | builder.testCase().className('root.test.Class1');
23 | const suite1: TestSuite = builder.testSuite().name('first.Suite');
24 | suite1.testCase().name('Second test');
25 |
26 | const case2: TestCase = suite1.testCase();
27 | case2
28 | .className('suite1.test.Class2')
29 | .name('Third test')
30 | .file('./path-to/the-test-file.coffee')
31 | .property('property name', 'property value')
32 | .multilineProperty('property name 2', 'property value 2');
33 |
34 | const suite2 = builder.testSuite().name('second.Suite');
35 | suite2.testCase().failure('Failure message');
36 | suite2.testCase().stacktrace('Stacktrace');
37 | suite2.testCase().skipped();
38 |
39 | builder.writeTo('build/tmp/test_resources/actual_report.xml');
40 |
41 | const actual = fs.readFileSync('build/tmp/test_resources/actual_report.xml').toString().trim();
42 | const expected = fs.readFileSync('spec/expected_report.xml').toString().trim();
43 |
44 | expect(actual).toBe(expected);
45 | });
46 |
47 | it('should produce an empty list of test suites when nothing reported', () => {
48 | expect(builder.build()).toBe(
49 | // prettier-ignore
50 | '\n' +
51 | '',
52 | );
53 | });
54 |
55 | it('should set testsuites name', () => {
56 | builder.name('testSuitesName');
57 | expect(builder.build()).toBe(
58 | // prettier-ignore
59 | '\n' +
60 | '',
61 | );
62 | });
63 |
64 | it('should produce an empty test suite when a test suite reported', () => {
65 | builder.testSuite();
66 |
67 | expect(builder.build()).toBe(
68 | reportWith(
69 | // prettier-ignore
70 | '\n' +
71 | ' \n' +
72 | '',
73 | ),
74 | );
75 | });
76 |
77 | it('should produce a root test case when reported', () => {
78 | builder.testCase();
79 |
80 | expect(builder.build()).toBe(
81 | reportWith(
82 | // prettier-ignore
83 | '\n' +
84 | ' \n' +
85 | '',
86 | ),
87 | );
88 | });
89 |
90 | it('should produce a root test case with failure when reported', () => {
91 | builder.testCase().failure('it failed');
92 |
93 | expect(builder.build()).toBe(
94 | reportWith(
95 | // prettier-ignore
96 | '\n' +
97 | ' \n' +
98 | ' \n' +
99 | ' \n' +
100 | '',
101 | ),
102 | );
103 | });
104 |
105 | it('should produce a root test case with failure and type when reported', () => {
106 | builder.testCase().failure('it failed', 'the type');
107 |
108 | expect(builder.build()).toBe(
109 | reportWith(
110 | // prettier-ignore
111 | '\n' +
112 | ' \n' +
113 | ' \n' +
114 | ' \n' +
115 | '',
116 | ),
117 | );
118 | });
119 |
120 | it('should produce a root test case with error when reported', () => {
121 | builder.testCase().error('it errored');
122 |
123 | expect(builder.build()).toBe(
124 | reportWith(
125 | // prettier-ignore
126 | '\n' +
127 | ' \n' +
128 | ' \n' +
129 | ' \n' +
130 | '',
131 | ),
132 | );
133 | });
134 |
135 | it('should produce a root test case with error, type and content when reported', () => {
136 | builder.testCase().error('it errored', 'the type', 'the content');
137 |
138 | expect(builder.build()).toBe(
139 | reportWith(
140 | // prettier-ignore
141 | '\n' +
142 | ' \n' +
143 | ' \n' +
144 | ' \n' +
145 | '',
146 | ),
147 | );
148 | });
149 |
150 | it('should produce a test suite with a test case when reported', () => {
151 | builder.testSuite().testCase();
152 |
153 | expect(builder.build()).toBe(
154 | reportWith(
155 | // prettier-ignore
156 | '\n' +
157 | ' \n' +
158 | ' \n' +
159 | ' \n' +
160 | '',
161 | ),
162 | );
163 | });
164 |
165 | it('should produce a test suite with a failed test case when reported', () => {
166 | builder.testSuite().testCase().failure();
167 |
168 | expect(builder.build()).toBe(
169 | reportWith(
170 | // prettier-ignore
171 | '\n' +
172 | ' \n' +
173 | ' \n' +
174 | ' \n' +
175 | ' \n' +
176 | ' \n' +
177 | '',
178 | ),
179 | );
180 | });
181 |
182 | it('should produce a test suite with an errored test case when reported', () => {
183 | builder.testSuite().testCase().error();
184 |
185 | expect(builder.build()).toBe(
186 | reportWith(
187 | // prettier-ignore
188 | '\n' +
189 | ' \n' +
190 | ' \n' +
191 | ' \n' +
192 | ' \n' +
193 | ' \n' +
194 | '',
195 | ),
196 | );
197 | });
198 |
199 | it('should produce a test suite with a skipped test case when reported', () => {
200 | builder.testSuite().testCase().skipped();
201 |
202 | expect(builder.build()).toBe(
203 | reportWith(
204 | // prettier-ignore
205 | '\n' +
206 | ' \n' +
207 | ' \n' +
208 | ' \n' +
209 | ' \n' +
210 | ' \n' +
211 | '',
212 | ),
213 | );
214 | });
215 |
216 | it('should add the reported time to the test sute', () => {
217 | builder.testSuite().time(2.5);
218 |
219 | expect(builder.build()).toBe(
220 | reportWith(
221 | // prettier-ignore
222 | '\n' +
223 | ' \n' +
224 | '',
225 | ),
226 | );
227 | });
228 |
229 | it('should add the reported timestamp to the test sute', () => {
230 | builder.testSuite().timestamp(new Date(2015, 10, 22, 13, 37, 59, 123));
231 |
232 | expect(builder.build()).toBe(
233 | reportWith(
234 | // prettier-ignore
235 | '\n' +
236 | ' \n' +
237 | '',
238 | ),
239 | );
240 | });
241 |
242 | it('should add the reported time to the test case', () => {
243 | builder.testSuite().testCase().time(2.5);
244 |
245 | expect(builder.build()).toBe(
246 | reportWith(
247 | // prettier-ignore
248 | '\n' +
249 | ' \n' +
250 | ' \n' +
251 | ' \n' +
252 | '',
253 | ),
254 | );
255 | });
256 |
257 | it('should print the reported standard output log to system-out', () => {
258 | builder.testSuite().testCase().standardOutput('This was written to stdout!');
259 |
260 | expect(builder.build()).toBe(
261 | reportWith(
262 | // prettier-ignore
263 | '\n' +
264 | ' \n' +
265 | ' \n' +
266 | ' \n' +
267 | ' \n' +
268 | ' \n' +
269 | '',
270 | ),
271 | );
272 | });
273 |
274 | it('should print the reported standard error log to system-err', () => {
275 | builder.testSuite().testCase().standardError('This was written to stderr!');
276 |
277 | expect(builder.build()).toBe(
278 | reportWith(
279 | // prettier-ignore
280 | '\n' +
281 | ' \n' +
282 | ' \n' +
283 | ' \n' +
284 | ' \n' +
285 | ' \n' +
286 | '',
287 | ),
288 | );
289 | });
290 |
291 | it('should print the reported attachment to system-err', () => {
292 | builder
293 | .testSuite()
294 | .testCase()
295 | .standardError('This was written to stderr!')
296 | .errorAttachment('absolute/path/to/attachment');
297 |
298 | expect(builder.build()).toBe(
299 | reportWith(
300 | // prettier-ignore
301 | '\n' +
302 | ' \n' +
303 | ' \n' +
304 | ' \n' +
305 | ' \n' +
306 | ' [[ATTACHMENT|absolute/path/to/attachment]]\n' +
307 | ' \n' +
308 | ' \n' +
309 | ' \n' +
310 | '',
311 | ),
312 | );
313 | });
314 |
315 | it('should output test suites and test cases in the order reported', () => {
316 | builder.testCase().name('1');
317 | builder.testSuite().name('2');
318 | builder.testCase().name('3');
319 |
320 | expect(builder.build()).toBe(
321 | reportWith(
322 | // prettier-ignore
323 | '\n' +
324 | ' \n' +
325 | ' \n' +
326 | ' \n' +
327 | '',
328 | ),
329 | );
330 | });
331 |
332 | it('should builder supports emojis in cdata tags', () => {
333 | builder.testCase().standardOutput('Emoji: 🤦');
334 |
335 | expect(builder.build()).toBe(
336 | reportWith(
337 | // prettier-ignore
338 | '\n' +
339 | ' \n' +
340 | ' \n' +
341 | ' \n' +
342 | '',
343 | ),
344 | );
345 | });
346 |
347 | it('should escape quotes', () => {
348 | builder.testCase().error('it is "quoted"');
349 |
350 | expect(builder.build()).toBe(
351 | reportWith(
352 | // prettier-ignore
353 | '\n' +
354 | ' \n' +
355 | ' \n' +
356 | ' \n' +
357 | '',
358 | ),
359 | );
360 | });
361 |
362 | it('should remove invalid characters', () => {
363 | builder.testCase().error('Invalid\x00Characters\x08Stripped');
364 |
365 | expect(builder.build()).toBe(
366 | reportWith(
367 | // prettier-ignore
368 | '\n' +
369 | ' \n' +
370 | ' \n' +
371 | ' \n' +
372 | '',
373 | ),
374 | );
375 | });
376 | });
377 |
--------------------------------------------------------------------------------
/spec/expected_report.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | property value 2
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/spec/test_case.spec.ts:
--------------------------------------------------------------------------------
1 | import { XMLElement } from 'xmlbuilder';
2 | import { TestCase } from '../src/test_case';
3 |
4 | describe('Test Case builder', () => {
5 | let testCase: TestCase;
6 | let parentElement: XMLElement;
7 | let propertiesElement: XMLElement;
8 | let testCaseElement: XMLElement;
9 | let failureElement: XMLElement;
10 | let errorElement: XMLElement;
11 | let skippedElement: XMLElement;
12 | let systemOutElement: XMLElement;
13 | let systemErrElement: XMLElement;
14 |
15 | const createElementMock = () =>
16 | ({
17 | ele: jest.fn(),
18 | cdata: jest.fn(),
19 | att: jest.fn(),
20 | txt: jest.fn(),
21 | }) as unknown as XMLElement;
22 |
23 | beforeEach(() => {
24 | testCase = new TestCase();
25 | parentElement = createElementMock();
26 | testCaseElement = createElementMock();
27 | failureElement = createElementMock();
28 | errorElement = createElementMock();
29 | skippedElement = createElementMock();
30 | systemOutElement = createElementMock();
31 | systemErrElement = createElementMock();
32 | propertiesElement = createElementMock();
33 |
34 | (parentElement.ele as any as jest.SpyInstance).mockImplementation((elementName: string) => {
35 | switch (elementName) {
36 | case 'testcase':
37 | return testCaseElement;
38 | }
39 | throw new Error(`Unexpected element name: ${elementName}`);
40 | });
41 |
42 | (testCaseElement.ele as any as jest.SpyInstance).mockImplementation((elementName: string) => {
43 | switch (elementName) {
44 | case 'failure':
45 | return failureElement;
46 | case 'skipped':
47 | return skippedElement;
48 | case 'system-out':
49 | return systemOutElement;
50 | case 'system-err':
51 | return systemErrElement;
52 | case 'properties':
53 | return propertiesElement;
54 | case 'error':
55 | return errorElement;
56 | }
57 | throw new Error(`Unexpected element name: ${elementName}`);
58 | });
59 |
60 | (systemErrElement.cdata as any as jest.SpyInstance).mockImplementation((stdError: string) => {
61 | switch (stdError) {
62 | case 'Error with screenshot':
63 | return systemErrElement;
64 | case 'Standard error':
65 | return createElementMock();
66 | case 'Second stderr':
67 | return createElementMock();
68 | }
69 | throw new Error(`Unexpected element name: ${stdError}`);
70 | });
71 | });
72 |
73 | it('should build a testcase element without attributes by default', () => {
74 | testCase.build(parentElement);
75 |
76 | expect(parentElement.ele).toHaveBeenCalledWith('testcase', {});
77 | });
78 |
79 | it('should add the provided class name as an attribute', () => {
80 | testCase.className('my.Class');
81 |
82 | testCase.build(parentElement);
83 |
84 | expect(parentElement.ele).toHaveBeenCalledWith('testcase', {
85 | classname: 'my.Class',
86 | });
87 | });
88 |
89 | it('should add the provided name as an attribute', () => {
90 | testCase.name('my test name');
91 |
92 | testCase.build(parentElement);
93 |
94 | expect(parentElement.ele).toHaveBeenCalledWith('testcase', {
95 | name: 'my test name',
96 | });
97 | });
98 |
99 | it('should add the provided time as an attribute', () => {
100 | testCase.time(100);
101 |
102 | testCase.build(parentElement);
103 |
104 | expect(parentElement.ele).toHaveBeenCalledWith('testcase', {
105 | time: 100,
106 | });
107 | });
108 |
109 | it('should add the provided file as an attribute', () => {
110 | testCase.file('./path-to/the-test-file.coffee');
111 |
112 | testCase.build(parentElement);
113 |
114 | expect(parentElement.ele).toHaveBeenCalledWith('testcase', {
115 | file: './path-to/the-test-file.coffee',
116 | });
117 | });
118 |
119 | it('should add the provided property as elements', () => {
120 | testCase.property('property name', 'property value');
121 |
122 | testCase.build(parentElement);
123 |
124 | expect(propertiesElement.ele).toHaveBeenCalledWith('property', {
125 | name: 'property name',
126 | value: 'property value',
127 | });
128 | });
129 |
130 | it('should add the provided property with textContent as elements with textContent', () => {
131 | testCase.multilineProperty('property name', 'property value');
132 |
133 | testCase.build(parentElement);
134 |
135 | expect(propertiesElement.ele).toHaveBeenCalledWith(
136 | 'property',
137 | {
138 | name: 'property name',
139 | },
140 | 'property value',
141 | );
142 | });
143 |
144 | it('should add a failure node when test failed', () => {
145 | testCase.failure();
146 |
147 | testCase.build(parentElement);
148 |
149 | expect(testCaseElement.ele).toHaveBeenCalledWith('failure', {});
150 | });
151 |
152 | it('should add a failure node with message when test failed', () => {
153 | testCase.failure('Failure message');
154 |
155 | testCase.build(parentElement);
156 |
157 | expect(testCaseElement.ele).toHaveBeenCalledWith('failure', {
158 | message: 'Failure message',
159 | });
160 | });
161 |
162 | it('should add a failure node with message and type when test failed', () => {
163 | testCase.failure('Failure message', 'Failure type');
164 |
165 | testCase.build(parentElement);
166 |
167 | expect(testCaseElement.ele).toHaveBeenCalledWith('failure', {
168 | message: 'Failure message',
169 | type: 'Failure type',
170 | });
171 | });
172 |
173 | it('should add the stactrace to the failure node when stacktrace provided', () => {
174 | testCase.stacktrace('This is a stacktrace');
175 |
176 | testCase.build(parentElement);
177 |
178 | expect(failureElement.cdata).toHaveBeenCalledWith('This is a stacktrace');
179 | });
180 |
181 | it('should add a failure node with message and stacktrace when both provided', () => {
182 | testCase.failure('Failure message');
183 | testCase.stacktrace('This is a stacktrace');
184 |
185 | testCase.build(parentElement);
186 |
187 | expect(testCaseElement.ele).toHaveBeenCalledWith('failure', {
188 | message: 'Failure message',
189 | });
190 | expect(failureElement.cdata).toHaveBeenCalledWith('This is a stacktrace');
191 | });
192 |
193 | it('should add a skipped node when test failed', () => {
194 | testCase.skipped();
195 |
196 | testCase.build(parentElement);
197 |
198 | expect(testCaseElement.ele).toHaveBeenCalledWith('skipped');
199 | });
200 |
201 | it('should add a failure node when test failed', () => {
202 | testCase.failure();
203 |
204 | testCase.build(parentElement);
205 |
206 | expect(testCaseElement.ele).toHaveBeenCalledWith('failure', {});
207 | });
208 |
209 | it('should add an error node when test errored', () => {
210 | testCase.error();
211 |
212 | testCase.build(parentElement);
213 |
214 | expect(testCaseElement.ele).toHaveBeenCalledWith('error', {});
215 | });
216 |
217 | it('should add a error node with message when test errored', () => {
218 | testCase.error('Error message');
219 |
220 | testCase.build(parentElement);
221 |
222 | expect(testCaseElement.ele).toHaveBeenCalledWith('error', {
223 | message: 'Error message',
224 | });
225 | });
226 |
227 | it('should add a error node with message and type when test errored', () => {
228 | testCase.error('Error message', 'Error type');
229 |
230 | testCase.build(parentElement);
231 |
232 | expect(testCaseElement.ele).toHaveBeenCalledWith('error', {
233 | message: 'Error message',
234 | type: 'Error type',
235 | });
236 | });
237 |
238 | it('should add a error node with message, type and content when test errored', () => {
239 | testCase.error('Error message', 'Error type', 'Error content');
240 |
241 | testCase.build(parentElement);
242 |
243 | expect(testCaseElement.ele).toHaveBeenCalledWith('error', {
244 | message: 'Error message',
245 | type: 'Error type',
246 | });
247 | expect(errorElement.cdata).toHaveBeenCalledWith('Error content');
248 | });
249 |
250 | describe('system-out', () => {
251 | it('should not create a system-out tag when nothing logged', () => {
252 | testCase.build(parentElement);
253 |
254 | expect(testCaseElement.ele).not.toHaveBeenCalledWith('system-out', expect.anything());
255 | });
256 |
257 | it('should create a system-out tag with the log as a cdata tag', () => {
258 | testCase.standardOutput('Standard output');
259 |
260 | testCase.build(parentElement);
261 |
262 | expect(testCaseElement.ele).toHaveBeenCalledWith('system-out');
263 | expect(systemOutElement.cdata).toHaveBeenCalledWith('Standard output');
264 | });
265 |
266 | it('should only add the last logged content to system-out', () => {
267 | testCase.standardOutput('Standard output').standardOutput('Second stdout');
268 |
269 | testCase.build(parentElement);
270 |
271 | expect(systemOutElement.cdata).not.toHaveBeenCalledWith('Standard output');
272 | expect(systemOutElement.cdata).toHaveBeenCalledWith('Second stdout');
273 | });
274 | });
275 |
276 | describe('system-err', () => {
277 | it('should not create a system-err tag when nothing logged', () => {
278 | testCase.build(parentElement);
279 |
280 | expect(testCaseElement.ele).not.toHaveBeenCalledWith('system-err', expect.anything());
281 | });
282 |
283 | it('should create a system-err tag with the log as a cdata tag', () => {
284 | testCase.standardError('Standard error');
285 |
286 | testCase.build(parentElement);
287 |
288 | expect(testCaseElement.ele).toHaveBeenCalledWith('system-err');
289 | expect(systemErrElement.cdata).toHaveBeenCalledWith('Standard error');
290 | });
291 |
292 | it('should only add the last logged content to system-err', () => {
293 | testCase.standardError('Standard error').standardError('Second stderr');
294 |
295 | testCase.build(parentElement);
296 |
297 | expect(systemErrElement.cdata).not.toHaveBeenCalledWith('Standard error');
298 | expect(systemErrElement.cdata).toHaveBeenCalledWith('Second stderr');
299 | });
300 |
301 | it('should add an attachment to system-err', () => {
302 | testCase.standardError('Error with screenshot');
303 | testCase.errorAttachment('absolute/path/to/attachment');
304 |
305 | testCase.build(parentElement);
306 |
307 | expect(testCaseElement.ele).toHaveBeenCalledWith('system-err');
308 | expect(systemErrElement.cdata).toHaveBeenCalledWith('Error with screenshot');
309 | expect(systemErrElement.txt).toHaveBeenCalledWith('[[ATTACHMENT|absolute/path/to/attachment]]');
310 | });
311 | });
312 |
313 | describe('failure counting', () => {
314 | it('should should have 0 failures when not failed', () => expect(testCase.getFailureCount()).toBe(0));
315 |
316 | it('should should have 1 failure when failed', () => {
317 | testCase.failure();
318 |
319 | expect(testCase.getFailureCount()).toBe(1);
320 | });
321 |
322 | it('should should have 1 failure when failed many times', () => {
323 | testCase.failure();
324 | testCase.failure();
325 |
326 | expect(testCase.getFailureCount()).toBe(1);
327 | });
328 | });
329 |
330 | describe('error counting', () => {
331 | it('should should have 0 errors when error not called', () => expect(testCase.getErrorCount()).toBe(0));
332 |
333 | it('should should have 1 error when error called', () => {
334 | testCase.error();
335 |
336 | expect(testCase.getErrorCount()).toBe(1);
337 | });
338 |
339 | it('should should have 1 error when error called many times', () => {
340 | testCase.error();
341 | testCase.error();
342 |
343 | expect(testCase.getErrorCount()).toBe(1);
344 | });
345 | });
346 |
347 | describe('skipped counting', () => {
348 | it('should be 0 when skipped not called', () => expect(testCase.getSkippedCount()).toBe(0));
349 |
350 | it('should be 1 when skipped called', () => {
351 | testCase.skipped();
352 |
353 | expect(testCase.getSkippedCount()).toBe(1);
354 | });
355 |
356 | it('should be 1 when skipped called many times', () => {
357 | testCase.skipped();
358 | testCase.skipped();
359 |
360 | expect(testCase.getSkippedCount()).toBe(1);
361 | });
362 | });
363 |
364 | describe('test case counting', () => {
365 | it('should be 1 for a test case', () => expect(testCase.getTestCaseCount()).toBe(1));
366 | });
367 | });
368 |
--------------------------------------------------------------------------------
/spec/test_suite.spec.ts:
--------------------------------------------------------------------------------
1 | import { XMLElement } from 'xmlbuilder';
2 | import { Factory } from '../src/factory';
3 | import { TestSuite } from '../src/test_suite';
4 | import { TestCase } from '../src/test_case';
5 |
6 | describe('Test Suite builder', () => {
7 | let testSuite: TestSuite;
8 | let parentElement: XMLElement;
9 | let testSuiteElement: XMLElement;
10 | let propertiesElement: XMLElement;
11 | let testCase: TestCase;
12 |
13 | beforeEach(() => {
14 | testCase = {
15 | build: jest.fn(),
16 | getFailureCount: jest.fn().mockReturnValue(0),
17 | getErrorCount: jest.fn().mockReturnValue(0),
18 | getSkippedCount: jest.fn().mockReturnValue(0),
19 | getTestCaseCount: jest.fn().mockReturnValue(1),
20 | } as unknown as TestCase;
21 |
22 | const factory = {
23 | newTestCase: jest.fn().mockReturnValue(testCase),
24 | } as unknown as Factory;
25 |
26 | propertiesElement = { ele: jest.fn() } as unknown as XMLElement;
27 | testSuiteElement = {
28 | ele: jest.fn().mockImplementation((elementName: string) => {
29 | switch (elementName) {
30 | case 'properties':
31 | return propertiesElement;
32 | }
33 | throw new Error(`Unexpected element name: ${elementName}`);
34 | }),
35 | } as unknown as XMLElement;
36 | parentElement = {
37 | ele: jest.fn().mockImplementation((elementName: string) => {
38 | switch (elementName) {
39 | case 'testsuite':
40 | return testSuiteElement;
41 | }
42 | throw new Error(`Unexpected element name: ${elementName}`);
43 | }),
44 | } as unknown as XMLElement;
45 | testSuite = new TestSuite(factory);
46 | });
47 |
48 | it('should create a testsuite element when building', () => {
49 | testSuite.build(parentElement);
50 |
51 | expect(parentElement.ele).toHaveBeenCalledWith(
52 | 'testsuite',
53 | expect.objectContaining({
54 | tests: 0,
55 | }),
56 | );
57 | });
58 |
59 | it('should add the provided name as an attribute', () => {
60 | testSuite.name('suite name');
61 |
62 | testSuite.build(parentElement);
63 |
64 | expect(parentElement.ele).toHaveBeenCalledWith(
65 | 'testsuite',
66 | expect.objectContaining({
67 | name: 'suite name',
68 | }),
69 | );
70 | });
71 |
72 | it('should count the number of testcase elements', () => {
73 | testSuite.testCase();
74 |
75 | testSuite.build(parentElement);
76 |
77 | expect(parentElement.ele).toHaveBeenCalledWith(
78 | 'testsuite',
79 | expect.objectContaining({
80 | tests: 1,
81 | }),
82 | );
83 | });
84 |
85 | it('should add the provided time as an attribute', () => {
86 | testSuite.time(12.3);
87 |
88 | testSuite.build(parentElement);
89 |
90 | expect(parentElement.ele).toHaveBeenCalledWith(
91 | 'testsuite',
92 | expect.objectContaining({
93 | time: 12.3,
94 | }),
95 | );
96 | });
97 |
98 | it('should add the provided timestamp as an attribute', () => {
99 | testSuite.timestamp('2014-10-21T12:36:58');
100 |
101 | testSuite.build(parentElement);
102 |
103 | expect(parentElement.ele).toHaveBeenCalledWith(
104 | 'testsuite',
105 | expect.objectContaining({
106 | timestamp: '2014-10-21T12:36:58',
107 | }),
108 | );
109 | });
110 |
111 | it('should format the provided timestamp date and add it as an attribute', () => {
112 | testSuite.timestamp(new Date(2015, 10, 22, 13, 37, 59, 123));
113 |
114 | testSuite.build(parentElement);
115 |
116 | expect(parentElement.ele).toHaveBeenCalledWith(
117 | 'testsuite',
118 | expect.objectContaining({
119 | timestamp: '2015-11-22T13:37:59',
120 | }),
121 | );
122 | });
123 |
124 | it('should add the provided property as elements', () => {
125 | testSuite.property('property name', 'property value');
126 |
127 | testSuite.build(parentElement);
128 |
129 | expect(propertiesElement.ele).toHaveBeenCalledWith('property', {
130 | name: 'property name',
131 | value: 'property value',
132 | });
133 | });
134 |
135 | it('should add the provided property with textContent as element with textContent', () => {
136 | testSuite.multilineProperty('property name', 'property value');
137 |
138 | testSuite.build(parentElement);
139 |
140 | expect(propertiesElement.ele).toHaveBeenCalledWith(
141 | 'property',
142 | {
143 | name: 'property name',
144 | },
145 | 'property value',
146 | );
147 | });
148 |
149 | it('should add all the provided properties as elements', () => {
150 | testSuite.property('name 1', 'value 1');
151 | testSuite.property('name 2', 'value 2');
152 |
153 | testSuite.build(parentElement);
154 |
155 | expect(propertiesElement.ele).toHaveBeenCalledWith('property', {
156 | name: 'name 1',
157 | value: 'value 1',
158 | });
159 | expect(propertiesElement.ele).toHaveBeenCalledWith('property', {
160 | name: 'name 2',
161 | value: 'value 2',
162 | });
163 | });
164 |
165 | it('should pass testsuite element to the test case when building', () => {
166 | testSuite.testCase();
167 |
168 | testSuite.build(parentElement);
169 |
170 | expect(testCase.build).toHaveBeenCalledWith(testSuiteElement);
171 | });
172 |
173 | it('should pass testsuite element to all created test cases when building', () => {
174 | testSuite.testCase();
175 | testSuite.testCase();
176 |
177 | testSuite.build(parentElement);
178 |
179 | const spy: jest.SpyInstance = testCase.build as any;
180 | expect(spy).toHaveBeenCalledTimes(2);
181 | expect(spy.mock.calls[0][0]).toEqual(testSuiteElement);
182 | expect(spy.mock.calls[1][0]).toEqual(testSuiteElement);
183 | });
184 |
185 | it('should return the newly created test case', () => expect(testSuite.testCase()).toEqual(testCase));
186 |
187 | it('should itself when configuring property', () => expect(testSuite.property('name', 'value')).toEqual(testSuite));
188 |
189 | it('should itself when configuring name', () => expect(testSuite.name('name')).toEqual(testSuite));
190 |
191 | describe('failure count', () => {
192 | it('should not report any failures when no test cases', () => {
193 | testSuite.build(parentElement);
194 |
195 | expect(parentElement.ele).toHaveBeenCalledWith(
196 | 'testsuite',
197 | expect.objectContaining({
198 | failures: 0,
199 | }),
200 | );
201 | });
202 |
203 | it('should not report any failures when no test cases failed', () => {
204 | testSuite.testCase();
205 | testSuite.testCase();
206 |
207 | testSuite.build(parentElement);
208 |
209 | expect(parentElement.ele).toHaveBeenCalledWith(
210 | 'testsuite',
211 | expect.objectContaining({
212 | failures: 0,
213 | }),
214 | );
215 | });
216 |
217 | it('should report two failures when two test cases failed', () => {
218 | (testCase.getFailureCount as any as jest.SpyInstance).mockReturnValue(1);
219 |
220 | testSuite.testCase();
221 | testSuite.testCase();
222 |
223 | testSuite.build(parentElement);
224 |
225 | expect(parentElement.ele).toHaveBeenCalledWith(
226 | 'testsuite',
227 | expect.objectContaining({
228 | failures: 2,
229 | }),
230 | );
231 | });
232 | });
233 |
234 | describe('error count', () => {
235 | it('should not report any errors when no test cases', () => {
236 | testSuite.build(parentElement);
237 |
238 | expect(parentElement.ele).toHaveBeenCalledWith(
239 | 'testsuite',
240 | expect.objectContaining({
241 | errors: 0,
242 | }),
243 | );
244 | });
245 |
246 | it('should not report any errors when no test cases errored', () => {
247 | testSuite.testCase();
248 | testSuite.testCase();
249 |
250 | testSuite.build(parentElement);
251 |
252 | expect(parentElement.ele).toHaveBeenCalledWith(
253 | 'testsuite',
254 | expect.objectContaining({
255 | errors: 0,
256 | }),
257 | );
258 | });
259 |
260 | it('should report two errors when two test cases errored', () => {
261 | (testCase.getErrorCount as any as jest.SpyInstance).mockReturnValue(1);
262 |
263 | testSuite.testCase();
264 | testSuite.testCase();
265 |
266 | testSuite.build(parentElement);
267 |
268 | expect(parentElement.ele).toHaveBeenCalledWith(
269 | 'testsuite',
270 | expect.objectContaining({
271 | errors: 2,
272 | }),
273 | );
274 | });
275 | });
276 |
277 | describe('skipped count', () => {
278 | it('should not report any skipped when no test cases', () => {
279 | testSuite.build(parentElement);
280 |
281 | expect(parentElement.ele).toHaveBeenCalledWith(
282 | 'testsuite',
283 | expect.objectContaining({
284 | skipped: 0,
285 | }),
286 | );
287 | });
288 |
289 | it('should not report any skipped when no test cases errored', () => {
290 | testSuite.testCase();
291 | testSuite.testCase();
292 |
293 | testSuite.build(parentElement);
294 |
295 | expect(parentElement.ele).toHaveBeenCalledWith(
296 | 'testsuite',
297 | expect.objectContaining({
298 | skipped: 0,
299 | }),
300 | );
301 | });
302 |
303 | it('should report two skipped when two test cases errored', () => {
304 | (testCase.getSkippedCount as any as jest.SpyInstance).mockReturnValue(1);
305 |
306 | testSuite.testCase();
307 | testSuite.testCase();
308 |
309 | testSuite.build(parentElement);
310 |
311 | expect(parentElement.ele).toHaveBeenCalledWith(
312 | 'testsuite',
313 | expect.objectContaining({
314 | skipped: 2,
315 | }),
316 | );
317 | });
318 | });
319 | });
320 |
--------------------------------------------------------------------------------
/src/builder.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import makeDir from 'make-dir';
3 | import fs from 'fs';
4 | import { TestSuites } from './test_suites';
5 | import { TestCase } from './test_case';
6 | import { TestSuite } from './test_suite';
7 | import { Factory } from './factory';
8 |
9 | export class JUnitReportBuilder {
10 | private _rootTestSuites: TestSuites;
11 | /**
12 | * @param factory
13 | */
14 | constructor(private _factory: Factory) {
15 | this._rootTestSuites = new TestSuites(_factory);
16 | }
17 |
18 | /**
19 | * @param reportPath
20 | */
21 | writeTo(reportPath: string) {
22 | makeDir.sync(path.dirname(reportPath));
23 | fs.writeFileSync(reportPath, this.build(), 'utf8');
24 | }
25 |
26 | /**
27 | * @returns a string representation of the JUnit report
28 | */
29 | build(): string {
30 | var xmlTree = this._rootTestSuites.build();
31 | return xmlTree.end({ pretty: true });
32 | }
33 |
34 | /**
35 | * @param name
36 | * @returns this
37 | */
38 | name(name: string): this {
39 | this._rootTestSuites.name(name);
40 | return this;
41 | }
42 |
43 | /**
44 | * @returns a test suite
45 | */
46 | testSuite(): TestSuite {
47 | return this._rootTestSuites.testSuite();
48 | }
49 |
50 | /**
51 | * @returns a test case
52 | */
53 | testCase(): TestCase {
54 | return this._rootTestSuites.testCase();
55 | }
56 |
57 | /**
58 | * @returns a new builder
59 | */
60 | newBuilder(): JUnitReportBuilder {
61 | return this._factory.newBuilder();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/factory.ts:
--------------------------------------------------------------------------------
1 | import { JUnitReportBuilder } from './builder';
2 | import { TestSuites } from './test_suites';
3 | import { TestSuite } from './test_suite';
4 | import { TestCase } from './test_case';
5 |
6 | export class Factory {
7 | /**
8 | * @returns a newly created builder
9 | */
10 | newBuilder(): JUnitReportBuilder {
11 | return new JUnitReportBuilder(this);
12 | }
13 |
14 | /**
15 | * @returns a newly created test suite
16 | */
17 | newTestSuite(): TestSuite {
18 | return new TestSuite(this);
19 | }
20 |
21 | /**
22 | * @returns a newly created test case
23 | */
24 | newTestCase(): TestCase {
25 | return new TestCase();
26 | }
27 |
28 | /**
29 | * @returns a newly created test suites
30 | */
31 | newTestSuites() {
32 | return new TestSuites(this);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Factory } from './factory';
2 |
3 | export default new Factory().newBuilder();
4 |
5 | export type { JUnitReportBuilder as Builder } from './builder';
6 | export type { TestCase } from './test_case';
7 | export type { TestSuite } from './test_suite';
8 |
--------------------------------------------------------------------------------
/src/test_case.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { TestNode } from './test_node';
3 | import type { XMLElement } from 'xmlbuilder';
4 |
5 | export class TestCase extends TestNode {
6 | private _error: boolean;
7 | private _failure: boolean;
8 | private _skipped: boolean;
9 | private _standardOutput: string | undefined;
10 | private _standardError: string | undefined;
11 | private _stacktrace: string | undefined;
12 | private _errorAttributes: any;
13 | private _failureAttributes: any;
14 | private _errorAttachment: string | undefined;
15 | private _errorContent: string | undefined;
16 |
17 | constructor() {
18 | super('testcase');
19 | this._error = false;
20 | this._failure = false;
21 | this._skipped = false;
22 | this._standardOutput = undefined;
23 | this._standardError = undefined;
24 | this._stacktrace = undefined;
25 | this._errorAttributes = {};
26 | this._failureAttributes = {};
27 | this._errorAttachment = undefined;
28 | this._errorContent = undefined;
29 | }
30 |
31 | /**
32 | * @param className
33 | * @returns this
34 | */
35 | className(className: string): this {
36 | this._attributes.classname = className;
37 | return this;
38 | }
39 |
40 | /**
41 | * @param {string} filepath
42 | * @returns {TestCase}
43 | */
44 | file(filepath: string) {
45 | this._attributes.file = filepath;
46 | return this;
47 | }
48 |
49 | /**
50 | * @param message
51 | * @param type
52 | * @returns this
53 | */
54 | failure(message?: string, type?: string): this {
55 | this._failure = true;
56 | if (message) {
57 | this._failureAttributes.message = message;
58 | }
59 | if (type) {
60 | this._failureAttributes.type = type;
61 | }
62 | return this;
63 | }
64 |
65 | /**
66 | * @param message
67 | * @param type
68 | * @param content
69 | * @returns this
70 | */
71 | error(message?: string, type?: string, content?: string): this {
72 | this._error = true;
73 | if (message) {
74 | this._errorAttributes.message = message;
75 | }
76 | if (type) {
77 | this._errorAttributes.type = type;
78 | }
79 | if (content) {
80 | this._errorContent = content;
81 | }
82 | return this;
83 | }
84 |
85 | /**
86 | * @param stacktrace
87 | * @returns this
88 | */
89 | stacktrace(stacktrace?: string): this {
90 | this._failure = true;
91 | this._stacktrace = stacktrace;
92 | return this;
93 | }
94 |
95 | /**
96 | * @returns this
97 | */
98 | skipped(): this {
99 | this._skipped = true;
100 | return this;
101 | }
102 |
103 | /**
104 | * @param log
105 | * @returns this
106 | */
107 | standardOutput(log?: string): this {
108 | this._standardOutput = log;
109 | return this;
110 | }
111 |
112 | /**
113 | * @param log
114 | * @returns this
115 | */
116 | standardError(log?: string): this {
117 | this._standardError = log;
118 | return this;
119 | }
120 |
121 | /**
122 | * @inheritdoc
123 | */
124 | override getTestCaseCount(): number {
125 | return 1;
126 | }
127 |
128 | /**
129 | * @inheritdoc
130 | */
131 | override getFailureCount(): number {
132 | return this._failure ? 1 : 0;
133 | }
134 |
135 | /**
136 | * @inheritdoc
137 | */
138 | override getErrorCount(): number {
139 | return this._error ? 1 : 0;
140 | }
141 |
142 | /**
143 | * @inheritdoc
144 | */
145 | override getSkippedCount() {
146 | return this._skipped ? 1 : 0;
147 | }
148 |
149 | /**
150 | *
151 | * @param path
152 | * @returns this
153 | */
154 | errorAttachment(path: string): this {
155 | this._errorAttachment = path;
156 | return this;
157 | }
158 |
159 | /**
160 | * @param parentElement - the parent element
161 | */
162 | build(parentElement: XMLElement) {
163 | const testCaseElement = this.buildNode(this.createElement(parentElement));
164 | if (this._failure) {
165 | var failureElement = testCaseElement.ele('failure', this._failureAttributes);
166 | if (this._stacktrace) {
167 | failureElement.cdata(this._stacktrace);
168 | }
169 | }
170 | if (this._error) {
171 | var errorElement = testCaseElement.ele('error', this._errorAttributes);
172 | if (this._errorContent) {
173 | errorElement.cdata(this._errorContent);
174 | }
175 | }
176 | if (this._skipped) {
177 | testCaseElement.ele('skipped');
178 | }
179 | if (this._standardOutput) {
180 | testCaseElement.ele('system-out').cdata(this._standardOutput);
181 | }
182 | var systemError;
183 | if (this._standardError) {
184 | systemError = testCaseElement.ele('system-err').cdata(this._standardError);
185 |
186 | if (this._errorAttachment) {
187 | systemError.txt('[[ATTACHMENT|' + this._errorAttachment + ']]');
188 | }
189 | }
190 | return testCaseElement;
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/src/test_group.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import { TestNode } from './test_node';
3 | import type { TestCase } from './test_case';
4 | import type { XMLElement } from 'xmlbuilder';
5 | import type { Factory } from './factory';
6 | import type { TestSuite } from './test_suite';
7 |
8 | export abstract class TestGroup extends TestNode {
9 | protected _children: (TestCase | TestSuite)[];
10 |
11 | /**
12 | * @param factory
13 | * @param elementName
14 | */
15 | constructor(
16 | protected _factory: Factory,
17 | elementName: string,
18 | ) {
19 | super(elementName);
20 | this._children = [];
21 | }
22 |
23 | /**
24 | * @param timestamp
25 | * @returns this
26 | */
27 | timestamp(timestamp: string | Date): this {
28 | if (_.isDate(timestamp)) {
29 | this._attributes.timestamp = this.formatDate(timestamp);
30 | } else {
31 | this._attributes.timestamp = timestamp;
32 | }
33 | return this;
34 | }
35 |
36 | /**
37 | * @returns the created test case
38 | */
39 | testCase(): TestCase {
40 | var testCase = this._factory.newTestCase();
41 | this._children.push(testCase);
42 | return testCase;
43 | }
44 |
45 | /**
46 | * @inheritdoc
47 | */
48 | override getTestCaseCount(): number {
49 | return this._sumTestCaseCounts((testCase: TestCase | TestSuite) => {
50 | return testCase.getTestCaseCount();
51 | });
52 | }
53 |
54 | /**
55 | * @inheritdoc
56 | */
57 | override getFailureCount(): number {
58 | return this._sumTestCaseCounts((testCase: TestCase | TestSuite) => {
59 | return testCase.getFailureCount();
60 | });
61 | }
62 |
63 | /**
64 | * @inheritdoc
65 | */
66 | override getErrorCount(): number {
67 | return this._sumTestCaseCounts((testCase: TestCase | TestSuite) => {
68 | return testCase.getErrorCount();
69 | });
70 | }
71 |
72 | /**
73 | * @inheritdoc
74 | */
75 | override getSkippedCount(): number {
76 | return this._sumTestCaseCounts((testCase: TestCase | TestSuite) => {
77 | return testCase.getSkippedCount();
78 | });
79 | }
80 |
81 | /**
82 | * @param counterFunction - the function to count the test cases
83 | * @returns the sum of the counts of the test cases
84 | */
85 | protected _sumTestCaseCounts(counterFunction: (testCase: TestCase | TestSuite) => number): number {
86 | var counts = this._children.map(counterFunction);
87 | return counts.reduce((sum, count) => sum + count, 0);
88 | }
89 |
90 | /**
91 | * @param parentElement - the parent element
92 | * @returns the newly created element
93 | */
94 | build(parentElement?: XMLElement) {
95 | this._attributes.tests = this.getTestCaseCount();
96 | this._attributes.failures = this.getFailureCount();
97 | this._attributes.errors = this.getErrorCount();
98 | this._attributes.skipped = this.getSkippedCount();
99 | return super.build(parentElement);
100 | }
101 |
102 | /**
103 | * @param element
104 | * @returns the built element
105 | */
106 | protected buildNode(element: XMLElement) {
107 | element = super.buildNode(element);
108 | _.forEach(this._children, (child) => {
109 | child.build(element);
110 | });
111 | return element;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/test_node.ts:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import xmlBuilder, { type XMLElement } from 'xmlbuilder';
3 |
4 | /** Helper interface to describe one property */
5 | interface Property {
6 | /** name of the property */
7 | name: string;
8 | /** value of the property */
9 | value: string;
10 | /** if true the property will be serialized as node with textcontent */
11 | isPropertyWithTextContent: boolean;
12 | }
13 |
14 | export abstract class TestNode {
15 | private _elementName: string;
16 | protected _attributes: any;
17 | private _properties: Property[];
18 |
19 | /**
20 | * @param elementName - the name of the XML element
21 | */
22 | constructor(elementName: string) {
23 | this._elementName = elementName;
24 | this._attributes = {};
25 | this._properties = [];
26 | }
27 |
28 | /**
29 | * @param name
30 | * @param value
31 | * @returns this
32 | */
33 | property(name: string, value: string): this {
34 | this._properties.push({ name: name, value: value, isPropertyWithTextContent: false });
35 | return this;
36 | }
37 |
38 | /**
39 | * @param name
40 | * @param value
41 | * @returns this
42 | */
43 | multilineProperty(name: string, value: string): this {
44 | this._properties.push({ name: name, value: value, isPropertyWithTextContent: true });
45 | return this;
46 | }
47 |
48 | /**
49 | * @param name
50 | * @returns this
51 | */
52 | name(name: string): this {
53 | this._attributes.name = name;
54 | return this;
55 | }
56 |
57 | /**
58 | * @param timeInSeconds
59 | * @returns this
60 | */
61 | time(timeInSeconds: number): this {
62 | this._attributes.time = timeInSeconds;
63 | return this;
64 | }
65 |
66 | /**
67 | * @returns the number of test cases
68 | */
69 | public abstract getTestCaseCount(): number;
70 |
71 | /**
72 | * @returns the number of failed test cases
73 | */
74 | public abstract getFailureCount(): number;
75 |
76 | /**
77 | * @returns the number of errored test cases
78 | */
79 | public abstract getErrorCount(): number;
80 |
81 | /**
82 | * @returns the number of skipped test cases
83 | */
84 | public abstract getSkippedCount(): number;
85 |
86 | /**
87 | * @param parentElement - the parent element
88 | */
89 | build(parentElement?: XMLElement) {
90 | return this.buildNode(this.createElement(parentElement));
91 | }
92 |
93 | /**
94 | * @param parentElement - the parent element
95 | * @returns the created element
96 | */
97 | protected createElement(parentElement?: XMLElement): XMLElement {
98 | if (parentElement) {
99 | return parentElement.ele(this._elementName, this._attributes);
100 | }
101 | return this.createRootElement();
102 | }
103 |
104 | /**
105 | * @returns the created root element
106 | */
107 | private createRootElement(): XMLElement {
108 | const element = xmlBuilder.create(this._elementName, { encoding: 'UTF-8', invalidCharReplacement: '' });
109 | Object.keys(this._attributes).forEach((key) => {
110 | element.att(key, this._attributes[key]);
111 | });
112 | return element;
113 | }
114 |
115 | /**
116 | * @protected
117 | * @param date
118 | * @returns {string}
119 | */
120 | protected formatDate(date: Date) {
121 | const pad = (num: number) => (num < 10 ? '0' : '') + num;
122 |
123 | return (
124 | date.getFullYear() +
125 | '-' +
126 | pad(date.getMonth() + 1) +
127 | '-' +
128 | pad(date.getDate()) +
129 | 'T' +
130 | pad(date.getHours()) +
131 | ':' +
132 | pad(date.getMinutes()) +
133 | ':' +
134 | pad(date.getSeconds())
135 | );
136 | }
137 |
138 | /**
139 | * @param element
140 | * @returns the created element
141 | */
142 | protected buildNode(element: XMLElement): XMLElement {
143 | if (this._properties.length) {
144 | var propertiesElement = element.ele('properties');
145 | _.forEach(this._properties, (property: Property) => {
146 | if (property.isPropertyWithTextContent) {
147 | propertiesElement.ele('property', { name: property.name }, property.value);
148 | } else {
149 | propertiesElement.ele('property', { name: property.name, value: property.value });
150 | }
151 | });
152 | }
153 | return element;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/test_suite.ts:
--------------------------------------------------------------------------------
1 | import { TestGroup } from './test_group';
2 | import type { Factory } from './factory';
3 |
4 | export class TestSuite extends TestGroup {
5 | /**
6 | * @param factory
7 | */
8 | constructor(factory: Factory) {
9 | super(factory, 'testsuite');
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/test_suites.ts:
--------------------------------------------------------------------------------
1 | import { TestGroup } from './test_group';
2 | import type { Factory } from './factory';
3 |
4 | export class TestSuites extends TestGroup {
5 | /**
6 | * @param factory
7 | */
8 | constructor(factory: Factory) {
9 | super(factory, 'testsuites');
10 | }
11 |
12 | /**
13 | * @returns a new created test suite
14 | */
15 | testSuite() {
16 | var suite = this._factory.newTestSuite();
17 | this._children.push(suite);
18 | return suite;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "lib": ["es2015"],
7 |
8 | "outDir": "dist",
9 | "strict": true,
10 | "alwaysStrict": true,
11 | "strictFunctionTypes": true,
12 | "strictNullChecks": true,
13 | "strictPropertyInitialization": true,
14 |
15 | "forceConsistentCasingInFileNames": true,
16 | "noImplicitAny": true,
17 | "noImplicitReturns": true,
18 | "noImplicitThis": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 |
23 | "emitDecoratorMetadata": true,
24 | "experimentalDecorators": true,
25 | "downlevelIteration": true,
26 | "declaration": true,
27 |
28 | "pretty": true,
29 | "esModuleInterop": true,
30 | "noEmit": true,
31 |
32 | "types": ["node", "jest"]
33 | },
34 | "include": ["typings/**/*", "src/**/*"]
35 | }
36 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tslint:latest"
3 | }
4 |
--------------------------------------------------------------------------------