React E2E Test Tutorial with Selenium: Page Object Model Pattern
Build 'Scalable' E2E testing environment using POM pattern
Building E2E tests on React project is - more or less - necessary. E2E tests can be the final assurance to check if the app is really acceptable to the users since it emulates the behaviors on the actual browsers, which Unit/Integration tests do not offer.
In this post I will introduce how to build E2E tests on React project using Selenium
, jest
and Typescript
. I'll also show you how to build "scalable" test environment utilizing Page Object Model (POM) Pattern, which is often required on E2E tests. Let's go.
What is End-to-End Test?
End-to-End test is a way of testing, which focuses on the users' interactions on the actual browser.
It doesn't test the "implementation" of the app, rather checks if the "specification" of the app is working as expected. And it's achieved by using libraries that emulates interactions on browsers, such like Selenium
or Cypress
.
Shortly put, E2E tests can be the acceptance test for the app's real users.
Why You Need E2E test for Your React Project
Basically you can test your React app by writing Unit/Integration tests on it, but sometimes they can't cover the specific scenarios, which scopes are wider and have combined range of things to test. That is, when:
- You have to test the app's behavior including frontend, APIs, Data, Authentications, pages paths(e.g. Tutorials for the first login of user).
- You have to test the app through the sequential interactions, so it can have the situation to fire some features of it (e.g. Notifications on other users' actions)
- Your project is already full of legacy codes and it's hard to separate testable "units".
Then it's reasonable to run the whole app and test on it. Well, you can still test those complex UX and scenarios manually, you open the app on browser and do the test actions on it, check if something shows up... this is gonna be pain if your app has 100 scenarios and you can hardly do it every deployment.
Theoretically E2E tests can handle whatever you want by mimicking the browser interactions, so you don't have to test it manually anymore.
The drawbacks of E2E
Seems like a solution, but remember, building E2E tests can be your headache or doesn't have much ROI as you expect. Why? Because it is relatively hard to write a working e2e test than unit/integration test.
E2E test requires you to run the whole app, in the actual browsers. That means, it takes more time to run tests and can result to flaky tests due to browser's rendering latency.
For example, if the test includes communication to API, you need to make test runner to wait for the result. But it requires more control over the test scripts. This is why it's harder than writing unit tests or integration tests, which are basically synchronous executions of scripts.
So it is recommended to (and often result to) write less E2E tests than unit/integration. You need to consider how much you cover by writing E2E tests.
Write E2E Tests on the Example Project
Now, let's try writing E2E tests on React project. Clone the example repository here and install libraries.
git clone git@github.com:yozibak/react-testing-example.git
git switch e2e-tests-starter
yarn install
Installed libraries include:
selenium-webdriver
&chromedriver
- for browser interactionts-jest
&babel-jest
- for running tests
Now run yarn start
and see example Todo app. We're going to test against this app.
You can always check the final result by switching branch(git switch e2e-tests
).
Write Out Test Scnearios
Before writing the test scripts, we need to write out test scenarios for the app. So that we won't miss something that users will face in the actual use of the app.
Think from the Scenario: BDD-style Test Cases
There's a kind of logical framework to come up with the scenarios. BDD-style.
BDD(Behavior Driven Development) is the method to match the specifications (in business context) to the testable cases. And the set of test cases are often called as "scenarios" in BDD. That's like:
- Given the user is not logged in,
- When the user clicks button,
- Then it should show login form
In the abstract,
- Given Context,
- When Action,
- Then Assert
In this form, you can turn the specifications into testable cases. I typically write scenarios in spreadsheet like this:
Now we know what to test, move on to writing actual tests.
Write Pure Selenium Test Script with jest
First, I'm going to demonstrate how to use selenium
in a "pure" form.
Look at e2e/tests/login.v1.test.ts
. We want to test the scenario around log-in.
By using selenium
, we're going to test it like:
- Prepare "webdriver" object to set up browser
- Access to the desired URL(Page) through the driver
- Find and interact with the element on the page
- Assert it with the expected result
- Terminate the driver
The process is often described as:
- Setup
- Arrange
- Action
- Assert
- Teardown
Now examine this pattern through the first test case...
// e2e/tests/login.v1.test.ts
import { ThenableWebDriver, Builder, Capabilities, By, until } from 'selenium-webdriver'
import chrome from 'selenium-webdriver/chrome'
// Configs
const driverConfig = {
headless: false,
screenSize: {
width: 1200,
height: 800,
},
}
const ROOT_URL = 'http://localhost:3000'
describe('User logs into the todo app and see his/her own todos', () => {
// driver object
let webDriver: ThenableWebDriver
beforeAll(() => { // 1: setup
let options = new chrome.Options()
if (driverConfig.headless) {
options = options.headless()
}
webDriver = new Builder()
.withCapabilities(Capabilities.chrome())
.setChromeOptions(options.windowSize(driverConfig.screenSize))
.build()
})
afterAll(async () => { // 5: teardown
await webDriver.quit()
})
describe('Access to the URL of this app', () => {
beforeAll(async () => { // 2: arrange
await webDriver.get(ROOT_URL)
})
it('should show the index page', async () => {
// 3, 4: action & assert
const appTitle = webDriver.findElement(By.xpath('//div[text()="Simple Todo App"]'))
expect(await appTitle.isDisplayed()).toBe(true)
})
it("should show 'Log In' on the header", async () => {
// 3, 4: action & assert
const loginButton = webDriver.findElement(By.xpath('//div[@class="header"]//button'))
expect(await loginButton.getText()).toBe('Log In')
})
})
// ...continues
}
Try running yarn e2e:all
. The browser should show up and do the automated browser interaction. Cool. But there're several things to dig in.
1. Setup browser driver
Look at the jest
's beforeAll
hook. First we need to prepare the browser driver object: selenium
's ThenableWebDriver
. This class enables you to interact with the elements on the browser. So we use this again and again later on the test script.
We provide some options when instantiate it, for example the screen size. By enabling "headless" on the options, you can see the browser showing up on the screen.
docs here selenium.dev/documentation/webdriver/gettin..
2. Arrange the situation
Before testing each cases, we need to prepare the situation for that case. In the first case the browser must access to the index url, localhost:3000. We can do this by calling ThenableWebDriver.get()
.
3. 4. Action & Assert
Now we are on the index page, and we need to find the element on the browser (in this case the app's title that says "Simple Todo App".
selenium
provides some strategies to find the element on the browser, and typically we're going to find them through css selector or "xpath".
const divByCss = webDriver.findElement(By.css("div.some-class"))
const divByXpath = webDriver.findElement(By.xpath("//div[@class='some-class']"))
As you can see, finding element by css is more intuitive and familiar. But xpath is more capable of finding complex queries and I think you'll end up using xpath for the 70-80% of the tests. That means, just get used to it.
A bit about xpath for the beginners:
//
means "wherever it is". Searches for the entire dom (from the current position)[]
adds condition for the current element.
You can see more usages of xpath at aplaces like w3 w3schools.com/xml/xpath_syntax.asp
Once you find the element, you need to check if that is showing properly. selenium
's ThenableWebDriver.findElement
returns WebElementPromise
. You can "reserve" actions on this, and it will execute reserved actions on that element.
const appTitle = webDriver.findElement(By.xpath('//div[text()="Simple Todo App"]'))
expect(await appTitle.isDisplayed()).toBe(true)
And finally you assert the result by jest
's expect
and toBe
.
5. Teardown
Look at jest
's afterAll
hook in the script. You have to quit the browser once all the test cases done.
Write more e2e tests
Let's fill the rest of the cases:
describe('User logs into the todo app and see his/her own todos', () => {
// ...continues
describe("Click 'Log In' button ", () => {
beforeAll(async () => {
const loginButton = webDriver.findElement(By.xpath('//div[@class="header"]//button'))
await loginButton.click()
})
it("should show Login form", async () => {
const usernameInput = webDriver.findElement(By.css('input[id="username-input"]'))
const passwordInput = webDriver.findElement(By.css('input[id="password-input"]'))
expect(await usernameInput.isDisplayed()).toBe(true)
expect(await passwordInput.isDisplayed()).toBe(true)
})
})
describe("Enter wrong username & password into form", () => {
beforeAll(async () => {
const usernameInput = webDriver.findElement(By.css('input[id="username-input"]'))
const passwordInput = webDriver.findElement(By.css('input[id="password-input"]'))
const submitButton = webDriver.findElement(By.xpath("//button[text()='Submit']"))
await usernameInput.sendKeys('wrong-username')
await passwordInput.sendKeys('wrong-pass')
await submitButton.click()
})
it("should show dialog that says 'Incorrect username or password'", async () => {
await webDriver.wait(until.alertIsPresent())
const alert = await webDriver.switchTo().alert()
expect(await alert.getText()).toBe("Incorrect username or password.")
alert.accept()
})
})
describe("Enter correct username & password into form", () => {
beforeAll(async () => {
const usernameInput = webDriver.findElement(By.css('input[id="username-input"]'))
const passwordInput = webDriver.findElement(By.css('input[id="password-input"]'))
const submitButton = webDriver.findElement(By.xpath("//button[text()='Submit']"))
await usernameInput.clear()
await usernameInput.sendKeys('Katsumi')
await passwordInput.clear()
await passwordInput.sendKeys('MyCoolPass')
await submitButton.click()
})
it("should show todo dashboard", async () => {
const pageTitle = webDriver.findElement(By.css("div[class='page-title']"))
expect(await pageTitle.getText()).toBe("Dashboard")
})
it("should show user's todo list", async () => {
const myTodoTitle = 'Watch Seven Samurai'
const myTodo = webDriver.findElement(By.xpath(`//div[contains(@class, 'todo') and .//*[text()='${myTodoTitle}']]`))
expect(await myTodo.isDisplayed()).toBe(true)
})
})
describe("Hit 'Log out' button on the header", () => {
beforeAll(async () => {
const logoutButton = webDriver.findElement(By.xpath("//div[@class='header']//button[text()='Log Out']"))
await logoutButton.click()
})
it("should show the index page", async () => {
const appTitle = webDriver.findElement(By.xpath('//div[text()="Simple Todo App"]'))
expect(await appTitle.isDisplayed()).toBe(true)
})
})
})
Several points to note:
- You have to
await
for each actions on theWebElementPromise
. - Typical actions on element are
sendKeys
,click
andgetText
. - You can combine the xpath conditions like
//div[contains(@class, 'todo') and .//*[text()='${myTodoTitle}']]
, which means a div that has .todo class and its children contains text that matches $myTodoTitle.
Run tests by yarn e2e:only login.v1.test.ts
, and it should pass all the cases. So far, so good. But a lot of work. So exhausting and tedious, isn't it? The script is somewhat redundant and you feel like you repeat yourself, not DRY.
Make Test Scripts Reusable Across Cases: POM Pattern
Now let's make our tests go one step farther. The problem is we have to write each process every time test case requires it. Can't we make these process "reusable" across test cases?
Imagine that we have 30 pages on the app and gotta test against them. It becomes a matter when we are about to scale our test!
The solution is, to apply a design pattern for e2e tests: Page Object Model.
POM pattern thinks like this:
- Each page is a class with methods and properties
- Each page has its actions as its methods
- Each page has its elements as its properties
So we can reuse each page's actions or elements in the test script, therefore we don't have to write the xpath queries or action process every time we need them.
All we have to do is to build a concrete class for each page like this:
export class LoginPage extends Page {
constructor(browser: Browser) {
super(browser)
this.setUrl('/login')
}
@findBy("css", 'input[id="username-input"]')
public usernameInput: TextInput
@findBy("css", 'input[id="password-input"]')
public passwordInput: TextInput
@findBy("xpath", "//button[text()='Submit']")
public submitButton: WebComponent
public async submitAuthInfo(username: string, password: string) {
await this.usernameInput.clear()
await this.usernameInput.type(username)
await this.passwordInput.clear()
await this.passwordInput.type(password)
await this.submitButton.click()
}
}
What's beautiful about this pattern is, that you can benefit from OOP practices. You can extend the each pages functionalities and make a little bit changes to it, override the methods, or use some kind of "mixins" as you want, it is up to you.
The drawbacks of this pattern is, if any, that you have to take some time to prepare the environment around this pattern (which I'm going to explain), and some might find it complicated when he/she is not familiar with OOP.
But I would highly recommend this pattern if you have several scenarios that go and back across pages and have to revoke same logics in multiple scenarios.
In short, POM helps you to scale your e2e tests.
Setup POM environment
Prepare files like below:
./e2e
├── lib # local libraries for pom pattern
│ ├── browser.ts
│ ├── components.ts
│ ├── config.ts
│ ├── page.ts
│ └── utils.ts
├── pages # page classes
│ ├── DashboardPage.ts
│ ├── IndexPage.ts
│ ├── LoginPage.ts
│ └── index.ts
└── tests # test scripts
├── login.v1.test.ts
└── login.v2.test.ts
We first need Browser
class to provide some browser interactions to our page classes. This class includes same processes that we saw before, written directly on the login.v1.test.ts
.
// e2e/lib/config.ts
export const ROOT_URL = 'http://localhost:3000'
export const DriverConfig = {
headless: false,
screenSize: {
width: 1200,
height: 800,
},
elementWaitSecs: 1,
}
export const TestUser = {
username: 'Katsumi',
password: 'MyCoolPass'
}
// e2e/lib/browser.ts
import 'chromedriver'
import { Builder, ThenableWebDriver, By, WebElementPromise, until, Capabilities } from 'selenium-webdriver'
import chrome from 'selenium-webdriver/chrome'
import { DriverConfig } from './config';
export class Browser {
private driver: ThenableWebDriver
public constructor(
headless: boolean = DriverConfig.headless,
screenSize: { width: number; height: number } = DriverConfig.screenSize,
private elementWaitSecs: number = DriverConfig.elementWaitSecs
) {
let options = new chrome.Options()
if (headless) {
options = options.headless()
}
this.driver = new Builder()
.withCapabilities(Capabilities.chrome())
.setChromeOptions(options.windowSize(screenSize))
.build()
}
public async navigate(url: string): Promise<void> {
try {
await this.driver.get(url)
} catch (e) {
console.error(e)
throw new Error('URL does not respond')
}
}
public findElementBy(method: 'css' | 'xpath', selector: string): WebElementPromise {
const find = method === 'css' ? By.css : By.xpath
return this.driver.wait(
until.elementLocated(find(selector)),
this.elementWaitSecs * 1000,
`Time out after ${this.elementWaitSecs} secs. (thrown by finding ${selector})`,
500 // check if element is available each 0.5 second
)
}
public async acceptConfirm() {
await this.driver.wait(until.alertIsPresent())
const alert = await this.driver.switchTo().alert()
const alertText = await alert.getText()
await alert.accept()
return alertText
}
public async close(): Promise<void> {
await this.driver.quit()
}
}
Notice that findElementBy
method retries to find the desired element, and this is because concerning about the browser's rendering latency, it sometimes doesn't render immediately so we should wait for it, or it will complain that the element wasn't found.
OK, let's move on. Remember we used selenium
's WebElementPromise
to reserve actions on that element. We are going to make a wrapper for this and each page class is going to have this WebComponent
object as properties.
// e2e/lib/components.ts
import { WebElementPromise } from 'selenium-webdriver'
export class WebComponent {
constructor(protected element: WebElementPromise) {}
public async click() {
await this.element.click()
}
public async isDisplayed() {
try {
return await this.element.isDisplayed()
} catch (ex) {
return false
}
}
public async getText() {
return await this.element.getText()
}
public async getAttr(attr: string) {
return await this.element.getAttribute(attr)
}
}
export class TextInput extends WebComponent {
constructor(element: WebElementPromise) {
super(element)
}
public type(text: string | number) {
return this.element.sendKeys(text)
}
public clear() {
return this.element.clear()
}
}
export class Button extends WebComponent {
constructor(element: WebElementPromise) {
super(element)
}
public async isDisabled() {
try {
return (await this.element.getAttribute('disabled')) === 'true'
} catch (ex) {
return false
}
}
}
It seems like repeating WebElementPromise's methods and you could go without this. But the reason why we need this is to work around some issues around WebElementPromise
.
For example, look at isDisplayed
method. isDisplayed
throws error when it can't find the element. But what if we intended to make sure the element is not present? That's why we we wrapped it in our WebComponent.isDisplayed
and we made it return false if it doesn't find element.
Like this, you can customize or extend the original WebElementPromise
by having custom WebComponent
class. You can put some workaround on each WebComponent classes, as you want.
Now, we can have Page
class.
// e2e/lib/page.ts
import { Browser } from "./browser"
import { ROOT_URL } from "./config"
import 'reflect-metadata'
export abstract class Page {
private url: string = ROOT_URL
protected setUrl(path: string) {
this.url = ROOT_URL + path
}
public async navigate(): Promise<void> {
await this.browser.navigate(this.url)
}
public constructor(protected browser: Browser) {}
}
/**
* decorator for page object's element properties.
* getting page.element calls the get function set by this decorator.
*
* For decorators, see: https://www.typescriptlang.org/docs/handbook/decorators.html
*/
export function findBy(method: 'css' | 'xpath', selector: string) {
return (target: any, propertyKey: string) => {
const WebComp = Reflect.getMetadata('design:type', target, propertyKey)
Object.defineProperty(target, propertyKey, {
configurable: true,
enumerable: true,
get: function () {
const webElementPromise = (this as any).browser.findElementBy(method, selector)
return new WebComp(webElementPromise)
},
})
}
}
The Page
base class is for each page to extend from (so Page
is "abstract"). It requires Browser
in the constructor, so that each page can have abilities to interact with the browser dom. Each page has its own url path and Page.navigate()
will change the browser's path.
Notice the findBy
decorator? We're going to use this decorator when writing concrete page classes.
Write Page Classes
That's all for /lib
. Let's move on to /pages
.
We are going to "classify" each pages users see, and put every actions and elements into each page classes. Like so:
// e2e/pages/IndexPage.ts
import { Browser } from "../lib/browser";
import { WebComponent } from "../lib/components";
import { findBy, Page } from "../lib/page";
export class IndexPage extends Page {
constructor(browser: Browser) {
super(browser)
}
@findBy("xpath", "//div[text()='Simple Todo App']")
public appTitle: WebComponent
@findBy("xpath", "//div[@class='header']//button")
public authButton: WebComponent
}
at Index page (localhost:3000), you'll see title and buttons on the header. We are going to test against these elements, so put them as properties as you can see. @findBy
enables you to access to each element calling indexPage.appTitle
as object's property.
const indexPage = new IndexPage(browser)
const title = indexPage.appTitle
This actually calling get
method defined by @findBy
decorator, which is
get: function () {
const webElementPromise = (this as any).browser.findElementBy(method, selector)
return new WebComp(webElementPromise)
}
Decorator is very useful and make page classes cleaner, making you spot the element property by the @mark above it.
I won't explain about the decorators itself, but you can read it more on TypeScript docs.
Also, remember to turn on decorator feature in tsconfig.json
.
{
"compilerOptions": {
// ...other configs
"strictPropertyInitialization": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
},
}
Let's build another page class for login page at localhost:3000/login.
// e2e/pages/LoginPage.ts
import { Browser } from "../lib/browser";
import { TextInput, WebComponent } from "../lib/components";
import { findBy, Page } from "../lib/page";
export class LoginPage extends Page {
constructor(browser: Browser) {
super(browser)
this.setUrl('/login')
}
@findBy("css", 'input[id="username-input"]')
public usernameInput: TextInput
@findBy("css", 'input[id="password-input"]')
public passwordInput: TextInput
@findBy("xpath", "//button[text()='Submit']")
public submitButton: WebComponent
public async submitAuthInfo(username: string, password: string) {
await this.usernameInput.clear()
await this.usernameInput.type(username)
await this.passwordInput.clear()
await this.passwordInput.type(password)
await this.submitButton.click()
}
}
This time, we need to assign the page's url path other than localhost:3000. Put url setup in the constructor.
Also note that this page has an action method called submitAuthInfo
. It contains the interaction processes to log in. By calling this one method, we won't have to write 5 lines each time we need to log in.
Lastly, we make another page class for dashboard page.
// e2e/pages/DashboardPage.ts
import { WebComponent } from "../lib/components"
import { Browser } from "../lib/browser"
import { findBy, Page } from "../lib/page"
export class DashboardPage extends Page {
constructor(browser: Browser) {
super(browser)
this.setUrl('/dashboard')
}
@findBy("css", "div[class='page-title']")
public pageTitle: WebComponent
@findBy("xpath", "//div[@class='header']//button[text()='Log Out']")
public logoutButton: WebComponent
private locateTodoCardByTitle(todoTitle: string) {
return `//div[contains(@class, 'todo-cards')]/div[contains(@class, 'todo') and .//*[text()='${todoTitle}']]`
}
public findTodoCardByTitle(todoTitle: string) {
const loc = this.locateTodoCardByTitle(todoTitle)
const promise = this.browser.findElementBy("xpath", loc)
return new WebComponent(promise)
}
}
Actually we need more methods and elements to cover whole actions on this page, but just leave it and let's try these "page classes" in our test scripts.
Use Page Class in Test Script
We will make another version of login.v1.test.ts
, utilizing POM pattern.
// e2e/pages/login.v2.test.ts
import { Browser } from '../lib/browser'
import { IndexPage, LoginPage, DashboardPage } from '../pages'
describe('User logs into the todo app and see his/her own todos', () => {
let browser: Browser
let indexPage: IndexPage
let loginPage: LoginPage
let dashboardPage: DashboardPage
beforeAll(() => {
// build browser
browser = new Browser()
// instantiate page objects
indexPage = new IndexPage(browser)
loginPage = new LoginPage(browser)
dashboardPage = new DashboardPage(browser)
})
afterAll(async () => {
await browser.close()
})
describe('Access to the URL of this app', () => {
beforeAll(async () => {
await indexPage.navigate()
})
it('should show the index page', async () => {
expect(await indexPage.appTitle.isDisplayed()).toBe(true)
})
it("should show 'Log In' on the header", async () => {
expect(await indexPage.authButton.getText()).toBe("Log In")
})
})
// ...followed by other cases...
})
The main difference is we instantiate each page classes at beforeAll
hook. Afterwards we access to each pages' elements and actions. So we no longer have to query the element each time we need it. Instead write like indexPage.appTitle
. Cool.
Let's continue writing our scripts.
describe('User logs into the todo app and see his/her own todos', () => {
// ...followed by other cases...
describe("Click 'Log In' button ", () => {
beforeAll(async () => {
await indexPage.authButton.click()
})
it("should show Login form", async () => {
expect(await loginPage.usernameInput.isDisplayed()).toBe(true)
expect(await loginPage.passwordInput.isDisplayed()).toBe(true)
})
})
describe("Enter wrong username & password into form", () => {
beforeAll(async () => {
await loginPage.submitAuthInfo('wrong-username', 'wrong-password')
})
it("should show dialog that says 'Incorrect username or password'", async () => {
const alertText = await browser.acceptConfirm()
expect(alertText).toBe("Incorrect username or password.")
})
})
describe("Enter correct username & password into form", () => {
beforeAll(async () => {
await loginPage.submitAuthInfo('Katsumi', 'MyCoolPass')
})
it("should show todo dashboard", async () => {
expect(await dashboardPage.pageTitle.getText()).toBe("Dashboard")
})
it("should show user's todo list", async () => {
const myTodo = dashboardPage.findTodoCardByTitle('Watch Seven Samurai')
expect(await myTodo.isDisplayed()).toBe(true)
})
})
describe("Hit 'Log out' button on the header", () => {
beforeAll(async () => {
await dashboardPage.logoutButton.click()
})
it("should show the index page", async () => {
expect(await indexPage.appTitle.isDisplayed()).toBe(true)
})
})
})
Look how it changed from our first version. The test cases are now cleaner and more readable.
Also, you will feel how POM pattern benefits us as you write more tests.... Try writing the rest of the test scenarios on tests/createTodo.test.ts
and test/editTodo.test.ts
. You can view the final result by switching branch: git checkout e2e-tests
. Good luck.
Conclusion
E2E test is a powerful testing method and recommended especially when you have to check the app through the complex users' interactions as a consecutive process. It gives you the confidence on whether the app is really acceptable on the real browsers, using libraries like Selenium
.
Though it's relatively harder than writing other testing methods, there's a pattern that accelerates your process building tests: POM. I hope you'd found the way to build a solid testing environment and test scripts.
Again, all the codes I introduced are found on my repository at github.com/yozibak/react-testing-example .
Feel free to leave a comment if you find some issues or trouble through this tutorial. Thanks for reading!