Browser client

Browser client

The browser client of Japa is built on top of Playwright library and integrates seamlessly with the Japa test runner. Following are some reasons to use this plugin over manually interacting with the Playwright API.

  • Automatic management of browsers and browser contexts.
  • Built-in assertions.
  • Ability to extend the browser, context, and page objects using decorators.
  • Class-based pages and interactions to de-compose the page under test into smaller and reusable components.
  • Toggle headless mode, tracing, and browsers using CLI flags.

Installation

The browser client plugin has peer dependencies on the @japa/assert plugin and the playwright library. Make sure to install them before installing this plugin.

Peer dependencies
npm i -D @japa/assert playwright
npm i -D @japa/browser-client

And register it as a plugin within the entry point file, i.e. (bin/test.js)

import { assert } from '@japa/assert'
import { configure } from '@japa/runner'
import { browserClient } from '@japa/browser-client'
configure({
plugins: [
assert(),
browserClient({
runInSuites: ['browser']
})
]
})

Configuring browser suite

You must configure a separate suite for browser tests. This ensures the rest of your tests do not get slow, as this plugin will create a new browser context for each test.

In the following example, we create two test suites, one for running browser tests and another for running unit tests. Also, we tell the browserClient plugin to create a new browser context only in the browser suite.

configure({
suites: [
{
name: 'browser',
timeout: 30 * 1000,
files: ['tests/browser/**/*.spec.js'],
},
{
name: 'unit',
files: ['tests/unit/**/*.spec.js'],
}
],
plugins: [
assert(),
browserClient({
runInSuites: ['browser']
})
]
})

Basic example

Once the setup is completed, you can write tests inside the tests/browser directory.

tests/browser/visit_japa.spec.js
import { test } from '@japa/runner'
test('has docs for browser client', async ({ visit }) => {
const page = await visit('https://japa.dev/docs')
await page.getByRole('link', { name: 'Browser client' }).click()
/**
* Assertions
*/
await page.assertPath('/docs/plugins/browser-client')
await page.assertTextContains('body', 'Browser client')
})

Let's run the test using the node bin/test.js file.

node bin/test.js
# Run tests for browser suite
node bin/test.js browser
# Launch browser
node bin/test.js browser --headed
# Run in slow motion
node bin/test.js browser --headed --slow

Browser API

Since the browser client plugin uses Playwright under the hood, you can access all the Playwright library methods. Refer to the following example for more information.

test('has docs for browser client', async ({
browser,
browserContext,
visit
}) => {
// Create new page
const page = await browserContext.newPage()
await page.goto(url)
// Or use visit helper
const page = await visit(url)
// Create multiple contexts
const context1 = await browser.newContext()
const context2 = await browser.newContext()
})

page

Reference to the Playwright's Page class. You can get an instance of it either using the visit method or the browserContext.newPage method.

browserContext

Reference to the Playwright's Context class. An isolated instance of browserContext is shared with every test.

browser

Reference to the Playwright's Browser class.

visit

A helper method to create a new page and visit a URL in a single step. The browser client plugin adds the visit helper.

Configuration

You can configure the plugin when registering it inside the plugins array. Following is the list of available options.

plugins: [
browserClient({
runInSuites: ['browser'],
contextOptions: {},
tracing: {
enabled: false,
event: 'onError',
cleanOutputDirectory: true,
outputDirectory: join(__dirname, '..')
}
})
]

runInSuites

Configure the plugin to run for selected test suites.

browserClient({
runInSuites: ['browser'],
})

launcher

An optional function to manually launch a playwright browser. By default, we launch the chromium browser, and you can choose other browsers using the --browser flag.

You might want to implement this function if you need more control over launching a new browser with custom options.

import { firefox } from 'playwright'
browserClient({
async launcher(options) {
return firefox.launch({
...options,
...customOptionsToMerge
})
}
})

contextOptions

Configuration options to use when creating a new browser context behind the scenes. The contextOptions are given to the browser.newContext method as it is.

