Example files: You'll find the example code in the blog post's project directory
A few days ago we stumbled over a problem in our API test infrastructure that induced some brainwork to fix it. In this article I show you how you can build your own Jest reporter and also how you can wrap existing reporters to filter out specific test results.
Our use case
We skip tests conditional under specific circumstances.
How is this done? Well we have overwritten describe() & it()
and if we encounter a situation where we must skip the test, we do not pass the original arguments to the original function but use this construct:
describe('Skipped', () => {
it("Skipped", () => { return; });
});
Similar works the replacement for it()
that allows us to execute tests within a describe
but skip specific ones.
To keep our reports clean we want to filter out skipped tests.
So we can omit all tests that are named Skipped.
This is our spec file:
// code/jest.spec.js
describe("suite", () => {
it("this test should appear in the html and junit report", () => {
expect(true).toBe(true)
})
it("Skipped", () => {
expect(true).toBe(true)
})
})
And the jest
config:
// code/jest.config-before.js
module.exports = {
reporters: [
"default",
"jest-junit",
["jest-html-reporter", {
"pageTitle": "Jest Test Report"
}]
],
testMatch: ["<rootDir>/spec.js"]
};
Which results in following results:
HTML:
JUNIT:
<!-- code/junit-before.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="jest tests" tests="2" failures="0" time="9.87">
<testsuite name="suite" errors="0" failures="0" skipped="0" timestamp="2020-01-09T19:48:00" time="2.451" tests="2">
<testcase classname="suite this test should appear in the html and junit report" name="suite this test should appear in the html and junit report" time="0.003">
</testcase>
<testcase classname="suite Skipped" name="suite Skipped" time="0">
</testcase>
</testsuite>
</testsuites>
Obviously we want this output:
JUNIT:
<!-- code/junit-after.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="jest tests" tests="1" failures="0" time="2.124">
<testsuite name="suite" errors="0" failures="0" skipped="0" timestamp="2020-01-09T19:50:17" time="1.272" tests="1">
<testcase classname="suite this test should appear in the html and junit report" name="suite this test should appear in the html and junit report" time="0.002">
</testcase>
</testsuite>
</testsuites>
The idea
I read through the "Jest" documentation, but found nothing related to filter logic. Changing existing reporters is out of the question so the only solution is to pack an existing reporter into an own reporter that does not pass on all test results. Also some test measurements must be adjusted, like the amount of total tests and so on.
Writing an Jest reporter
So our first goal is to write an own jest reporter. It's basic structure is very simple as you can see:
// code/jest-reporter-empty.js
class ReporterWrapper {
constructor(globalConfig, options) {
}
onRunStart(runResults, runConfig) {
}
onTestResult(testRunConfig, testResults, runResults) {
}
onRunComplete(test, runResults) {
}
}
module.exports = ReporterWrapper;
Writing a reporter is simple. You just do anything you want within those methods, save the file anywhere and then reference your reporter in the jest.config.js
among the other reporters:
// code/jest.config-own-reporter.js
module.exports = {
reporters: [
"default",
"jest-junit",
"jest-html-reporter",
"relative/path/to/jest-report-wrapper.js"
],
testMatch: ["<rootDir>/specs/*.js"]
};
❗ Attention:
Jest
passes the same instance of test results through all reporters. And it seems that the list of reporters is worked off beginning with the first. So changes made to the results in the first reporter are visible in the subsequent reporters, too.
The structure of the test results
NOTE:
I don't want to go to deep into the meaning of the arguments because there are many good resources in the web. Please see the Additional Resources section for further information.
testResults
contains the result of the last executed spec file. runResults
contains all results from within one execution. The structure (only the relevant parts) of the testResults
looks like this:
{
"numPassingTests": 5,
"testFilePath": "path/to/spec.js",
"testResults": [
{
"ancestorTitles": [
"SelectField Component"
],
"title": "renders text input correctly"
}
]
}
ancestorTitles
contains a list of parent names. They come from descibe()
and nested describe()
.
And the runResults
's structure looks like this:
{
"numPassedTestSuites": 15,
"numTotalTestSuites": 15,
"numPassedTests": 46,
"numTotalTests": 46,
"testResults": [
"here are testResults"
]
}
Cleaning the test results
When a reporter callback is hit, we only had to check the title
and the list of ancestorTitles
if the word Skipped is found. If yes, the testresult is removed and the result counts are decreased by one.
After all tests run, the callback onRunComplete
is hit and we must adjust the total testcounts.
Let's go!
The options
Of course we have to tell the wrapper what kind of underlying reporter exist and also which options those reporter need.
As every reporter receives the same testresult instance, it makes sense to configure the wrapper once and pass all existing reporter into it. I just left the default
reporter untouched because I didn't want to mess around with Jest's internals.
// project/jest.config.js
module.exports = {
reporters: [
"default",
["./jest-report-wrapper.js",
[
{ underlying: "jest-junit" },
{
underlying: "jest-html-reporter",
underlyingOptions: {
"pageTitle": "Jest Test Report"
}
}
]
]
],
testMatch: ["<rootDir>/spec.js"]
};
The constructor
First we need to do some initializing stuff.
// project/jest-report-wrapper.js#L2-L2
constructor(globalConfig, options) {
options
contains exactly the options from the reporter config. So it looks like this:
// project/jest.config.js#L5-L13
[
{ underlying: "jest-junit" },
{
underlying: "jest-html-reporter",
underlyingOptions: {
"pageTitle": "Jest Test Report"
}
}
]
What we have to do is real obvious:
- load the module whose path is in
underlying
- create
new
instance and pass the reporter's options
// project/jest-report-wrapper.js#L2-L11
constructor(globalConfig, options) {
this._globalConfig = globalConfig;
this._options = options;
this.underlyingReporters = [];
this.underlyingReporters = options.map(r => {
const resolved = require(r.underlying);
return new resolved(globalConfig, r.underlyingOptions);
})
}
onStart callback
We are not interested in this callback as it has no real meaning, so we pass on all arguments without modifications.
// project/jest-report-wrapper.js#L13-L19
onRunStart(runResults, runConfig) {
this.underlyingReporters.forEach(r => {
if (r.onRunStart) {
r.onRunStart(runResults, runConfig)
}
})
}
onTestResult callback
We start by searching single tests that match the criteria. If one is found, it is removed.
testResults
consists of several single tests. So we must check every single one if it's name is Skipped or if one of it's parent names is Skipped. removed
represents the amount of removed results and is used to adjust the testresult counts.
// project/jest-report-wrapper.js#L21-L32
onTestResult(testRunConfig, testResults, runResults) {
const removed = this.processSpecFile(testResults);
testResults.numPassingTests -= removed;
runResults.numPassedTests -= removed;
runResults.numTotalTests -= removed;
this.underlyingReporters.forEach(r => {
if (r.onTestResult) {
r.onTestResult(testRunConfig, testResults, runResults)
}
})
}
// project/jest-report-wrapper.js#L52-L66
processSpecFile(specFile) {
let removed = 0;
for (let testIndex = 0; testIndex < specFile.testResults.length; testIndex++) {
const test = specFile.testResults[testIndex];
if (test.ancestorTitles.indexOf("Skipped") > -1 || test.title === "Skipped") {
// test was skipped thus remove it
specFile.testResults.splice(testIndex, 1);
removed++;
// adjust indexCount because 'specFile.testResults.length' has been updated due to the 'splice'
testIndex--;
}
}
return removed;
}
onRunComplete callback
After all tests have been executed, this callback is called. We should remove the testsuites, that are empty, from the runResults
to keep the results clean and also to avoid unwanted behaviour in the reporter. If an empty testsuite is found, it is removed and the total result counts are adjusted.
// project/jest-report-wrapper.js#L34-L50
onRunComplete(test, runResults) {
for (let i = 0; i < runResults.testResults.length; i++) {
let tr = runResults.testResults[i];
if (tr.testResults.length == 0) {
runResults.testResults.splice(i, 1);
i--;
runResults.numPassedTestSuites -= 1;
runResults.numTotalTestSuites -= 1;
}
}
this.underlyingReporters.forEach(r => {
if (r.onRunComplete) {
r.onRunComplete(test, runResults)
}
})
}
Summary
You learned how you can write an own jest
reporter and how you can modify the test results to control the html/junit/... output.
Is there a native functionality in jest
for doing this kind of stuff? Let me know!
Additional Resources
Found a typo?
As I am not a native English speaker, it is very likely that you will find an error. In this case, feel free to create a pull request here: https://github.com/gabbersepp/dev.to-posts . Also please open a PR for all other kind of errors.
Do not worry about merge conflicts. I will resolve them on my own.
Top comments (0)