Skip to content

Commit c8a7be8

Browse files
DavertMikclaude
andcommitted
feat: add programmatic API with getSuites(), executeSuite(), executeTest()
Add clean programmatic API to Codecept class that wraps Mocha internals, eliminating duplicated boilerplate across dryRun, workers, and custom scripts. - getSuites(pattern?) returns parsed suites with tests as plain objects - executeSuite(suite) runs all tests in a suite - executeTest(test) runs a single test by fullTitle - Refactor workers.js to use getSuites() (removes ~30 lines of Mocha boilerplate) - Refactor dryRun.js to use getSuites() (removes Container.mocha() dependency) - Export Result class from lib/index.js - Rewrite docs/internal-api.md with full programmatic API reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a43b54e commit c8a7be8

File tree

5 files changed

+251
-80
lines changed

5 files changed

+251
-80
lines changed

docs/internal-api.md

Lines changed: 133 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -225,42 +225,143 @@ Step events provide step objects with following fields:
225225
226226
Whenever you execute tests with `--verbose` option you will see registered events and promises executed by a recorder.
227227
228-
## Custom Runner
228+
## Programmatic API
229229
230-
You can run CodeceptJS tests from your script.
230+
CodeceptJS can be imported and used programmatically from your scripts. The main entry point is the `Codecept` class, which provides methods to list and execute tests.
231+
232+
### Setup
231233
232234
```js
233-
const { codecept: Codecept } = require('codeceptjs');
234-
235-
// define main config
236-
const config = {
237-
helpers: {
238-
WebDriver: {
239-
browser: 'chrome',
240-
url: 'http://localhost'
241-
}
242-
}
235+
import { Codecept, container } from 'codeceptjs';
236+
237+
const config = {
238+
helpers: {
239+
Playwright: { browser: 'chromium', url: 'http://localhost' }
240+
},
241+
tests: './*_test.js',
243242
};
244243

245-
const opts = { steps: true };
246-
247-
// run CodeceptJS inside async function
248-
(async () => {
249-
const codecept = new Codecept(config, options);
250-
codecept.init(__dirname);
251-
252-
try {
253-
await codecept.bootstrap();
254-
codecept.loadTests('**_test.js');
255-
// run tests
256-
await codecept.run(test);
257-
} catch (err) {
258-
printError(err);
259-
process.exitCode = 1;
260-
} finally {
261-
await codecept.teardown();
262-
}
263-
})();
244+
const codecept = new Codecept(config, { steps: true });
245+
await codecept.init(__dirname);
246+
```
247+
248+
### Listing Tests
249+
250+
Use `getSuites()` to get all parsed suites with their tests without executing them:
251+
252+
```js
253+
const suites = codecept.getSuites();
254+
255+
for (const suite of suites) {
256+
console.log(suite.title, suite.tags);
257+
for (const test of suite.tests) {
258+
console.log(' -', test.title, test.tags);
259+
}
260+
}
261+
```
262+
263+
`getSuites()` accepts an optional glob pattern. If `loadTests()` hasn't been called yet, it will be called internally.
264+
265+
Each suite contains:
266+
267+
| Property | Type | Description |
268+
|----------|------|-------------|
269+
| `title` | `string` | Feature/suite title |
270+
| `file` | `string` | Absolute path to the test file |
271+
| `tags` | `string[]` | Tags (e.g. `@smoke`) |
272+
| `tests` | `Array` | Tests in this suite |
273+
274+
Each test contains:
275+
276+
| Property | Type | Description |
277+
|----------|------|-------------|
278+
| `title` | `string` | Scenario title |
279+
| `uid` | `string` | Unique test identifier |
280+
| `tags` | `string[]` | Tags from scenario and suite |
281+
| `fullTitle` | `string` | `"Suite: Test"` format |
282+
283+
### Executing Suites
284+
285+
Use `executeSuite()` to run all tests within a suite:
286+
287+
```js
288+
await codecept.bootstrap();
289+
290+
const suites = codecept.getSuites();
291+
for (const suite of suites) {
292+
await codecept.executeSuite(suite);
293+
}
294+
295+
const result = container.result();
296+
console.log(result.stats);
297+
console.log(`Passed: ${result.passedTests.length}`);
298+
console.log(`Failed: ${result.failedTests.length}`);
299+
300+
await codecept.teardown();
301+
```
302+
303+
### Executing Individual Tests
304+
305+
Use `executeTest()` to run a single test:
306+
307+
```js
308+
await codecept.bootstrap();
309+
310+
const suites = codecept.getSuites();
311+
for (const test of suites[0].tests) {
312+
await codecept.executeTest(test);
313+
}
314+
315+
const result = container.result();
316+
await codecept.teardown();
317+
```
318+
319+
### Result Object
320+
321+
The `Result` object returned by `container.result()` provides:
322+
323+
| Property | Type | Description |
324+
|----------|------|-------------|
325+
| `stats` | `object` | `{ passes, failures, tests, pending, failedHooks, duration }` |
326+
| `tests` | `Test[]` | All collected tests |
327+
| `passedTests` | `Test[]` | Tests that passed |
328+
| `failedTests` | `Test[]` | Tests that failed |
329+
| `skippedTests` | `Test[]` | Tests that were skipped |
330+
| `hasFailed` | `boolean` | Whether any test failed |
331+
| `duration` | `number` | Total duration in milliseconds |
332+
333+
### Full Lifecycle (Low-Level)
334+
335+
For full control, you can orchestrate the lifecycle manually:
336+
337+
```js
338+
const codecept = new Codecept(config, opts);
339+
await codecept.init(__dirname);
340+
341+
try {
342+
await codecept.bootstrap();
343+
codecept.loadTests('**_test.js');
344+
await codecept.run();
345+
} catch (err) {
346+
console.error(err);
347+
process.exitCode = 1;
348+
} finally {
349+
await codecept.teardown();
350+
}
264351
```
265352
266-
> Also, you can run tests inside workers in a custom scripts. Please refer to the [parallel execution](/parallel) guide for more details.
353+
### Codecept Methods Reference
354+
355+
| Method | Description |
356+
|--------|-------------|
357+
| `new Codecept(config, opts)` | Create runner instance |
358+
| `await init(dir)` | Initialize globals, container, helpers, plugins |
359+
| `loadTests(pattern?)` | Find test files by glob pattern |
360+
| `getSuites(pattern?)` | Load and return parsed suites with tests |
361+
| `await bootstrap()` | Execute bootstrap hook |
362+
| `await run(test?)` | Run all loaded tests (or filter by file path) |
363+
| `await executeSuite(suite)` | Run a specific suite from `getSuites()` |
364+
| `await executeTest(test)` | Run a specific test from `getSuites()` |
365+
| `await teardown()` | Execute teardown hook |
366+
367+
> Also, you can run tests inside workers in a custom script. Please refer to the [parallel execution](/parallel) guide for more details.