browserClient({
contextOptions: {
baseURL: 'http://localhost:3333',
colorScheme: 'dark',
}
})

tracing

The tracing property allows you to control the tracing event and options for generating test traces.

See also: Tracing

import { join } from 'path'
browserClient({
tracing: {
enabled: false, // can be enabled using --trace flag
event: 'onError',
cleanOutputDirectory: true,
outputDirectory: join(__dirname, '../tests/traces')
}
})

CLI flags

You can use the following CLI flags to control the behavior of tests.

browser

The --browser flag allows you to switch between browsers at runtime. This flag is only used when a custom launcher method is not defined.

node bin/test.js --browser=chromium
node bin/test.js --browser=webkit
node bin/test.js --browser=firefox

trace

The --trace flag allows you to enable the automatic tracing of tests. You must pass the event for tracing as the flag value.

# Generate trace file when a test fails
node bin/test.js --trace=onError
# Generate trace file for all tests
node bin/test.js --trace=onTest

slow

The --slow flag allows you to enable slow mode. In slow mode, all operations will be slowed down by a specified amount of milliseconds.

# Slow operations by 100ms
node bin/test.js --slow
# Slow operations by 500ms
node bin/test.js --slow=500

devtools

Open the browser devtools automatically after launching the browser. The --devtools flag will disable the headless mode.

node bin/test.js --devtools

headed

The --headed flag disables the headless mode.

node bin/test.js --headed

Class-based pages

Pages serve as an organization layer for your tests. Instead of writing all the operations inline inside the test callback, you can use dedicated page classes to encapsulate the logic for a page or an interaction.

For example, if you are writing tests for a blog, you may create test pages for listing all posts, creating a post, viewing a post, and so on.

Let's create a page class for testing the posts list view. You can organize your tests and pages as you like, but we will keep the pages next within the test directory for this example.

tests/
├── browser
└── posts
├── list.spec.js
└── pages
└── listing_page.js

Create the listing page

A page must extend the BasePage class and define the URL to visit during the test. The primary goal of a page class is to encapsulate the testing behavior and expose a declarative API.

import { BasePage } from '@japa/browser-client'
export class PostsListingPage extends BasePage {
url = '/posts'
async assertHasEmptyList() {
await this.page.assertTextContains('.posts_list', 'No posts found. Check back later')
}
async assertPostsCount(count: number) {
await this.page.assertElementsCount('.post', count)
}
async assertHasPost(title: string) {
await this.page.assertExists(
this.page.locator('.post h2', { hasText: title })
)
}
async paginateTo(page: number) {
await this.page.locator('.pagination_links a', { hasText: String(page) }).click()
}
}

Once you have created a page, you can import it inside a test and use its public API to test an endpoint behavior expressively.

import { test } from '@japa/runner'
import { PostsListingPage } from './pages/listing_page.js'
test.group('Posts | list', () => {
test('see an empty list, when posts does not exists', async ({ visit }) => {
const page = await visit(PostsListingPage)
await page.assertHasEmptyList()
})
test('see first 10 posts', async ({ visit }) => {
await PostsFactory.createMany(10)
const page = await visit(PostsListingPage)
await page.assertPostsCount(10)
})
test('navigate using pagination', async ({ visit }) => {
const posts = await PostsFactory.createMany(20)
const page = await visit(PostsListingPage)
await page.paginateTo(2)
await page.assertHasPost(posts[10].title)
})
})

Using page class with an existing page

You can use the Page classes with an existing page object using the page.use method. For example, you can create a page for viewing a single blog post and mount it inside an existing CreatePostPage or UpdatePostPage.

import { BasePage } from '@japa/browser-client'
export class ViewPostPage extends BasePage {
async assertViewingPost(title: string) {
await this.page.assertPathMatches(/\/posts\/[0-9]+/)
await this.page.assertExists(
this.page.locator('.post h1', { hasText: title })
)
}
}
import { ViewPostPage } from './pages/view_post_page.js'
test.group('Posts | create', () => {
test('create post and redirect to single post view', async ({ visit }) => {
const page = await visit('/posts/create')
const post = await getPostData()
await page.submitForm(post)
await page
.use(ViewPostPage)
.assertViewingPost(post.title)
})
})

