Assertions
CodeceptJS ships with browser assertions built into the I object — I.see('Welcome'), I.seeElement('.cart'), I.dontSee('Error'). They read like prose, produce clear failure messages, and cover most day-to-day checks with no extra setup.
When the built-ins are not enough — sort order, business math, JSON shapes, domain rules — you have three ways to assert, in order of preference:
- Reusable custom assertions in a helper:
I.seeTableIsOrdered('Price', 'desc') - Quick assertions with ExpectHelper on grabbed data:
I.expectDeepEqualExcluding(order, expected, ['id']) - Bring your own library —
chai,jest, or Node’sassert
This page also covers element assertions via WebElement, soft assertions for running many checks in one scenario, and masking secrets in assertion logs.
Built-in Assertions
Section titled “Built-in Assertions”Every browser helper (Playwright, WebDriver, Puppeteer) exposes the same assertion API. Every positive check has a dontSee* counterpart.
Text on the Page
Section titled “Text on the Page”I.see(text) asserts that visible text appears on the page. A second argument scopes the search.
I.see('Order confirmed')I.see('Total: $42.00', '.cart-summary')I.dontSee('Out of stock')seeTextEquals is stricter — the element’s text must match exactly.
I.seeTextEquals('Welcome, Miles', 'h1')
seechecks rendered, visible text. For content hidden by CSS, useseeInSourceorseeElementInDOM.
Elements
Section titled “Elements”ARIA locators make the strongest assertions — they survive CSS refactors and describe what the user sees.
I.seeElement({ role: 'button', name: 'Submit' })I.seeElement('.alert-success', '#checkout')I.dontSeeElement('.spinner')
I.seeElementInDOM('#hidden-token') // in the DOM, possibly invisibleI.dontSeeElementInDOM('.removed-row')Counts, Forms, URLs
Section titled “Counts, Forms, URLs”I.seeNumberOfElements('.cart-item', 3)I.seeNumberOfVisibleElements('.notification', 1)
I.seeInField('Email', 'miles@davis.com')I.seeCheckboxIsChecked('Accept Terms')
I.seeInCurrentUrl('/dashboard')I.seeInTitle('Admin — Orders')I.seeCookie('session')Custom Assertions
Section titled “Custom Assertions”When You Need Custom Assertions
Section titled “When You Need Custom Assertions”Reach for a custom assertion when a check is:
- Repeated across tests — “is this table sorted?”, “does this response match our Order schema?”
- Domain-specific — “is the cart total equal to the sum of its items plus tax?”
- Structural, not textual — shape of a JSON payload, ordering of a list, layout of elements
- Too noisy with built-ins — chains of
I.seecalls that obscure intent
Four options, from least setup to most:
- Expect Helper — chai matchers exposed on
I(I.expectEqual,I.expectDeepEqual, …). Best for quick, readable one-offs on grabbed data. - Built-in
codeceptjs/assertions— the same factories CodeceptJS uses internally. Zero dependencies, failure messages matchI.seestyle. - External library —
chai,jest,node:assert. Use when you need a matcher the others do not cover. - Reusable custom assertions — wrap any of the above into your own
I.see*method via a helper class. Best for checks that repeat across many tests.
Quick Assertions with Expect Helper
Section titled “Quick Assertions with Expect Helper”ExpectHelper exposes chai’s assertions as methods on the I object. Use it for one-off checks on data you have already grabbed — no helper class, no boilerplate.
Install separately:
npm i -D @codeceptjs/expect-helperConfigure:
helpers: { Playwright: { /* ... */ }, ExpectHelper: {},}Use it in scenarios:
Scenario('checkout total matches the sum of line items', async ({ I }) => { I.amOnPage('/cart')
const prices = await I.grabTextFromAll('.line-item .price') const total = await I.grabTextFrom('.cart-total')
const sum = prices .map(p => Number(p.replace(/[^0-9.]/g, ''))) .reduce((a, b) => a + b, 0)
I.expectEqual(Number(total.replace(/[^0-9.]/g, '')), sum)})Scenario('API returns the created order shape', async ({ I }) => { const { data } = await I.sendPostRequest('/api/orders', { items: ['SKU-1'] })
I.expectDeepEqualExcluding( data, { items: ['SKU-1'], status: 'pending', total: 29.99 }, ['id', 'createdAt', 'updatedAt'], ) I.expectMatchesPattern(data.id, /^ord_[a-z0-9]{16}$/) I.expectLengthOf(data.items, 1)})Common methods:
| Method | Purpose |
|---|---|
expectEqual / expectNotEqual | Shallow equality |
expectDeepEqual / expectDeepMembers | Deep equality for objects and arrays |
expectDeepEqualExcluding | Deep equality, ignoring named fields |
expectContain / expectStartsWith / expectEndsWith | Substring / prefix / suffix |
expectMatchesPattern / expectMatchRegex | Regex match |
expectAbove / expectBelow / expectLengthOf | Numeric and length checks |
expectHasProperty / expectEmpty | Object shape |
expectJsonSchema / expectJsonSchemaUsingAJV | Full schema validation |
ExpectHelper calls appear in the step log next to browser steps, so failures read in order with the rest of the scenario. See the full reference.
Built-in Assertion Library
Section titled “Built-in Assertion Library”CodeceptJS ships a small, dependency-free assertion library at codeceptjs/assertions. It powers every built-in I.see* method, and you can use it directly in your own scenarios and helpers. Failure messages render with the same formatting as I.see failures, so reports stay consistent.
import { equals, includes, empty, truth } from 'codeceptjs/assertions'Each factory takes a subject — the noun that appears in the failure message — and returns an assertion with .assert(actual, expected) (fails on mismatch) and .negate(actual, expected) (the dontSee* direction).
Strict equality, comparing a grabbed value to an expected one:
const total = await I.grabTextFrom('.cart-total')equals('cart total').assert(total, '$42.00')// expected cart total "$10.00" to equal "$42.00"
// negate — useful when an action should change a valueconst sessionAfter = await I.grabCookie('session')equals('session id').negate(sessionAfter.value, sessionBefore.value)// expected session id not to equal "abc123"Substring or array contains, working on grabbed text or arrays:
const title = await I.grabTitle()includes('page title').assert('Welcome', title)// expected page title to include "Welcome"
const resultTitles = await I.grabTextFromAll('.result h3')includes('search results').assert('miles', resultTitles)// expected search results to include "miles"
const logs = await I.grabBrowserLogs()includes('console logs').negate('Uncaught', logs.map(l => l.text()))// expected console logs not to include "Uncaught"Empty value or empty array — pairs naturally with grabTextFromAll or grabWebElements:
I.click('Archive all')const remaining = await I.grabWebElements('.email-row')empty('inbox').assert(remaining)// expected inbox '[ELEMENT, ELEMENT]' to be empty
I.click('Submit')const errors = await I.grabTextFromAll('.field-error')empty('form errors').assert(errors)// expected form errors '[Email is required]' to be emptyTruthy value with custom phrasing — the second argument shapes the message:
const cookie = await I.grabCookie('session')truth('session cookie', 'to be set').assert(cookie)// expected session cookie to be set
const button = await I.grabWebElement({ role: 'button', name: 'Checkout' })truth('checkout button', 'to be enabled').assert(await button.isEnabled())// expected checkout button to be enabled
const stock = Number(await I.grabAttributeFrom('.product', 'data-stock'))truth('stock level', 'to be positive').assert(stock > 0)// expected stock level to be positiveFor comparisons the four factories do not cover, fall through to chai/jest/node:assert, or wrap the check in a reusable custom assertion helper.
Using an External Library
Section titled “Using an External Library”When you need a matcher that ExpectHelper does not cover, or your team already standardises on a library, grab the data and assert however you like. Any library works — grab* methods return plain JavaScript values.
grab*methods always needawait.
Node’s built-in assert — zero dependencies:
import { strict as assert } from 'node:assert'
Scenario('profile email matches the logged-in user', async ({ I }) => { I.amOnPage('/profile') const email = await I.grabTextFrom('.user-email') assert.equal(email, 'miles@davis.com')})Chai:
import { expect } from 'chai'
Scenario('product list is sorted alphabetically', async ({ I }) => { I.amOnPage('/catalog') const names = await I.grabTextFromAll('.product .name') expect(names).to.deep.equal([...names].sort())})Jest’s expect (install expect standalone if you are not on Jest):
import { expect } from 'expect'
Scenario('dashboard renders every KPI', async ({ I }) => { I.amOnPage('/dashboard') const kpis = await I.grabTextFromAll('.kpi .value') expect(kpis).toHaveLength(6) expect(kpis[0]).toMatch(/^\$[\d,]+$/)})Failures from these libraries fail the scenario normally, but they do not appear as CodeceptJS steps — the failure shows up in the error output. For checks you want visible in the step log, prefer ExpectHelper or codeceptjs/assertions.
Reusable Custom Assertions
Section titled “Reusable Custom Assertions”When the same check appears across many tests, wrap it in a custom helper. The assertion lives in one place, has a name that reads like a requirement, and produces a clean failure message.
Scaffold a helper with npx codeceptjs gh, then write a class extending @codeceptjs/helper. Public methods — anything not prefixed with _ — become methods on I. Reach other helpers through this.helpers['<HelperName>']. Inside the helper, use codeceptjs/assertions (or any of the libraries above) — never throw new Error(...) — so failures render as proper assertion errors.
helpers/table_assertions.js
import Helper from '@codeceptjs/helper'import { equals } from 'codeceptjs/assertions'
class TableAssertions extends Helper { /** * @param {string} columnName - text of the column header * @param {'asc'|'desc'} order */ async seeTableIsOrdered(columnName, order = 'asc') { const { Playwright } = this.helpers const headers = await Playwright.grabTextFromAll('table thead th') const col = headers.findIndex(h => h.trim() === columnName) + 1 const cells = await Playwright.grabTextFromAll(`table tbody tr td:nth-child(${col})`) const sorted = [...cells].sort() if (order === 'desc') sorted.reverse()
equals(`column "${columnName}" sorted ${order}`).assert(cells.join(','), sorted.join(',')) }}
export default TableAssertionsWire it up in codecept.conf.js:
helpers: { Playwright: { /* ... */ }, TableAssertions: { require: './helpers/table_assertions.js' },}Use it:
Scenario('orders table sorts by price on click', ({ I }) => { I.amOnPage('/orders') I.click('Price') I.seeTableIsOrdered('Price', 'asc') I.click('Price') I.seeTableIsOrdered('Price', 'desc')})Follow the naming convention: positive assertions start with
see*, negative withdontSee*(use.negate()from the same factory). It keeps the custom API consistent with CodeceptJS built-ins.
Element Assertions
Section titled “Element Assertions”grabWebElement and grabWebElements return objects with a uniform API across helpers: isVisible(), isEnabled(), getText(), getAttribute(), getBoundingBox(), exists(). See the full WebElement API.
Use WebElement when you need to loop over many elements and assert on each.
Scenario('every todo row has a label and a checkbox', async ({ I }) => { I.amOnPage('/todos') const rows = await I.grabWebElements('.todo-item') I.expectLengthAboveThan(rows, 0)
for (const row of rows) { const label = await row.getText() I.expectNotEmpty(label.trim())
const checkbox = await row.$('input[type="checkbox"]') I.expectTrue(await checkbox.isVisible()) }})getBoundingBox() enables layout assertions — confirming a sticky header stays pinned, or a tooltip sits inside the viewport.
const header = await I.grabWebElement('.sticky-header')const box = await header.getBoundingBox()I.expectEqual(box.y, 0)Soft Assertions
Section titled “Soft Assertions”Use a soft assertion when one scenario needs to check many independent facts and you want to see every failure in one run, not just the first.
CodeceptJS provides hopeThat from codeceptjs/effects. It wraps a block of I.* steps:
- If the steps inside succeed,
hopeThatreturnstrue. - If any step inside fails, the failure is logged to the report as a note, the scenario keeps running, and
hopeThatreturnsfalse.
import { hopeThat } from 'codeceptjs/effects'
Scenario('registration form shows every validation error at once', async ({ I }) => { I.amOnPage('/register') I.click('Create Account') // submit empty form
await hopeThat(() => I.see('Email is required', '#email-error')) await hopeThat(() => I.see('Password is required', '#password-error')) await hopeThat(() => I.see('You must accept the terms', '#terms-error')) await hopeThat(() => I.seeElement('.summary-error'))})Failures are written to the test log as Unsuccessful assertion > ... and attached to the test as notes for reporters that surface them.
hopeThat does not fail the scenario on its own — each call logs the failure and lets the scenario continue. Call hopeThat.noErrors() once at the end to fail the scenario if any soft assertion failed. It throws a single assertion error listing every recorded failure and clears the state for the next test.
import { hopeThat } from 'codeceptjs/effects'
Scenario('registration form shows every validation error at once', async ({ I }) => { I.amOnPage('/register') I.click('Create Account') // submit empty form
await hopeThat(() => I.see('Email is required', '#email-error')) await hopeThat(() => I.see('Password is required', '#password-error')) await hopeThat(() => I.see('You must accept the terms', '#terms-error')) await hopeThat(() => I.seeElement('.summary-error'))
hopeThat.noErrors()})If two checks failed, the scenario fails with a single aggregated message like:
expected soft assertions '[expected web application to include "You must accept the terms", expected element (.summary-error) to be visible]' to be emptyDecision Assertions
Section titled “Decision Assertions”Some checks are hard to express with locators: “checkout form has all required fields”, “success message is shown”, “sidebar is shown”. The Decision helper asserts such statements in plain language with a decision model.
A decision model, like Jev, does not generate text. It reads the page and returns the probability that a statement is true. This makes it a good fit for assertions:
- Fast. One request returns in a fraction of a second, so a decision step runs about as fast as a regular browser step.
- Cost-efficient. A request costs a fraction of a cent. You can run decision assertions in every CI build.
- Reliable. The answer is a probability, not free text. There is nothing to parse, and you choose how confident the model must be for the step to pass.
Configure the decision model in ai.decisionModel as described in Configure Decision Models, and enable the helper next to your browser helper:
helpers: { Playwright: { url: 'http://localhost' }, Decision: {},}ai.decisionModel accepts:
| Option | Default | Description |
|---|---|---|
model | decision model for I.decide | |
visualModel | decision model with image input for I.decideVisually | |
confidence | 0.7 | minimal probability for a statement to pass |
timeout | 15000 | request timeout in ms |
maxLength | 12000 | maximal length of ARIA snapshot or HTML sent to the model |
Any model from the OpenRouter decision models list can be used with openrouter.evaluationModel(). Decisions don’t need the --ai flag, and ai.model is not required.
Then assert statements about the current page:
I.decide('top level navigation is available')
I.decide([ 'checkout form has all required fields', 'success message is shown', 'submit button enabled', 'cancel button present',])
I.decideVisually('sidebar is shown')I.decide sends the page URL, title and ARIA snapshot to the model. A statement passes when its probability reaches confidence. A list of statements is checked in one request, and every statement must pass. The failure message lists the statements that failed, with their probabilities:
expected page to satisfy "success message is shown" (12%) with confidence of 70%I.decideVisually also sends a screenshot, so it needs a visual decision model with image input, like Clef, set as visualModel. Visual decisions are experimental.
To turn decisions off without removing the helper, set its mode:
| Mode | Behavior |
|---|---|
assert | default, fails the step when a statement is not confirmed |
report | requests the model but never fails, even on API errors; results and errors are added to the step as a comment |
skip | does not request the model, every decision passes |
helpers: { Playwright: { url: 'http://localhost' }, Decision: { mode: process.env.CI ? 'assert' : 'skip' },}A failed decision is not retried by the retryFailedStep plugin: asking again would cost another request and return the same answer. Only connection errors and timeouts are retried.
Use decision assertions for what a page means, and built-in assertions for exact values. I.see('Total: $42.00') is still the right check for a price.
Choosing an Approach
Section titled “Choosing an Approach”| You want to check… | Use |
|---|---|
| Visible text on the page | I.see / I.dontSee |
| An element by role and accessible name | I.seeElement({ role, name }) |
| A form field’s current value | I.seeInField / I.seeCheckboxIsChecked |
| URL or page title | I.seeInCurrentUrl / I.seeInTitle |
| A count of matching elements | I.seeNumberOfElements |
| Business logic / JSON shape on grabbed data | ExpectHelper — expectEqual, expectDeepEqualExcluding, expectJsonSchema |
| A lightweight, dependency-free assertion in a scenario | equals, includes, empty, truth from codeceptjs/assertions |
| Per-element state in a loop | grabWebElements + WebElement API |
| A matcher the above do not cover | grab* + chai / jest / node:assert |
| A reusable, project-specific check | Custom helper with see* method using codeceptjs/assertions |
| Many independent checks in one run | hopeThat from codeceptjs/effects |
| A statement that is hard to express with locators | Decision helper — I.decide, I.decideVisually |
| Hiding values from logs | secret() |