Skip to content

Commit eb8b9b6

Browse files
feat: add soft assertions feature (#1836)
* feat: add soft assertions feature * chore: implement suggestion * Update docs/API.md * Update src/index.ts * chore: fix tests * chore: adjust vitest threshold * Update docs/API.md --------- Co-authored-by: Christian Bromann <git@bromann.dev>
1 parent efd1c0e commit eb8b9b6

11 files changed

+1039
-6
lines changed

docs/API.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,115 @@
22

33
When you're writing tests, you often need to check that values meet certain conditions. `expect` gives you access to a number of "matchers" that let you validate different things on the `browser`, an `element` or `mock` object.
44

5+
## Soft Assertions
6+
7+
Soft assertions allow you to continue test execution even when an assertion fails. This is useful when you want to check multiple conditions in a test and collect all failures rather than stopping at the first failure. Failures are collected and reported at the end of the test.
8+
9+
### Usage
10+
11+
```js
12+
// Mocha example
13+
it('product page smoke', async () => {
14+
// These won't throw immediately if they fail
15+
await expect.soft(await $('h1').getText()).toEqual('Basketball Shoes');
16+
await expect.soft(await $('#price').getText()).toMatch(/\d+/);
17+
18+
// Regular assertions still throw immediately
19+
await expect(await $('.add-to-cart').isClickable()).toBe(true);
20+
});
21+
22+
// At the end of the test, all soft assertion failures
23+
// will be reported together with their details
24+
```
25+
26+
### Soft Assertion API
27+
28+
#### expect.soft()
29+
30+
Creates a soft assertion that collects failures instead of immediately throwing errors.
31+
32+
```js
33+
await expect.soft(actual).toBeDisplayed();
34+
await expect.soft(actual).not.toHaveText('Wrong text');
35+
```
36+
37+
#### expect.getSoftFailures()
38+
39+
Get all collected soft assertion failures for the current test.
40+
41+
```js
42+
const failures = expect.getSoftFailures();
43+
console.log(`There are ${failures.length} soft assertion failures`);
44+
```
45+
46+
#### expect.assertSoftFailures()
47+
48+
Manually assert all collected soft failures. This will throw an aggregated error if any soft assertions have failed.
49+
50+
```js
51+
// Manually throw if any soft assertions have failed
52+
expect.assertSoftFailures();
53+
```
54+
55+
#### expect.clearSoftFailures()
56+
57+
Clear all collected soft assertion failures for the current test.
58+
59+
```js
60+
// Clear all collected failures
61+
expect.clearSoftFailures();
62+
```
63+
64+
### Integration with Test Frameworks
65+
66+
The soft assertions feature integrates with WebdriverIO's test runner automatically. By default, it will report all soft assertion failures at the end of each test (Mocha/Jasmine) or step (Cucumber).
67+
68+
To use with WebdriverIO, add the SoftAssertionService to your services list:
69+
70+
```js
71+
// wdio.conf.js
72+
import { SoftAssertionService } from 'expect-webdriverio'
73+
74+
export const config = {
75+
// ...
76+
services: [
77+
// ...other services
78+
[SoftAssertionService]
79+
],
80+
// ...
81+
}
82+
```
83+
84+
#### Configuration Options
85+
86+
The SoftAssertionService can be configured with options to control its behavior:
87+
88+
```js
89+
// wdio.conf.js
90+
import { SoftAssertionService } from 'expect-webdriverio'
91+
92+
export const config = {
93+
// ...
94+
services: [
95+
// ...other services
96+
[SoftAssertionService, {
97+
// Disable automatic assertion at the end of tests (default: true)
98+
autoAssertOnTestEnd: false
99+
}]
100+
],
101+
// ...
102+
}
103+
```
104+
105+
##### autoAssertOnTestEnd
106+
107+
- **Type**: `boolean`
108+
- **Default**: `true`
109+
110+
When set to `true` (default), the service will automatically assert all soft assertions at the end of each test and throw an aggregated error if any failures are found. When set to `false`, you must manually call `expect.assertSoftFailures()` to verify soft assertions.
111+
112+
This is useful if you want full control over when soft assertions are verified or if you want to handle soft assertion failures in a custom way.
113+
5114
## Default Options
6115

7116
These default options below are connected to the [`waitforTimeout`](https://webdriver.io/docs/options#waitfortimeout) and [`waitforInterval`](https://webdriver.io/docs/options#waitforinterval) options set in the config.
@@ -46,7 +155,7 @@ Every matcher can take several options that allows you to modify the assertion:
46155

47156
##### String Options
48157

49-
This option can be applied in addition to the command options when strings are being asserted.
158+
This option can be applied in addition to the command options when strings are being asserted.
50159

51160
| Name | Type | Details |
52161
| ---- | ---- | ------- |

src/index.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { RawMatcherFn } from './types.js'
44

55
import wdioMatchers from './matchers.js'
66
import { DEFAULT_OPTIONS } from './constants.js'
7+
import createSoftExpect from './softExpect.js'
8+
import { SoftAssertService } from './softAssert.js'
79

810
export const matchers = new Map<string, RawMatcherFn>()
911

@@ -20,7 +22,28 @@ expectLib.extend = (m) => {
2022
type MatchersObject = Parameters<typeof expectLib.extend>[0]
2123

2224
expectLib.extend(wdioMatchers as MatchersObject)
23-
export const expect = expectLib as unknown as ExpectWebdriverIO.Expect
25+
26+
// Extend the expect object with soft assertions
27+
const expectWithSoft = expectLib as unknown as ExpectWebdriverIO.Expect
28+
Object.defineProperty(expectWithSoft, 'soft', {
29+
value: <T = unknown>(actual: T) => createSoftExpect(actual)
30+
})
31+
32+
// Add soft assertions utility methods
33+
Object.defineProperty(expectWithSoft, 'getSoftFailures', {
34+
value: (testId?: string) => SoftAssertService.getInstance().getFailures(testId)
35+
})
36+
37+
Object.defineProperty(expectWithSoft, 'assertSoftFailures', {
38+
value: (testId?: string) => SoftAssertService.getInstance().assertNoFailures(testId)
39+
})
40+
41+
Object.defineProperty(expectWithSoft, 'clearSoftFailures', {
42+
value: (testId?: string) => SoftAssertService.getInstance().clearFailures(testId)
43+
})
44+
45+
export const expect = expectWithSoft
46+
2447
export const getConfig = (): ExpectWebdriverIO.DefaultOptions => DEFAULT_OPTIONS
2548
export const setDefaultOptions = (options = {}): void => {
2649
Object.entries(options).forEach(([key, value]) => {
@@ -37,6 +60,12 @@ export const setOptions = setDefaultOptions
3760
*/
3861
export { SnapshotService } from './snapshot.js'
3962

63+
/**
64+
* export soft assertion utilities
65+
*/
66+
export { SoftAssertService } from './softAssert.js'
67+
export { SoftAssertionService, type SoftAssertionServiceOptions } from './softAssertService.js'
68+
4069
/**
4170
* export utils
4271
*/

src/matchers/snapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'node:path'
22
import type { AssertionError } from 'node:assert'
33

4-
import { expect } from '../index.js'
4+
import { expect } from 'expect'
55
import { SnapshotService } from '../snapshot.js'
66

77
interface InlineSnapshotOptions {

src/softAssert.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { AssertionError } from 'node:assert'
2+
3+
interface SoftFailure {
4+
error: AssertionError | Error;
5+
matcherName: string;
6+
location?: string;
7+
}
8+
9+
interface TestIdentifier {
10+
id: string;
11+
name?: string;
12+
file?: string;
13+
}
14+
15+
/**
16+
* Soft assertion service to collect failures without stopping test execution
17+
*/
18+
export class SoftAssertService {
19+
private static instance: SoftAssertService
20+
private failureMap: Map<string, SoftFailure[]> = new Map()
21+
private currentTest: TestIdentifier | null = null
22+
23+
private constructor() { }
24+
25+
/**
26+
* Get singleton instance
27+
*/
28+
public static getInstance(): SoftAssertService {
29+
if (!SoftAssertService.instance) {
30+
SoftAssertService.instance = new SoftAssertService()
31+
}
32+
return SoftAssertService.instance
33+
}
34+
35+
/**
36+
* Set the current test context
37+
*/
38+
public setCurrentTest(testId: string, testName?: string, testFile?: string): void {
39+
this.currentTest = { id: testId, name: testName, file: testFile }
40+
if (!this.failureMap.has(testId)) {
41+
this.failureMap.set(testId, [])
42+
}
43+
}
44+
45+
/**
46+
* Clear the current test context
47+
*/
48+
public clearCurrentTest(): void {
49+
this.currentTest = null
50+
}
51+
52+
/**
53+
* Get current test ID
54+
*/
55+
public getCurrentTestId(): string | null {
56+
return this.currentTest?.id || null
57+
}
58+
59+
/**
60+
* Add a soft failure for the current test
61+
*/
62+
public addFailure(error: Error, matcherName: string): void {
63+
const testId = this.getCurrentTestId()
64+
if (!testId) {
65+
throw error // If no test context, throw the error immediately
66+
}
67+
68+
// Extract stack information to get file and line number
69+
const stackLines = error.stack?.split('\n') || []
70+
let location = ''
71+
72+
// Find the first non-expect-webdriverio line in the stack
73+
for (const line of stackLines) {
74+
if (line && !line.includes('expect-webdriverio') && !line.includes('node_modules')) {
75+
location = line.trim()
76+
break
77+
}
78+
}
79+
80+
const failures = this.failureMap.get(testId) || []
81+
failures.push({ error, matcherName, location })
82+
this.failureMap.set(testId, failures)
83+
}
84+
85+
/**
86+
* Get all failures for a specific test
87+
*/
88+
public getFailures(testId?: string): SoftFailure[] {
89+
const id = testId || this.getCurrentTestId()
90+
if (!id) {
91+
return []
92+
}
93+
return this.failureMap.get(id) || []
94+
}
95+
96+
/**
97+
* Clear failures for a specific test
98+
*/
99+
public clearFailures(testId?: string): void {
100+
const id = testId || this.getCurrentTestId()
101+
if (id) {
102+
this.failureMap.delete(id)
103+
}
104+
}
105+
106+
/**
107+
* Throw an aggregated error if there are failures for the current test
108+
*/
109+
public assertNoFailures(testId?: string): void {
110+
const id = testId || this.getCurrentTestId()
111+
if (!id) {
112+
return
113+
}
114+
115+
const failures = this.getFailures(id)
116+
if (failures.length === 0) {
117+
return
118+
}
119+
120+
// Create a formatted error message with all failures
121+
let message = `${failures.length} soft assertion failure${failures.length > 1 ? 's' : ''}:\n\n`
122+
123+
failures.forEach((failure, index) => {
124+
message += `${index + 1}) ${failure.matcherName}: ${failure.error.message}\n`
125+
if (failure.location) {
126+
message += ` at ${failure.location}\n`
127+
}
128+
message += '\n'
129+
})
130+
131+
// Clear failures for this test to prevent duplicate reporting
132+
this.clearFailures(id)
133+
134+
// Throw an aggregated error
135+
const error = new Error(message)
136+
error.name = 'SoftAssertionsError'
137+
throw error
138+
}
139+
}

0 commit comments

Comments
 (0)