Debugging

You can debug your tests using the PWDEBUG environment variable or by pausing the test using the page.pause method.

PWDEBUG=console node bin/test.js

Alongside the page.pause method, you can use the page.pauseIf and page.pauseUnless methods to pause the script conditionally.

test('visit home page', async ({ visit }) => {
const page = await visit('/')
await page.pauseIf(process.env.DEBUG_TEST)
await page.pauseUnless(process.env.NO_DEBUG)
})

Tracing

Playwright supports generating traces for actions performed using the Playwright's API. Traces are stored as zip files on your computer, and you can view them using either trace.playwright.dev or the npx playwright show-trace trace-file.zip command.

Using the @japa/browser-client plugin, you can automatically generate traces using the --trace CLI flag. The --trace flag accepts the event at which to create the trace file.

  • The onError event will generate trace files for failing tests.
  • The onTest event will generate trace files for all the tests.
node bin/test.js --trace=onError

You can control the output directory for trace files using the tracing.outputDirectory config option.

browserClient({
tracing: {
enabled: false, // will be enabled using the --trace flag
event: 'onError',
cleanOutputDirectory: true,
outputDirectory: join(__dirname, '../tests/traces')
}
})

Switching between browsers

You can run your tests against different browsers using the --browser flag. Following is the list of valid browser options.

  • chromium
  • firefox
  • webkit
node bin/test.js --browser=firefox
node bin/test.js --browser=chromium

You may add the above commands as npm scripts and run them together if needed.

{
"test:firefox": "node bin/test.js --browser=firefox",
"test:chromium": "node bin/test.js --browser=chromium",
"test": "npm run test:firefox && npm run test:chromium"
}

Decorators

The browser client plugin allows you to extend the browser context object, the page object, and the response object using decorators. You can create a custom decorator can register it with the decoratorsCollection.

import { decoratorsCollection } from '@japa/browser-client'
decoratorsCollection.register({
/**
* Extend page
*/
page(page) {
page.getWidth = function () {
return this.viewportSize().width
}
},
/**
* Extend context
*/
context(context) {
context.injectShaHash = function () {
this.exposeFunction('sha256', (text) => {
return crypto.createHash('sha256').update(text).digest('hex')
})
}
},
/**
* Extend response
*/
response(response) {
response.getResponseTime = function () {
return this.headers()['x-response-time']
}
},
})

If you use TypeScript, you must use declaration merging to define types for the added properties and methods.

declare module 'playwright' {
export interface Page {
getWidth(): number
}
export interface BrowserContext {
injectShaHash(): void
}
export interface Response {
getResponseTime(): String | undefined
}
}

Assertions

You can write assertions for a page using the page.assert* methods. All assertion methods are asynchronous, so await them.

assertExists

Assert an element to exist. The method accepts either a string selector or the locator object.

const page = visit('/')
await page.assertExists('h2')
await page.assertExists(page.locator('h2', { hasText: 'It works!' }))

assertNotExists

Assert an element not to exist. The method accepts either a string selector or the locator object.

const page = visit('/')
await page.assertNotExists('input[type="email"] + p')
await page.assertNotExists(page.getByRole('alert'))

assertElementsCount

Assert an element to exist and have a matching count. The method accepts either a string selector or the locator object.

const page = visit('/')
await page.assertElementsCount('.posts', 10)
await page.assertElementsCount(page.locator('.posts'))

assertVisible

Assert an element to be visible. Elements with display:none and visibility:hidden are invisible.

const page = visit('/')
await page.getByText('Delete post').click()
await page.assertVisible('.confirmation-modal')
await page.assertVisible(
page.getByText('Are you sure, you want to delete this post?')
)

assertNotVisible

Assert an element to be not visible. Elements with display:none and visibility:hidden are invisible.

