page-object
Page Objects for concise, readable, reusable tests in React projects.
page-o
is a small wrapper around @testing-library/react
with the goal of making your tests
more concise and readable while maintaining the main principles of
Testing Library.
A Quick Example
import PageObject from 'page-o';
import MyComponent from './MyComponent.jsx';
describe('My Component', () => {
let page;
beforeEach(() => {
page = new PageObject(null, {
myComponent: '[data-test=myComponent]',
});
page.render(
<MyComponent data-test="myComponent" />
);
});
afterEach(() => {
page.destroySandbox();
});
it('should have been rendered.', () => {
expect(page.myComponent.exists).toBe(true);
});
it('should render the component text.', () => {
expect(page.myComponent.text).toEqual('Hello World');
});
it('should change the message after clicking.', () => {
page.myComponent.click();
expect(page.myComponent.text).toEqual("Don't click so hard!");
});
});
Why PageObjects?
Much like you centralize your UI logic into reusable components, we
find it helpful to centralize our test logic into PageObjects. This
makes refactoring easier because component interactions are not spread
across multiple tests. Additionally, it makes your tests more readable
because complex component interactions are no longer scattered amongst
test setup routines, leaving simple, readable it
and expect
statements.
PageObjects have the benefit of:
- Making component/page interactions reusable across tests such as between unit
and integration tests.
- Making coponent interactions composable in the same way you compose
your components. For example, a PageObject representing
MyComponent
can be used across all tests that need to ineract with MyComponent
. - Making tests easier to read by moving complex component interactions out of tests.
- Making tests easier to read by providing an English like syntax
for interacting with components under test.
- Making refactoring easier by centralizing interactions and page queries.
- Making component/page setup/destruction easier by providing convenient
setup/teardown methods.
You don't need page-o
to acheive the above but we find it helps us get up
and running quickly with good testing principles with minimal boilerplate.
In fact, we used to create vanilla js objects to do everything page-o
does
and that worked just fine. After a while though, we noticed a lot of
repeated code and boilerplate so we created page-o
.
Usage
When using page-o
, interactions with a component under test are performed through a
PageObject
instance. The PageObject
exposes methods for finding elements in the DOM
and then performing actions against those DOM elements.
Creating an PageObject
let page;
beforeEach(() => {
page = new PageObject(
root,
{
todoInput: 'input',
saveTodoButton: '[data-test=saveTodoButton]'
}
);
});
Sandbox setup
Once you've created a PageObject, you can use it to setup the sandbox for your test.
The sandbox is just a DOM element where components under test are rendered.
beforeEach(() => {
page.render(
<MyComponent />
);
});
afterEach(() => {
page.destroySandbox();
});
Querying your components
Once your sandbox is setup, you can interact with components on the page through
the DOM. We use the DOM (as opposed to interacting with the React
component instances themselves) because the DOM is how your users interact with
your application.
This follows the @testing-library
guiding principles.
We also stay away from practices like shallow rendering of components
because they make tests harder to debug, harder to grock and less resiliant to change.
it('should be visible.', () => {
const myComponent = page.myComponent;
const input = page.myInput;
expect(myComponent.exists).toBe(true)
expect(myComponent.text).toEqual('Hello World');
expect(page.buttonLabeled('Click Me').exists).toBe(true);
expect(myComponent.element).toEqual( document.querySelector('[data-test=myComponent]') );
myComponent.click();
input.focus();
input.value = 'Foo Bar';
page.myForm.submit();
});
PageObject reuse
The true power of page-o
comes from the reuse of your PageObjects and query selectors.
As we mentioned earlier, you don't need page-o
to encapsulate component interactons
but we find it helps reduce a lot of boilerplate.
Reusing selectors
We generally create a *.page-object.js
file that sits next to our *.spec.jsx
file.
From this file we export our reusable PageObject and DOM selectors.
However, you could also define and export these from your spec file.
export const myComponentSelectors = {
myComponent: '[data-test=myComponent]',
nextButton: '[data-test=nextButton]',
};
beforeEach(() => {
let page = new PageObject(null, myComponentSelectors);
});
PageObject subclassing
However, we generally also like to export a PageObject
subclass that is specific
to the component under test:
export class MyComponentPageObject extends PageObject {
selectors = myComponentSelectors;
}
beforeEach(() => {
let page = new MyComponentPageObject();
});
Component specific interactions
For complex component interacts, you can now also add custom
methods to MyComponentPageObject:
export const myComponentSelectors = {
name: '[data-test=nameInput]',
location: '[data-test=locationInput]',
};
export class MyComponentPageObject extends PageObject {
selectors = myComponentSelectors;
fillEntireForm(values) {
for (key in values) {
this[key].value = values[key];
}
}
doSomethingSlow(done) {
...do slow stuff here.
done();
}
}
describe('after filling out the form', () => {
beforeEach((done) => {
page.fillEntireForm({
name: 'Batman',
location: 'Bat Cave',
});
page.doSomethingSlow(done);
});
it('should be done', () => {
});
})
PageObject composition
Another great way to reuse your PageObjects between tests is to compose
PageObjects together.
Selector composition
One way to do that is to simple compose your selector objects:
import { myComponentSelectors } from '../my-compnent/MyComponent.page-object';
export const myOtherComponentSelectors = {
...myComponentSelectors,
nameInput: myComponentSelectors.name,
locationInput: '[data-test=myComponentRoot] ' + myComponentSelectors.location,
};
PageObject composition
You could also compose PageObject classes together.
import { MyComponentPageObject } from '../my-compnent/MyComponent.page-object';
export class MyOtherComponentPageObject extends PageObject {
selectors = myOtherComponentSelectors;
get nameInput() {
const myComponent = new MyComponentPageObject();
return myComponent.name;
}
get myComponent() {
return new MyComponentPageObject();
}
get myLocation() {
return this.myComponent.location;
}
get interactWithMyComponentAtIndex(index) {
const componentRoot = this.someSelector.nth(index).element;
const component = new MyComponentPageObject( componentRoot );
return component.doComplexThing();
}
}
PageObject inheritance
As you might expect, you could also inherit from another PageObject.
import { MyComponentPageObject } from '../my-compnent/MyComponent.page-object';
export class MyOtherComponentPageObject extends MyComponentPageObject {
}
The API
The page-o
API is divided into two peices:
- The
PageObject
API - methods available directly on a PageObject
. - The
PageSelector
API - methods available to objects returned by
selector queries.
PageObject
The following API is exposed by PageObject
instances:
Name | Description |
constructor(
rootDOM,
selectors
) |
rootDOM is an HTMLElement that serves as the root within
which this PageObject will search for elements to interact with.
Passing null , undefined or false will use the sandbox as
the root element.
selectors an object of key/value pairs representing
elements in the DOM you wish to interacte with.
For example, given the following PageObject:
let page = new PageObject(rootDiv, {someDiv: '[data-test=someDiv]'})
The property page.someDiv corresponds to an object Proxy
representing a DOM element with the attribute data-test=someDiv
inside the div rootDiv .
|
render(
jsxDefinition,
stylesObject,
additionalDOM
) |
Render the jsxDefinition into the sandbox. This method
returns a reference to the sandbox DOM element.
stylesObject is a string that will be applied
as global style element. In the following example,
we can make the text color of all buttons in our test red:
page.render(<Component />, 'button {color: red}'})
additionaDOM is a string representing additional DOM nodes
to render inside the sandbox. In the following example,
a div with the class "loader" is available in the sandbox
along side Component ;
page.render(, null, '<div class="loader"></div>')
|
destroySandbox() |
Clean up the sandbox DOM element after a test and unmount all
components.
|
get root |
The root element inside of which this PageObject will select.
This is the same element passed as the first argument to the constructor.
For legibility, we suggest you don't modify this in the middle of a test
but instead create new PageObject instances pointing to other
root elements.
|
get allSelectors |
A list of the selectors this PageObject is configured to interact with.
|
get selectors |
A list of selectors that you can modify at runtime. For example,
you could use this to add or change the selectors your PageObject
interacts with...
page.selectors.foo = '[data-test=foo]';
page.foo.click();
|
select(selector) |
A generic method for querying the DOM inside of page.root .
page.select('[data-test=foo]');
// equivalent to:
page.root.querySelector('[data-test=foo]');
|
selectAll(selector) |
A generic method for querying the DOM inside of page.root .
page.selectAll('[data-test=foo]');
// equivalent to:
page.root.querySelectorAll('[data-test=foo]');
|
submit() |
Find the first form on the page and simulate a submit event for it.
|
findByTestName(testName) |
Find an element by its `data-test` attribute.
page.findByTestName('foo');
// equivalent to
page.root.querySelector('[data-test=foo]'); |
PageSelector API
After defining the selectors available on a PageObject, you can interact
with those selectors by referencing them as properties of your PageObject.
For example:
<div id="sandbox">
<div data-test="myComponent">
Hello World
</div>
</div>
let page = new PageObject(null, {
myCommponent: '[data-test=myComponent]',
});
page.myComponent.click();
In the example above, the property myComponent
on page
is an instance of
a PageSelector
whose API is as follows.
Name | Description |
get element |
Get the HTMLElement associated with this selector.
|
get allElements |
Get a list of HTMLElements in the DOM that match the selector for this proxy.
|
elementAt(index) |
Get an element at a specific index (assuming the current
selector matches more than one element). If there is only
one element matching the selector and you ask for index 0,
you will get that single element. If the selector returns
no elements or it returns a single element and you asked for
element 2+, this function will return null and will log an error.
|
nth(index) |
Get a PageSelector configured to select against the
nth element matching the selector.
Example:
const secondInput = page.input.nth(1);
page.input.nth(1).value = 'foo';
expect( page.input.nth(2).exists ).toBe(true);
|
get count |
Count the number of elements in the DOM that match
this proxy.
|
get exists |
Determine if this proxy exists in the DOM.
|
get text |
Get the trimmed text content of this selector.
|
get classList |
Get the classList object for the element matching
this selector.
|
hasClass(className) |
Check if the element matching the current selector
has a specific class name.
Equivalent to: page.thing.classList.contains.className ;
|
get disabled |
Check if the element has a 'disabled' attribute.
|
get checked |
Get the checked value of a checkbox or radio input.
|
set checked |
Set the checked value of a checkbox or radio input.
Ex: page.thing.checked = true;
|
get value |
Get the text value or input value of this selector.
If the target element is an INPUT, then this returns
the value property of that INPUT. Otherwise, it
returns the trimmed text content.
|
set value |
Set the value of this selector if it points to
an INPUT element. If the selector points to something
other than an INPUT, then this setter has no effect.
|
get values |
Get the value/textContent of all direct children as an array of Strings.
|
get focused |
Determine if the current element has focus in the document.
|
focus() |
Emit a Focus event from the element matching this selector.
|
blur() |
Emit a Blur event from the element matching this selector.
|
simulateAction(
actionName,
element,
eventObject
) |
Simulate an action on a specific element. You can simulate any action provided by
@testing-library fireEvent .
Ex: page.simulateAction('click', page.thing.element, new CustomEvent())
|
await(options) |
Wait for the element matching the current selector to become visible in the DOM.
This returns a promise that resolves once the element is rendered or throws if it doesn't appear
in the duration specified in options. Under the hood this uses @testing-library waitFoElement .
For more details, see (https://testing-library.com/docs/dom-testing-library/api-async#waitforelement)
Ex: page.thing.await().then(() => done())
|
awaitRemoval(options) |
Similar to await but waits for an element to be removed. Under the hood this uses @testing-library
waitForElementToBeRemoved .
For more details, see (https://testing-library.com/docs/dom-testing-library/api-async#waitforelementtoberemoved)
Ex: page.thing.awaitRemoval().then(() => done())
|
click() |
Click on the first element that matches this selector.
|
submit() |
Simulate a submit event on the element matching the current selector.
Ex: page.myForm.submit()
Since there is usually only one form on a page that can be submitted,
PageObject also exposes a submit method directly that will find the first
form on a page and submit that.
Ex: page.submit()
|
pressEnter() |
Press the enter key on the specified element.
Ex: page.someInput.pressEnter()
|
What's happening under the hood?
More docs coming...
Other useful strategies
barrel page-objects
files
test selectors vs. element text
make your tests resiliant.