Skip to content

Commit 27af273

Browse files
committed
feat: add soft assertions feature
1 parent efd1c0e commit 27af273

7 files changed

Lines changed: 835 additions & 1 deletion

File tree

docs/API.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,85 @@
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. 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+
584
## Default Options
685

786
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.

src/index.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ 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'
9+
import { SoftAssertionService } from './softAssertService.js'
710

811
export const matchers = new Map<string, RawMatcherFn>()
912

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

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

64+
/**
65+
* export soft assertion utilities
66+
*/
67+
export { SoftAssertService, SoftAssertionService }
68+
4069
/**
4170
* export utils
4271
*/

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+
}

src/softAssertService.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Services } from '@wdio/types'
2+
import type { Frameworks } from '@wdio/types'
3+
import { SoftAssertService } from './softAssert'
4+
5+
/**
6+
* WebdriverIO service to integrate soft assertions into the test lifecycle
7+
*/
8+
export class SoftAssertionService implements Services.ServiceInstance {
9+
private softAssertService: SoftAssertService
10+
11+
constructor() {
12+
this.softAssertService = SoftAssertService.getInstance()
13+
}
14+
15+
/**
16+
* Hook before a test starts
17+
*/
18+
beforeTest(test: Frameworks.Test) {
19+
const testId = this.getTestId(test)
20+
this.softAssertService.setCurrentTest(testId, test.title, test.file)
21+
}
22+
23+
/**
24+
* Hook before a Cucumber step starts
25+
*/
26+
beforeStep(step: Frameworks.PickleStep, scenario: Frameworks.Scenario) {
27+
const stepId = `${scenario.uri || ''}:${scenario.name || ''}:${step.text || ''}`
28+
this.softAssertService.setCurrentTest(stepId, step.text, scenario.uri)
29+
}
30+
31+
/**
32+
* Hook after a test completes
33+
*/
34+
afterTest(test: Frameworks.Test, _: any, result: Frameworks.TestResult) {
35+
// Only assert failures if the test hasn't already failed for another reason
36+
if (!result.error) {
37+
try {
38+
const testId = this.getTestId(test)
39+
this.softAssertService.assertNoFailures(testId)
40+
} catch (error) {
41+
// Update the test result with our aggregated error
42+
result.error = error
43+
result.passed = false
44+
}
45+
}
46+
this.softAssertService.clearCurrentTest()
47+
}
48+
49+
/**
50+
* Hook after a Cucumber step completes
51+
*/
52+
afterStep(step: Frameworks.PickleStep, scenario: Frameworks.Scenario, result: { passed: boolean, error?: Error }) {
53+
// Only assert failures if the step hasn't already failed for another reason
54+
if (result.passed) {
55+
try {
56+
const stepId = `${scenario.uri || ''}:${scenario.name || ''}:${step.text || ''}`
57+
this.softAssertService.assertNoFailures(stepId)
58+
} catch (error) {
59+
// Update the step result with our aggregated error
60+
result.error = error as Error
61+
result.passed = false
62+
}
63+
}
64+
this.softAssertService.clearCurrentTest()
65+
}
66+
67+
/**
68+
* Generate a unique test ID from a test object
69+
*/
70+
private getTestId(test: Frameworks.Test): string {
71+
return `${test.file || ''}:${test.parent || ''}:${test.title || ''}`
72+
}
73+
}

0 commit comments

Comments
 (0)