const page = visit('/')
await page.assertNotVisible('.confirmation-modal')
await page.assertNotVisible(
page.getByText('Are you sure, you want to delete this post?')
)

assertTitle

Assert the page title to match the expected value.

const page = visit('/')
await page.assertTitle('Home page')

assertTitleContains

Assert the page title to include a substring value.

const page = visit('/posts/1')
await page.assertTitleContains('Post - ')

assertUrl

Assert the page URL to match the expected value. The assertion is performed against the complete URL, including the domain and query string values.

const page = visit('/posts')
await page.assertUrl('https://foo.com/posts?order_by=popular')

assertUrlContains

Assert the page URL to contain the expected substring. The assertion is performed against the complete URL, including the domain and query string values.

const page = visit('/posts')
await page.assertUrlContains('/posts?')

assertUrlMatches

Assert the page URL to match the given regular expression.

const page = visit('/posts')
await page.assertUrlMatches(/posts(\?)?/)

assertPath

Assert the page path to match the expected value. The URL is parsed using the Node.js URL parser, and the pathname value is used for assertion.

const page = visit('/posts/1')
await page.assertPath('/posts/1')

assertPathContains

Assert the page path to contain the expected substring. The URL is parsed using the Node.js URL parser, and the pathname value is used for assertion.

const page = visit('/posts/1')
await page.assertPathContains('/posts/')

assertPathMatches

Assert the page path to match the expected regex. The URL is parsed using the Node.js URL parser, and the pathname value is used for assertion.

const page = visit('/posts/1')
await page.assertPathMatches(/\/posts\/[0-9]+/)

assertQueryString

Asserts the page URL querystring to contain values for the expected object.

const page = visit('/posts')
await page
.locator('.pagination_links a', { hasText: '2' })
.click()
await page.assertQueryString({ page: '2' })

assertCookie

Assert the cookie to exist and optionally match the expected value.

const page = visit('/')
await page.assertCookie('cart_items')
await page.assertCookie('cart_total', 80)

assertCookieMissing

Assert cookie to be missing.

const page = visit('/')
await page.assertCookieMissing('cart_items')

assertText

Assert the inner text of an element to match the expected value.

const page = visit('/')
await page.assertText('span.issues_count', '25 issues')
await page.assertText(
page.getByTitle('Issues count'),
'25 Issues'
)

assertTextContains

Assert the inner text of an element to include the expected substring.

const page = visit('/')
await page.assertTextContains('body', 'It works')

assertElementsText

Assert the inner text of multiple elements to match the expected value.

const page = visit('/')
await page.assertElementsText('ul.todos > li', [
'Buy groceries',
'Publish browser client plugin',
])
const pendingTodos = page
.locator('ul.todos > li')
.filter({ has: await page.getByRole('checkbox').isChecked() })
await page.assertElementsText(
pendingTodos,
[
'Buy groceries',
'Publish browser client plugin',
]
)

assertChecked

Assert a checkbox to be checked.

const page = visit('/')
await page.assertChecked('input[name="terms"]')

assertNotChecked

Assert a checkbox not to be checked.

const page = visit('/')
await page.assertNotChecked('input[name="newsletter"]')

assertDisabled

Assert an element to be disabled. All elements are enabled unless it is a button, select, input, or a textarea with a disabled attribute.

const page = visit('/')
await page.assertDisabled('button[type="submit"]')

assertNotDisabled

Assert an element to be not disabled. All elements are enabled unless it is a button, select, input, or a textarea with a disabled attribute.

const page = visit('/')
await page.assertNotDisabled('button[type="submit"]')

assertInputValue

Assert the input value to match the expected value. The assertion must be performed against an input, textarea, or a select box.

const page = visit('/')
await page.assertInputValue('input[name="username"]', 'virk')

assertSelectedOptions

Assert the select box selected options to match the expected values.

const page = visit('/')
await page.assertSelectedOptions('select[name="tags"]', [
'js',
'css',
'html'
])