├── .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 | [![Build Status](https://github.com/davidparsson/junit-report-builder/workflows/CI/badge.svg)](https://github.com/davidparsson/junit-report-builder/actions?query=workflow%3ACI) 4 | [![Weekly Downloads](https://img.shields.io/npm/dw/junit-report-builder.svg)](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 | --------------------------------------------------------------------------------