Custom Reporter
You can write your own custom reporter for the WDIO test runner that is tailored to your needs. And it’s easy!
All you need to do is to create a node module that inherits from the @wdio/reporter package, so it can receive messages from the test.
The basic setup should look like:
import WDIOReporter from '@wdio/reporter'
export default class CustomReporter extends WDIOReporter {
constructor(options) {
/*
* make reporter to write to the output stream by default
*/
options = Object.assign(options, { stdout: true })
super(options)
}
onTestPass(test) {
this.write(`Congratulations! Your test "${test.title}" passed 👏`)
}
}
To use this reporter, all you need to do is assign it to the reporter property in your configuration.
Your wdio.conf.js file should look like this:
import CustomReporter from './reporter/my.custom.reporter'
export const config = {
// ...
reporters: [
/**
* use imported reporter class
*/
[CustomReporter, {
someOption: 'foobar'
}],
/**
* use absolute path to reporter
*/
['/path/to/reporter.js', {
someOption: 'foobar'
}]
],
// ...
}
You can also publish the reporter to NPM so everyone can use it. Name the package like other reporters wdio-<reportername>-reporter, and tag it with keywords like wdio or wdio-reporter.
Event Handler
You can register an event handler for several events which are triggered during testing. All of the following handlers will receive payloads with useful information about the current state and progress.
The structure of these payload objects depend on the event, and are unified across the frameworks (Mocha, Jasmine, and Cucumber). Once you implement a custom reporter, it should work for all frameworks.
The following list contains all possible methods you can add to your reporter class:
import WDIOReporter from '@wdio/reporter'
export default class CustomReporter extends WDIOReporter {
onRunnerStart() {}
onBeforeCommand() {}
onAfterCommand() {}
onSuiteStart() {}
onHookStart() {}
onHookEnd() {}
onTestStart() {}
onTestPass() {}
onTestFail() {}
onTestSkip() {}
onTestEnd() {}
onContextEnd() {} // experimental, see "Reporters and parallel tests"
onSuiteEnd() {}
onRunnerEnd() {}
}
The method names are pretty self explanatory.
After onTestPass, onTestFail, onTestSkip or onTestPending, every framework calls onTestEnd for the same test: Cucumber too, for each step (or each scenario with scenarioLevelReporter). When a test is retried, its failed attempt ends with onTestRetry and gets no onTestEnd. With Cucumber at step level, the scenario is retried: the step that fails gets onTestRetry instead of onTestFail, every step of the failed attempt ends with onTestEnd as usual, and onSuiteRetry starts the next attempt and removes the steps of the failed attempt from this.counts (this.counts.hooks keeps the hooks of every attempt, as for a Mocha retry). onSuiteRetry sees the new suite.retries and the tests and hooks of the failed attempt; the base clears them after it. Finish your work for a test in onTestEnd, not in the result methods. A Cucumber BeforeAll or AfterAll runs once, outside of any scenario: it comes as onHookStart when it starts and onHookEnd when it ends (with error when it fails), under the feature suite, with hookKind beforeAll / afterAll and hookSuiteUid set to the uid of the feature. A passed one comes too, so the commands and logs of its body belong to it. WebdriverIO's own beforeFeature / afterFeature hooks send no hook event.
To print something on a certain event, use the this.write(...) method, which is provided by the parent WDIOReporter class. It either streams the content to stdout, or to a log file (depending on the options of the reporter).
import WDIOReporter from '@wdio/reporter'
export default class CustomReporter extends WDIOReporter {
onTestPass(test) {
this.write(`Congratulations! Your test "${test.title}" passed 👏`)
}
}
Note that you cannot defer the test execution in any way.
All event handlers should execute synchronous routines (or you'll run into race conditions).
Be sure to check out the example section where you can find an example custom reporter that prints the event name for each event.
If you have implemented a custom reporter that could be useful for the community, don't hesitate to make a Pull Request so we can make the reporter available for the public!
Also, if you run the WDIO testrunner via the Launcher interface, you can't apply a custom reporter as function as follows:
import Launcher from '@wdio/cli'
import CustomReporter from './reporter/my.custom.reporter'
const launcher = new Launcher('/path/to/config.file.js', {
// this will NOT work, because CustomReporter is not serializable
reporters: ['dot', CustomReporter]
})
Reporters and parallel tests
With parallel tests in a spec file, the tests of one spec file overlap: onTestStart of one test can come before onTestEnd of another, and commands of different tests interleave. WDIOReporter handles this for you. In parallel mode the framework events carry two extra fields:
parentUidonsuite:start,hook:startandtest:start: the uid of the owning suite (nullfor a top-level suite). The reporter adds the suite, hook or test to that suite, not to the suite that started last.contextIdon test and command events: the browsing context of the test. The reporter adds command output to the test that ran in that context. With Vitest, all tests of a spec file that has a concurrent test (test.concurrent,describe.concurrent) have acontextId: the id of the Vitest test, not a browsing context.
The test, hook and suite stats keep these fields when the event has them: in onTestEnd(test), test.contextId is the browsing context of the test and test.parentUid the uid of its suite. Without parallel mode, the stats have no such properties.
The suites, tests and hooks trees of the reporter are therefore correct. If your reporter keeps its own state, key it by the uid of the test or suite instead of tracking a single current test.
Output that comes without an event, such as console output or a custom process.emit event, has no uid. Call this.getContextTest() to get the test whose browsing context wrote it. In parallel mode it returns the test that started last in that context, also after its test:end, when its afterEach and after hooks run, and undefined when no test started in that context or after its context:end; without parallel mode it returns the test that started last. before() hooks of a parallel test run before its test:start, so no test owns the context yet; this.getContextId() returns the raw context id, which stays the same for the whole test.
import WDIOReporter from '@wdio/reporter'
export default class CustomReporter extends WDIOReporter {
onTestStart(test) {
this.write(`start ${test.uid} ${test.title}\n`)
}
onTestEnd(test) {
this.write(`end ${test.uid} ${test.state}\n`)
}
}
Some frameworks count the error of a failed hook one time, as the failure of a test. Vitest does this for beforeEach and afterEach: the test fails with the error of the hook. Such a hook has hook.failedTestUid, the uid of that test, and the test has the error too (in test.errors, next to its own error, when the test body also failed). Show the hook as failed, but do not count it as one more failure. The hooks of an attempt that Vitest runs again (retry) also have hook.retried === true; the error of that attempt is on its test:retry (onTestRetry), and its test stats have test.retried === true (with every framework). To count failures, use the helper hookCountsAsFailure(hook) from @wdio/reporter and skip the tests with test.retried, so that your reporter follows the same rule as the WebdriverIO reporters:
import WDIOReporter, { hookCountsAsFailure } from '@wdio/reporter'
export default class CountingReporter extends WDIOReporter {
failures = 0
onHookEnd(hook) {
if (hookCountsAsFailure(hook)) {
this.failures++
}
}
onTestFail(test) {
this.failures++
}
onTestRetry() {
// a retried attempt does not count: its test:retry comes instead of test:fail
}
}
failedTestUid, retried and hookCountsAsFailure are experimental.
The afterEach and after hooks of a parallel test run after its test:end. To know when a context is done, use onContextEnd(payload). It is called after the last hook of the test (with Cucumber: after the After hooks of the last attempt of the scenario), and the context sends no more events after it. The payload has cid, specs, uid (the last test of the context; for a Cucumber scenario with no step, the scenario) and contextId. Close or write what your reporter keeps for that context there.
context:end and onContextEnd are experimental: only Mocha and Cucumber parallelMode and Vitest concurrent tests send them for now, and the name and the payload can change.
Units
A unit is one test or scenario with all its attempts. In parallel mode, a unit is one browsing context, from its first event to context:end. In a sequential run, a unit is a test, or a Cucumber scenario (at step level, the suite of the scenario). The unit is the key to use for per-test state of a reporter, in both modes.
A ReporterUnit has these fields:
| Field | Description |
|---|---|
contextId | The browsing context of a parallel unit. undefined in a sequential run. |
stats | The scenario (Cucumber step level) or the test. undefined before it starts, for example in a Mocha parallelMode before all hook (its unit opens at hook:start). |
test | The test or step that started last in the unit. |
attempt | 0 for the first attempt. It changes at the start of the next attempt: at the next test:start, or at suite:retry for a Cucumber scenario at step level. |
ended | true from onUnitEnd on. |
The reporter calls these methods. In them, getContextUnit(), unitState() and attemptState() use the unit of the event:
onUnitStart(unit): at the first event of a parallel context (atest:start, atest:pendingor a scenariosuite:startin a context that ended starts a new unit), or at a sequential test or scenario.onUnitRetry(unit): an attempt failed and runs again (test:retry,suite:retry). It comes before the base clears the stats of the attempt.onUnitEnd(unit): atcontext:end. In a sequential run, after theafterEachhooks of the test (at the next hook, test or suite event), or at thesuite:endof the scenario. A test with notest:endends when the next unit starts or at the end of its suite. Else the unit ends atrunner:end.onUnitEndcomes afteronContextEndand after theonSuiteEndof the scenario, so these methods still see the open unit. At any other event that ends the unit (the next hook, test or suite,runner:end), it comes before theon*method of that event.
Use these methods to find the unit and to keep state for it:
this.getContextUnit(contextId?): the unit of the caller. In anon*method, this is the unit of the event. Else it is the unit of the parallel context of the caller, or the sequential unit. WithcontextId, it is the last unit of that context. The units and their state stay for the life of the reporter, also afterrunner:end. It returnsundefinedfor runner events, suite events (except a Cucumber scenario), abeforeAll/afterAllhook that has ahookKindand no context, and for code outside every context after the first parallel context.this.unitOf(stats): the unit of a suite, hook or test stats.this.unitState(key, init, target?): state of your reporter for the unit.init(unit)runs at the first call, and the same value comes back after that. It returnsundefinedwhen there is no unit.targetis a context id or a unit.this.attemptState(key, init, target?): asunitState, but new for each attempt of the unit.
In an async on* method, the unit of the event is known only before the first await. Keep it (const unit = this.unitOf(test)) and pass it to unitState() / attemptState() after the await:
async onTestEnd(test: TestStats) {
const unit = this.unitOf(test)
await this.upload(test)
this.unitState('uploads', () => [] as string[], unit)?.push(test.title)
}
This reporter keeps the lines of each test in unitState and prints them at onUnitEnd, also when tests run at the same time:
import WDIOReporter, { type ReporterUnit, type TestStats } from '@wdio/reporter'
export default class PerTestReporter extends WDIOReporter {
#lines = (unit: ReporterUnit) => this.unitState('lines', () => [] as string[], unit)!
onTestEnd(test: TestStats) {
const unit = this.unitOf(test)
if (unit) {
this.#lines(unit).push(`${test.state} ${test.title}`)
}
}
onUnitEnd(unit: ReporterUnit) {
const lines = this.#lines(unit)
this.write(`${unit.stats?.title} (attempt ${unit.attempt})\n ${lines.join('\n ')}\n`)
}
}
Tree helpers
The reporter keeps the suite of each stats object. Use these methods to read the tree without your own bookkeeping:
this.parentOf(stats): the suite that holds the suite, hook or test stats.undefinedfor a stats object under the root suite.this.pathOf(stats): the suites above the stats, outermost first, without the root suite.this.depthOf(stats): the number of these suites.
Owner of a log and a command
For a log that has no event, getContextTest() does not always return its owner. In a hook, it can return undefined or the test that started last. Call this.getContextOwner() to get the stats that own the output of the caller: the test of its parallel context, else the open hook of that context (before its test, and after a suite:retry until the next attempt starts a step), else the open beforeAll / afterAll hook that has a hookKind and no context, else in a sequential Cucumber scenario before its first step the open hook (with a hookKind) of that scenario. If there is no context in a parallel run and no parallel test runs, the owner is the last test of the unit that opened last, not the test that started last. In all other cases, it is the same as getContextTest(). The behavior of getContextTest() and getContextId() does not change.
Hooks
Frameworks can send these fields on a hook. A framework that sends no hookKind keeps the old owner rules of the reporter, which use the title of the hook. If you write an adapter for a framework, send hookKind to get the new rules.
hook.hookKind:'beforeAll','beforeEach','afterEach','afterAll','beforeStep'or'afterStep'.hook.hookSuiteUid: the uid of the suite that declares abeforeAll/afterAllhook.this.hookOwner(hook): the owner of a hook (ReporterUnit | SuiteStats | undefined). This is its unit, orundefinedwhen the hook has no unit. For abeforeAll/afterAllhook that has ahookKindand no context, it is the suite (hookSuiteUid, elseparentOf(hook)). When that hook is a hook of the root suite (for example a root Mochabefore), it is alsoundefined.isContextlessAllHook(hook)(an export of@wdio/reporter):truefor abeforeAll/afterAllhook that has ahookKindand no context. Such a hook belongs to its suite, not to a unit.this.isFeatureHook(hook):truefor a hook of a Cucumber feature (BeforeAll/AfterAll): the suite of the hook is afeature.hook.output: the commands of abeforeAll/afterAllhook that has ahookKindand no context, of a hook of a parallel context that runs before its test, and of a hook with ahookKindof a sequential Cucumber scenario before its first step (aBefore). The array exists after the first command.hookStats.state: the base reporter sets'failed'when the hook failed, or'skipped'when the framework skipped the hook (Mochathis.skip(), Cucumber hook resultSKIPPED). A passed hook keepsundefined. A reporter can set'passed'on its own stats (AllureReporterdoes). Each reporter has its ownHookStats.
Browser name
runner.capabilities are the capabilities of the session (in a multi-remote run, a map of the instance names to their capabilities). To show the browser that ran, use getBrowserNames(runner.capabilities, runner.isMultiRemote) (an export of @wdio/reporter). It returns one name for each session, in the order of the multi-remote instances: the result of getBrowserName(caps) (the browser, else the Appium app), else the Appium device name. A session with none of these has no entry. If your reporter has an option for the browser name, show that option when the user sets it, else these names. To put the capabilities in a file name or a package name, use sanitizeCaps(caps): it returns one capability string. To get the capabilities of each session with the name of its multi-remote instance, use getSessions(runner.capabilities, runner.isMultiRemote): it returns { name?, capabilities }[] (name is set only in a multi-remote run), unwraps alwaysMatch, and keeps a multi-remote instance with no capabilities with its name and empty capabilities. To show a mobile session with its device and platform, use getSessionNames(runner.capabilities, runner.isMultiRemote): it adds on <device> (<platform> <version>) to the name, for example MyApp.app on iPhone 17 (iOS 18.2), and leaves a desktop browser as it is.
The unit API, getBrowserNames, getSessionNames, getSessions and sanitizeCaps are experimental: ReporterUnit, onUnitStart, onUnitRetry, onUnitEnd, getContextUnit, unitOf, unitState, attemptState, parentOf, pathOf, depthOf, getContextOwner, hookOwner, isContextlessAllHook, isFeatureHook, HookKind, hookKind, hookSuiteUid, hook.output, SuiteStats.clearAttempt() and the 'skipped' state of a hook. The names and the results can change.
Wait Until isSynchronised
If your reporter has to execute async operations to report the data (e.g. upload of log files or other assets) you can overwrite the isSynchronised method in your custom reporter to let the WebdriverIO runner wait until you have computed everything. An example of this can be seen in the @wdio/sumologic-reporter:
export default class SumoLogicReporter extends WDIOReporter {
constructor (options) {
// ...
this.unsynced = []
this.interval = setInterval(::this.sync, this.options.syncInterval)
// ...
}
/**
* overwrite isSynchronised method
*/
get isSynchronised () {
return this.unsynced.length === 0
}
/**
* sync log files
*/
sync () {
// ...
request({
method: 'POST',
uri: this.options.sourceAddress,
body: logLines
}, (err, resp) => {
// ...
/**
* remove transferred logs from log bucket
*/
this.unsynced.splice(0, MAX_LINES)
// ...
}
}
}
This way the runner will wait until all log information are uploaded.
Publish Reporter on NPM
To make reporter easier to consume and discover by the WebdriverIO community, please follow these recommendations:
- Services should use this naming convention:
wdio-*-reporter - Use NPM keywords:
wdio-plugin,wdio-reporter - The
mainentry shouldexportan instance of the reporter - Example reporter:
@wdio/dot-service
Following the recommended naming pattern allows services to be added by name:
// Add wdio-custom-reporter
export const config = {
// ...
reporter: ['custom'],
// ...
}
Add Published Service to WDIO CLI and Docs
We really appreciate every new plugin that could help other people run better tests! If you have created such a plugin, please consider adding it to our CLI and docs to make it easier to be found.
Please raise a pull request with the following changes:
- add your service to the list of supported reporters) in the CLI module
- enhance the reporter list for adding your docs to the official Webdriver.io page