diff --git a/docs/data.md b/docs/data.md index f1e876a8a..f5c1fe149 100644 --- a/docs/data.md +++ b/docs/data.md @@ -18,7 +18,11 @@ The most efficient way would be to allow test to control its data, i.e. the 3rd However, accessing database directly is not a good idea as database vendor, schema and data are used by application internally and are out of scope of acceptance test. Today all modern web applications have REST or GraphQL API . So it is a good idea to use it to create data for a test and delete it after. -API is supposed to be a stable interface and it can be used by acceptance tests. CodeceptJS provides 4 helpers for Data Management via REST and GraphQL API. +API is supposed to be a stable interface and it can be used by acceptance tests. CodeceptJS provides helpers for Data Management via REST and GraphQL API, as well as **[Data Objects](/pageobjects#data-objects)** — class-based page objects with automatic cleanup via lifecycle hooks. + +## Data Objects + +For a lightweight, class-based approach to managing test data, see **[Data Objects](/pageobjects#data-objects)** in the Page Objects documentation. Data Objects let you create page object classes that manage API data with automatic cleanup via the `_after()` hook — no factory configuration needed. ## REST diff --git a/docs/pageobjects.md b/docs/pageobjects.md index c0c825d2c..78ba2fce2 100644 --- a/docs/pageobjects.md +++ b/docs/pageobjects.md @@ -40,9 +40,8 @@ Scenario('sample test', ({ I, myPage, mySteps }) => { During initialization, you were asked to create a custom steps file. If you accepted this option, you are now able to use the `custom_steps.js` file to extend `I`. See how the `login` method can be added to `I`: ```js -module.exports = function() { +export default function() { return actor({ - login: function(email, password) { this.fillField('Email', email); this.fillField('Password', password); @@ -52,11 +51,11 @@ module.exports = function() { } ``` -> ℹ Instead of `I` you should use `this` in the current context. +> Instead of `I` you should use `this` in the current context. ## PageObject -> ✨ CodeceptJS can [generate PageObjects using AI](/ai#generate-pageobjects). It fetches all interactive elements from a page, generates locators and methods page and writes JS code. Generated page object can be tested on the fly within the same browser session. +> CodeceptJS can [generate PageObjects using AI](/ai#generate-pageobjects). It fetches all interactive elements from a page, generates locators and methods page and writes JS code. Generated page object can be tested on the fly within the same browser session. If an application has different pages (login, admin, etc) you should use a page object. CodeceptJS can generate a template for it with the following command: @@ -67,46 +66,38 @@ npx codeceptjs gpo This will create a sample template for a page object and include it in the `codecept.json` config file. -```js -const { I, otherPage } = inject(); - -module.exports = { - - // insert your locators and methods here -} -``` - -As you see, the `I` object is available in scope, so you can use it just like you would do in tests. -A general page object for a login page could look like this: +**Page objects should be classes.** Use `const { I } = inject()` at the top of the file to access `I` and other page objects. Export the class itself — the DI container will auto-instantiate it. ```js -// enable I and another page object const { I, registerPage } = inject(); -module.exports = { - +class LoginPage { // setting locators - fields: { + fields = { email: '#user_basic_email', password: '#user_basic_password' - }, - submitButton: {css: '#new_user_basic input[type=submit]'}, + } + submitButton = { css: '#new_user_basic input[type=submit]' } // introducing methods sendForm(email, password) { I.fillField(this.fields.email, email); I.fillField(this.fields.password, password); I.click(this.submitButton); - }, + } register(email, password) { // use another page object inside current one registerPage.registerUser({ email, password }); } } + +export default LoginPage ``` -You can include this pageobject in a test by its name (defined in `codecept.json`). If you created a `loginPage` object, +> The `inject()` call at the top returns a lazy proxy. `I` and other page objects resolve at call time, so it's safe to destructure before class definition. + +You can include this pageobject in a test by its name (defined in `codecept.conf.js`). If you created a `loginPage` object, it should be added to the list of arguments to be included in the test: ```js @@ -116,19 +107,18 @@ Scenario('login', ({ I, loginPage }) => { }); ``` -Also, you can use `async/await` inside a Page Object: +You can use `async/await` inside a Page Object: ```js const { I } = inject(); -module.exports = { - +class MainPage { // setting locators - container: "//div[@class = 'numbers']", - mainItem: { + container = "//div[@class = 'numbers']" + mainItem = { number: ".//div[contains(@class, 'numbers__main-number')]", title: ".//div[contains(@class, 'numbers__main-title-block')]" - }, + } // introducing methods async openMainArticle() { @@ -144,31 +134,28 @@ module.exports = { return title; } } + +export default MainPage ``` and use them in your tests: ```js -Scenario('login2', async ({ I, loginPage, basePage }) => { +Scenario('open article', async ({ I, mainPage, basePage }) => { let title = await mainPage.openMainArticle() basePage.pageShouldBeOpened(title) }); ``` -Page Objects can be functions, arrays or classes. When declared as classes you can easily extend them in other page objects. - -Here is an example of declaring page object as a class: +Page objects can also be extended via class inheritance: ```js -const { expect } = require('chai'); const { I } = inject(); class AttachFile { - constructor() { - this.inputFileField = 'input[name=fileUpload]'; - this.fileSize = '.file-size'; - this.fileName = '.file-name' - } + inputFileField = 'input[name=fileUpload]' + fileSize = '.file-size' + fileName = '.file-name' async attachFileFrom(path) { await I.waitForVisible(this.inputFileField) @@ -180,20 +167,70 @@ class AttachFile { const size = await I.grabTextFrom(this.fileSize) expect(size).toEqual(fileSizeText) } +} + +// Export class for auto-instantiation +export default AttachFile +``` - async hasFileSizeInPosition(fileNameText, position) { - await I.waitNumberOfVisibleElements(this.fileName, position) - const text = await I.grabTextFrom(this.fileName) - expect(text[position - 1]).toEqual(fileNameText) +> While building complex page objects it is important to keep all `async` functions to be called with `await`. While CodeceptJS allows to run commands synchronously if async function has `I.grab*` or any custom function that returns a promise it must be called with `await`. If you see `UnhandledPromiseRejectionWarning` it might be caused by async page object function that was called without `await`. + +## Page Object Lifecycle Hooks + +Page objects support lifecycle hooks that mirror the helper hook system. These methods are called automatically by the framework: + +| Hook | When it runs | +|------|-------------| +| `_before()` | Before the first method call on this page object in a test (lazy, per-test) | +| `_after()` | After each test, but only if the page object was used in that test | +| `_beforeSuite()` | Before each Feature/suite (for all page objects that define it) | +| `_afterSuite()` | After each Feature/suite (for all page objects that define it) | + +```js +const { I } = inject(); + +class DashboardPage { + _before() { + I.amOnPage('/dashboard'); + I.waitForElement('.dashboard-loaded'); + } + + _after() { + I.clearCookie(); + } + + _afterSuite() { + I.sendDeleteRequest('/api/test-data/cleanup'); + } + + grabStats() { + return I.grabTextFrom('.stats'); + } + + seeWelcomeMessage(name) { + I.see(`Welcome, ${name}`, '.header'); } } -// For inheritance -module.exports = new AttachFile(); -module.exports.AttachFile = AttachFile; +export default DashboardPage ``` -> ⚠ While building complex page objects it is important to keep all `async` functions to be called with `await`. While CodeceptJS allows to run commands synchronously if async function has `I.grab*` or any custom function that returns a promise it must be called with `await`. If you see `UnhandledPromiseRejectionWarning` it might be caused by async page object function that was called without `await`. +```js +Scenario('see dashboard stats', async ({ I, dashboardPage }) => { + // dashboardPage._before() runs automatically before this line + dashboardPage.seeWelcomeMessage('John'); + const stats = await dashboardPage.grabStats(); + I.say(`Stats: ${stats}`); + // dashboardPage._after() runs automatically after test ends +}); +``` + +Key behaviors: +- `_before()` runs **lazily** — only when the page object is first used in a test, not when it's injected +- `_before()` runs **once per test** — calling multiple methods does not re-trigger it +- `_after()` is **skipped** for page objects that were never used in the test +- `_beforeSuite()` and `_afterSuite()` run for **all** page objects that define them, regardless of usage +- Hook methods are **not** shown as test steps in the output ## Page Fragments @@ -211,18 +248,18 @@ Methods of page fragments can use `within` block to narrow scope to a root locat ```js const { I } = inject(); -// fragments/modal.js -module.exports = { - root: '#modal', +class Modal { + root = '#modal' - // we are clicking "Accept: inside a popup window accept() { within(this.root, function() { I.click('Accept'); }); } } + +export default Modal ``` To use a Page Fragment within a Test Scenario, just inject it into your Scenario: @@ -242,13 +279,14 @@ To use a Page Fragment within a Page Object, you can use `inject` method to get ```js const { I, modal } = inject(); -module.exports = { - doStuff() { - ... +class CheckoutPage { + confirmOrder() { + I.click('Place Order'); modal.accept(); - ... } } + +export default CheckoutPage ``` > PageObject and PageFragment names are declared inside `include` section of `codecept.conf.js`. See [Dependency Injection](#dependency-injection) @@ -267,18 +305,103 @@ Technically, they are the same as PageObjects. StepObjects can inject PageObject ```js const { I, userPage, permissionPage } = inject(); -module.exports = { - +class AdminSteps { createUser(name) { // action composed from actions of page objects userPage.open(); userPage.create(name); permissionPage.activate(name); } +} -}; +export default AdminSteps ``` +## Data Objects + +Data Objects are page objects designed to manage test data via API. They use the REST helper (through `I`) to create data in a test and clean it up automatically via the `_after()` hook. + +This is a lightweight alternative to [ApiDataFactory](/helpers/ApiDataFactory) — ideal when you want full control over data creation and cleanup logic without factory configuration. + +### Defining a Data Object + +```js +const { I } = inject(); + +class UserData { + constructor() { + this._created = []; + } + + async createUser(data = {}) { + const response = await I.sendPostRequest('/api/users', { + name: data.name || 'Test User', + email: data.email || `test-${Date.now()}@example.com`, + ...data, + }); + this._created.push(response.data.id); + return response.data; + } + + async createPost(userId, data = {}) { + const response = await I.sendPostRequest('/api/posts', { + userId, + title: data.title || 'Test Post', + body: data.body || 'Test body', + ...data, + }); + this._created.push({ type: 'post', id: response.data.id }); + return response.data; + } + + async _after() { + for (const record of this._created.reverse()) { + const id = typeof record === 'object' ? record.id : record; + const type = typeof record === 'object' ? record.type : 'user'; + try { + await I.sendDeleteRequest(`/api/${type}s/${id}`); + } catch (e) { + // cleanup errors should not fail the test + } + } + this._created = []; + } +} + +export default UserData +``` + +### Configuration + +Add the REST helper and the Data Object to your config: + +```js +helpers: { + Playwright: { url: 'http://localhost', browser: 'chromium' }, + REST: { + endpoint: 'http://localhost/api', + defaultHeaders: { 'Content-Type': 'application/json' }, + }, +}, +include: { + I: './steps_file.js', + userData: './data/UserData.js', +} +``` + +### Usage in Tests + +```js +Scenario('user sees their profile', async ({ I, userData }) => { + const user = await userData.createUser({ name: 'John Doe' }); + I.amOnPage(`/users/${user.id}`); + I.see('John Doe'); + // userData._after() runs automatically — deletes the created user +}); +``` + +Data Objects can use any helper methods available via `I`, including `sendGetRequest`, `sendPutRequest`, and browser actions. They combine the convenience of managed test data with the flexibility of page objects. + ## Dynamic Injection You can inject objects per test by calling `injectDependencies` function in a Scenario: @@ -291,3 +414,27 @@ Scenario('search @grop', ({ I, Data }) => { ``` This requires the `./data.js` module and assigns it to a `Data` argument in a test. + +## Plain Object Page Objects (Legacy) + +Plain object page objects are still supported for backward compatibility: + +```js +const { I } = inject(); + +module.exports = { + fields: { + email: '#user_basic_email', + password: '#user_basic_password' + }, + submitButton: { css: '#new_user_basic input[type=submit]' }, + + sendForm(email, password) { + I.fillField(this.fields.email, email); + I.fillField(this.fields.password, password); + I.click(this.submitButton); + } +} +``` + +> Class-based page objects are recommended for new code as they support lifecycle hooks and inheritance. diff --git a/lib/codecept.js b/lib/codecept.js index e01fe8345..81ddce456 100644 --- a/lib/codecept.js +++ b/lib/codecept.js @@ -128,6 +128,7 @@ class Codecept { './listener/config.js', './listener/result.js', './listener/helpers.js', + './listener/pageobjects.js', './listener/globalTimeout.js', './listener/globalRetry.js', './listener/retryEnhancer.js', diff --git a/lib/container.js b/lib/container.js index 8d44753e2..f18e4ecf8 100644 --- a/lib/container.js +++ b/lib/container.js @@ -18,6 +18,11 @@ import actorFactory from './actor.js' let asyncHelperPromise +let beforeCalledSet = new Set() + +export function getBeforeCalledSet() { return beforeCalledSet } +export function resetBeforeCalledSet() { beforeCalledSet = new Set() } + let container = { helpers: {}, support: {}, @@ -156,6 +161,17 @@ class Container { return container.proxySupport[name] } + /** + * Get raw (non-proxied) support objects for direct access. + * Used by listeners to call lifecycle hooks without MetaStep wrapping. + * + * @api + * @returns {object} + */ + static supportObjects() { + return container.support + } + /** * Get all helpers or get a helper by name * @@ -544,6 +560,19 @@ function createSupportObjects(config) { let currentValue = currentObject[prop] if (isFunction(currentValue) || isAsyncFunction(currentValue)) { + if (prop.toString().charAt(0) !== '_' && currentObject._before && !beforeCalledSet.has(name)) { + beforeCalledSet.add(name) + const originalValue = currentValue + const wrappedValue = async function (...args) { + await currentObject._before() + return originalValue.apply(currentObject, args) + } + const ms = new MetaStep(name, prop) + ms.setContext(currentObject) + debug(`metastep is created for ${name}.${prop.toString()}() (with _before)`) + return ms.run.bind(ms, asyncWrapper(wrappedValue)) + } + const ms = new MetaStep(name, prop) ms.setContext(currentObject) if (isAsyncFunction(currentValue)) currentValue = asyncWrapper(currentValue) diff --git a/lib/listener/pageobjects.js b/lib/listener/pageobjects.js new file mode 100644 index 000000000..d9d28ea50 --- /dev/null +++ b/lib/listener/pageobjects.js @@ -0,0 +1,43 @@ +import event from '../event.js' +import recorder from '../recorder.js' +import store from '../store.js' +import container from '../container.js' +import { resetBeforeCalledSet, getBeforeCalledSet } from '../container.js' + +export default function () { + const runAsyncSupportHook = (hook, param, force) => { + if (store.dryRun) return + const support = container.supportObjects() + Object.keys(support).forEach(key => { + if (key === 'I') return + const obj = support[key] + if (!obj || typeof obj !== 'object' || !obj[hook]) return + recorder.add(`pageobject ${key}.${hook}()`, () => obj[hook](param), force, false) + }) + } + + event.dispatcher.on(event.test.started, () => { + resetBeforeCalledSet() + }) + + event.dispatcher.on(event.test.after, () => { + if (store.dryRun) return + const support = container.supportObjects() + const called = getBeforeCalledSet() + called.forEach(name => { + const obj = support[name] + if (obj && obj._after) { + recorder.add(`pageobject ${name}._after()`, () => obj._after(), true, false) + } + }) + recorder.catchWithoutStop(() => {}) + }) + + event.dispatcher.on(event.suite.after, suite => { + runAsyncSupportHook('_afterSuite', suite, true) + }) + + event.dispatcher.on(event.suite.before, suite => { + runAsyncSupportHook('_beforeSuite', suite, true) + }) +} diff --git a/test/data/sandbox/configs/pageObjects/codecept.hooks.js b/test/data/sandbox/configs/pageObjects/codecept.hooks.js new file mode 100644 index 000000000..c09d69c60 --- /dev/null +++ b/test/data/sandbox/configs/pageObjects/codecept.hooks.js @@ -0,0 +1,17 @@ +export const config = { + tests: './*_test.hooks.js', + output: './output', + helpers: { + CustomHelper: { + require: './customHelper.js', + }, + }, + include: { + I: './steps_file.js', + hookpage: './pages/hookpage.js', + nohookpage: './pages/nohookpage.js', + }, + bootstrap: null, + mocha: {}, + name: 'pageobject-hooks', +} diff --git a/test/data/sandbox/configs/pageObjects/hooks_test.hooks.js b/test/data/sandbox/configs/pageObjects/hooks_test.hooks.js new file mode 100644 index 000000000..d6fe9bc96 --- /dev/null +++ b/test/data/sandbox/configs/pageObjects/hooks_test.hooks.js @@ -0,0 +1,18 @@ +Feature('PageObject Hooks') + +Scenario('@PageObjectHooks', ({ hookpage }) => { + hookpage.doSomething() +}) + +Scenario('@PageObjectHooksMultipleCalls', ({ hookpage }) => { + hookpage.doSomething() + hookpage.doSomething() +}) + +Scenario('@PageObjectNoHooksUsed', ({ nohookpage }) => { + nohookpage.doAction() +}) + +Scenario('@PageObjectNotUsed', ({ I }) => { + I.printMessage('only I used') +}) diff --git a/test/data/sandbox/configs/pageObjects/pages/hookpage.js b/test/data/sandbox/configs/pageObjects/pages/hookpage.js new file mode 100644 index 000000000..7bc18045a --- /dev/null +++ b/test/data/sandbox/configs/pageObjects/pages/hookpage.js @@ -0,0 +1,21 @@ +const { I } = inject() + +class HookPage { + _before() { + I.printMessage('HookPage._before called') + } + + _after() { + I.printMessage('HookPage._after called') + } + + _afterSuite() { + I.printMessage('HookPage._afterSuite called') + } + + doSomething() { + I.printMessage('HookPage action') + } +} + +export default HookPage diff --git a/test/data/sandbox/configs/pageObjects/pages/nohookpage.js b/test/data/sandbox/configs/pageObjects/pages/nohookpage.js new file mode 100644 index 000000000..e16b5fc1b --- /dev/null +++ b/test/data/sandbox/configs/pageObjects/pages/nohookpage.js @@ -0,0 +1,9 @@ +const { I } = inject() + +class NoHookPage { + doAction() { + I.printMessage('NoHookPage action') + } +} + +export default NoHookPage diff --git a/test/runner/pageobject_test.js b/test/runner/pageobject_test.js index e32d228a0..2e30ce93f 100644 --- a/test/runner/pageobject_test.js +++ b/test/runner/pageobject_test.js @@ -172,6 +172,56 @@ describe('CodeceptJS PageObject', () => { }) }) + describe('PageObject Hooks', () => { + it('should run _before on first method call and _after after test', done => { + exec(`${config_run_config('codecept.hooks.js', '@PageObjectHooks$')} --debug`, (err, stdout) => { + expect(stdout).toContain('HookPage._before called') + expect(stdout).toContain('HookPage action') + expect(stdout).toContain('HookPage._after called') + expect(stdout).toContain('HookPage._afterSuite called') + const beforeIdx = stdout.indexOf('HookPage._before called') + const actionIdx = stdout.indexOf('HookPage action') + const afterIdx = stdout.indexOf('HookPage._after called') + expect(beforeIdx).toBeLessThan(actionIdx) + expect(actionIdx).toBeLessThan(afterIdx) + expect(stdout).toContain('OK | 1 passed') + expect(err).toBeFalsy() + done() + }) + }) + + it('should call _before only once for multiple method calls', done => { + exec(`${config_run_config('codecept.hooks.js', '@PageObjectHooksMultipleCalls')} --steps`, (err, stdout) => { + const lines = stdout.split('\n').filter(l => l.trim() === 'HookPage._before called') + expect(lines).toHaveLength(1) + expect(stdout).toContain('OK | 1 passed') + expect(err).toBeFalsy() + done() + }) + }) + + it('should not call _before/_after for unused page objects', done => { + exec(`${config_run_config('codecept.hooks.js', '@PageObjectNotUsed')} --debug`, (err, stdout) => { + expect(stdout).not.toContain('HookPage._before called') + expect(stdout).not.toContain('HookPage._after called') + expect(stdout).toContain('only I used') + expect(stdout).toContain('OK | 1 passed') + expect(err).toBeFalsy() + done() + }) + }) + + it('should not call _before/_after for page objects without hooks', done => { + exec(`${config_run_config('codecept.hooks.js', '@PageObjectNoHooksUsed')} --debug`, (err, stdout) => { + expect(stdout).not.toContain('_before called') + expect(stdout).toContain('NoHookPage action') + expect(stdout).toContain('OK | 1 passed') + expect(err).toBeFalsy() + done() + }) + }) + }) + it('built methods are still available custom I steps_file is added', done => { exec(`${config_run_config('codecept.class.js', '@CustomStepsBuiltIn')} --debug`, (err, stdout) => { expect(stdout).toContain('Built in say')