lib/codecept.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const __filename = fileURLToPath(import.meta.url)
1111
const __dirname = dirname(__filename)
1212

1313
import Helper from '@codeceptjs/helper'
14+
import MochaFactory from './mocha/factory.js'
1415
import container from './container.js'
1516
import Config from './config.js'
1617
import event from './event.js'
@@ -256,6 +257,98 @@ class Codecept {
256257
return testFiles.slice(startIndex, endIndex)
257258
}
258259

260+
/**
261+
* Returns parsed suites with their tests.
262+
* Creates a temporary Mocha instance to avoid polluting container state.
263+
* Must be called after init(). Calls loadTests() internally if testFiles is empty.
264+
*
265+
* @param {string} [pattern] - glob pattern for test files
266+
* @returns {Array<{title: string, file: string, tags: string[], tests: Array<{title: string, uid: string, tags: string[], fullTitle: string}>}>}
267+
*/
268+
getSuites(pattern) {
269+
if (this.testFiles.length === 0) {
270+
this.loadTests(pattern)
271+
}
272+
273+
const tempMocha = MochaFactory.create(this.config.mocha || {}, this.opts || {})
274+
tempMocha.files = this.testFiles
275+
tempMocha.loadFiles()
276+
277+
const suites = []
278+
for (const suite of tempMocha.suite.suites) {
279+
suites.push({
280+
...suite.simplify(),
281+
file: suite.file || '',
282+
tests: suite.tests.map(test => ({
283+
...test.simplify(),
284+
fullTitle: test.fullTitle(),
285+
})),
286+
})
287+
}
288+
289+
tempMocha.unloadFiles()
290+
return suites
291+
}
292+
293+
/**
294+
* Execute all tests in a suite.
295+
* Must be called after init() and bootstrap().
296+
*
297+
* @param {{file: string}} suite - suite object returned by getSuites()
298+
* @returns {Promise<void>}
299+
*/
300+
async executeSuite(suite) {
301+
return this.run(suite.file)
302+
}
303+
304+
/**
305+
* Execute a single test by its fullTitle.
306+
* Must be called after init() and bootstrap().
307+
*
308+
* @param {{fullTitle: string}} test - test object returned by getSuites()
309+
* @returns {Promise<void>}
310+
*/
311+
async executeTest(test) {
312+
await container.started()
313+
314+
const tsValidation = validateTypeScriptSetup(this.testFiles, this.requiringModules || [])
315+
if (tsValidation.hasError) {
316+
output.error(tsValidation.message)
317+
process.exit(1)
318+
}
319+
320+
try {
321+
const { loadTranslations } = await import('./mocha/gherkin.js')
322+
await loadTranslations()
323+
} catch (e) {
324+
// Ignore if gherkin module not available
325+
}
326+
327+
return new Promise((resolve, reject) => {
328+
const mocha = container.mocha()
329+
mocha.files = this.testFiles
330+
mocha.grep(test.fullTitle)
331+
332+
const done = async (failures) => {
333+
event.emit(event.all.result, container.result())
334+
event.emit(event.all.after, this)
335+
await recorder.promise()
336+
if (failures) {
337+
process.exitCode = 1
338+
}
339+
resolve()
340+
}
341+
342+
try {
343+
event.emit(event.all.before, this)
344+
mocha.run(async (failures) => await done(failures))
345+
} catch (e) {
346+
output.error(e.stack)
347+
reject(e)
348+
}
349+
})
350+
}
351+
259352
/**
260353
* Run a specific test or all loaded tests.
261354
*

lib/command/dryRun.js

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import Codecept from '../codecept.js'
44
import output from '../output.js'
55
import event from '../event.js'
66
import store from '../store.js'
7-
import Container from '../container.js'
87

98
export default async function (test, options) {
109
if (options.grep) process.env.grep = options.grep
@@ -35,7 +34,7 @@ export default async function (test, options) {
3534
store.dryRun = true
3635

3736
if (!options.steps && !options.verbose && !options.debug) {
38-
await printTests(codecept.testFiles)
37+
await printTests(codecept)
3938
return
4039
}
4140
event.dispatcher.on(event.all.result, printFooter)
@@ -46,16 +45,14 @@ export default async function (test, options) {
4645
}
4746
}
4847

49-
async function printTests(files) {
48+
async function printTests(codecept) {
5049
const { default: figures } = await import('figures')
5150
const { default: colors } = await import('chalk')
5251

5352
output.print(output.styles.debug(`Tests from ${store.codeceptDir}:`))
5453
output.print()
5554

56-
const mocha = Container.mocha()
57-
mocha.files = files
58-
mocha.loadFiles()
55+
const suites = codecept.getSuites()
5956

6057
let numOfTests = 0
6158
let numOfSuites = 0
@@ -65,19 +62,19 @@ async function printTests(files) {
6562
let filterRegex
6663
if (filterBy) {
6764
try {
68-
filterRegex = new RegExp(filterBy, 'i') // Case-insensitive matching
65+
filterRegex = new RegExp(filterBy, 'i')
6966
} catch (err) {
7067
console.error(`Invalid grep pattern: ${filterBy}`)
7168
process.exit(1)
7269
}
7370
}
7471

75-
for (const suite of mocha.suite.suites) {
72+
for (const suite of suites) {
7673
const suiteMatches = filterRegex ? filterRegex.test(suite.title) : true
7774
let suiteHasMatchingTests = false
7875

7976
if (suiteMatches) {
80-
outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file || '')}\n`
77+
outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file)}\n`
8178
suiteHasMatchingTests = true
8279
numOfSuites++
8380
}
@@ -87,7 +84,7 @@ async function printTests(files) {
8784

8885
if (testMatches) {
8986
if (!suiteMatches && !suiteHasMatchingTests) {
90-
outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file || '')}\n`
87+
outputString += `${colors.white.bold(suite.title)} -- ${output.styles.log(suite.file)}\n`
9188
suiteHasMatchingTests = true
9289
numOfSuites++
9390
}

lib/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import heal from './heal.js'
2323
import ai from './ai.js'
2424
import Workers from './workers.js'
2525
import Secret, { secret } from './secret.js'
26+
import Result from './result.js'
2627

2728
export default {
2829
/** @type {typeof CodeceptJS.Codecept} */
@@ -67,7 +68,10 @@ export default {
6768
Secret,
6869
/** @type {typeof CodeceptJS.secret} */
6970
secret,
71+
72+
/** @type {typeof Result} */
73+
Result,
7074
}
7175

7276
// Named exports for ESM compatibility
73-
export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret }
77+
export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret, Result }

0 commit comments

Comments
 (0)