-
Notifications
You must be signed in to change notification settings - Fork 364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
test: Jcom/infeng 454/sign in tests #9013
Merged
Merged
Changes from 42 commits
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
b2219e4
local config
djanicekpach b20be45
update readme with running instructions
djanicekpach 2848d8f
linter fixes
djanicekpach 51f34c5
move tests to folder
JComins000 9fb8596
page models are present, now i need to use them
JComins000 37d7362
here are the models
JComins000 f4870de
this is good
JComins000 3b58951
this is sick
JComins000 e66fe83
done for today
JComins000 c5793cf
done for real
JComins000 413d445
i finally got that friggin type right
JComins000 01d560e
formatting and non relative imports
JComins000 0966f96
Merge branch 'main' into jcom/INFENG-454/sign-in-tests
JComins000 6681aa9
documentation
JComins000 d093948
member names and attributes
JComins000 ad55b1f
doh
JComins000 cdac4ff
doh
JComins000 bebbe22
todo in place
JComins000 ddce075
subcomponents
JComins000 d53fb87
loc.fill works
JComins000 23cd71c
no need for haslocator, getslocator
JComins000 8a7f1b9
it compiles but i dont like pwLocator
JComins000 f0e6b67
locator stuff feels much better now
JComins000 5a497f4
mandator default selectors
JComins000 d1fa416
this doesn't work either
JComins000 5c1ea1a
locator stuff working i think
JComins000 c71db11
ayoo got it all working
JComins000 081ab90
more auth cahnges before i pull main back in
JComins000 5d4dac2
Merge branch 'main' into jcom/INFENG-454/sign-in-tests
JComins000 23e5bd1
just needs a text assertion
JComins000 67dd40b
missed one file
JComins000 72ee1e1
ready for review and merge
JComins000 3a5254d
let's merge this thing
JComins000 801e95d
readme
JComins000 1cd02e4
remove one test attribute
JComins000 98fce42
obj.freeze is fine && locators can return multiple elements
JComins000 aacfe9e
plural
JComins000 d02bb12
data-test -> data-test-component
JComins000 66ae0c7
no freeze
JComins000 e49ca51
use override instead of class creation
djanicekpach 0d6a22f
remove unused var
djanicekpach b44c36c
login fixture redirect condition
JComins000 f5d4fbc
linting
JComins000 dbbf6f1
linting
JComins000 bf532da
expect import
JComins000 67f3a4a
linting lol
JComins000 cf71f65
gotta run prettier too :rolling eyes emoji
JComins000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import { type Locator } from '@playwright/test'; | ||
import { BasePage } from './BasePage'; | ||
|
||
// BasePage is the root of any tree, use `instanceof BasePage` when climbing. | ||
type parentTypes = BasePage | BaseComponent | BaseReactFragment | ||
|
||
/** | ||
* Returns the representation of a Component. | ||
* | ||
* @remarks | ||
* This constructor is a base class for any component in src/components/. | ||
* | ||
* @param {Object} obj | ||
* @param {parentTypes} obj.parent - The parent used to locate this BaseComponent | ||
* @param {string} obj.selector - Used as a selector uesd to locate this object | ||
*/ | ||
export class BaseComponent { | ||
protected _selector: string; | ||
readonly _parent: parentTypes; | ||
protected _locator: Locator | undefined; | ||
|
||
constructor({ parent, selector }: { parent: parentTypes, selector: string }) { | ||
this._selector = selector; | ||
this._parent = parent; | ||
} | ||
|
||
/** | ||
* The playwright Locator that represents this model | ||
*/ | ||
get pwLocator(): Locator { | ||
if (typeof this._locator === 'undefined') { | ||
// Treat the locator as a readonly, but only after we've created it | ||
this._locator = this._parent.pwLocator.locator(this._selector); | ||
} | ||
return this._locator; | ||
} | ||
|
||
get root(): BasePage { | ||
let root: parentTypes = this | ||
while (true) { | ||
if (root instanceof BasePage) return root | ||
else root = root._parent | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Returns the representation of a React Fragment. | ||
* | ||
* @remarks | ||
* React Fragment Components are special in that they group elements, but not under a dir. | ||
* Fragments cannot have selectors | ||
* | ||
* @param {Object} obj | ||
* @param {parentTypes} obj.parent - The parent used to locate this BaseComponent | ||
*/ | ||
export class BaseReactFragment { | ||
readonly _parent: parentTypes | ||
|
||
constructor({ parent }: { parent: parentTypes }) { | ||
this._parent = parent | ||
} | ||
/** | ||
* The playwright Locator that represents this model | ||
* | ||
* @remarks | ||
* Since this model is a fragment, we simply get the parent's locator | ||
*/ | ||
get pwLocator(): Locator { return this._parent.pwLocator } | ||
} | ||
|
||
export type NamedComponentArgs = { | ||
parent: parentTypes, | ||
selector?: string | ||
} | ||
|
||
/** | ||
* The actual implemntation of a NamedComponent class | ||
* | ||
* @remarks | ||
* Remarks regarding implementation are found in the NamedComponent function | ||
*/ | ||
export abstract class NamedComponent extends BaseComponent { | ||
static defaultSelector: string; | ||
constructor({ parent, selector }: { parent: parentTypes, selector: string }) { | ||
super({parent, selector}) | ||
if ((this.constructor as any).defaultSelector == undefined){ | ||
throw new Error('A named component has been defined without a default selector!') | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { Locator, type Page } from '@playwright/test'; | ||
|
||
/** | ||
* Returns the representation of a Page. | ||
* | ||
* @remarks | ||
* This constructor is a base class for any component in src/pages/. | ||
* | ||
* @param {Page} page - The '@playwright/test' Page being used by a test | ||
*/ | ||
export abstract class BasePage { | ||
|
||
readonly _page: Page; | ||
readonly abstract url: string; | ||
|
||
constructor(page: Page) { | ||
this._page = page; | ||
} | ||
|
||
/** | ||
* The playwright top-level locator | ||
*/ | ||
get pwLocator(): Locator { return this._page.locator(':root') } | ||
|
||
/** | ||
* Returns this so we can chain. | ||
* | ||
* @remarks | ||
* ie. await expect(thePage.goto().theElement.loc()).toBeVisible() | ||
* | ||
* @param {Page} [waitForURL] - Whether for the URL to change | ||
*/ | ||
goto(waitForURL: boolean = true): BasePage { | ||
this._page.goto(this.url); | ||
if (waitForURL) { | ||
this._page.waitForURL(this.url); | ||
} | ||
return this | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
# Page Model Guide | ||
|
||
Models represented in this folder are meant to be a one-to-one representations of objects from `src`. | ||
|
||
* `src/pages/SignIn.tsx` -> `src/e2e/models/pages/SignIn.ts` | ||
* `src/components/DeterminedAuth.tsx` -> `src/e2e/models/components/DeterminedAuth.ts` | ||
* `src/utils/error.ts` -> `src/e2e/models/utils/error.ts` | ||
|
||
## Why page models | ||
|
||
Playwright has [a writeup](https://playwright.dev/docs/pom) on how page models are useful, namely that they can be used as an API for interacting with pages. While this is true, there is much more to be gained from using page models. | ||
|
||
### What playwright POM is missing | ||
|
||
The playwright API approach still loosely defines each component. Relying on text can be volitile, so it would be better to use accessibility or `data-testid`. Playwright POM also serves as an API rather than a **model**. | ||
|
||
Our page models are meant to implement the precise structure of a page. This allows us to be specific in which components we represent. For exmaple, a login page might have different sign in boxes for straightforward authetication and for SSO. Or maybe a dashboard page has several tables. When we test for "a username input" or "the first row of the table", these models will provide a uniform, specific mechanism to clearly state which component we're using. | ||
|
||
In addition, playwright POM muddies their implementation with test steps executing inside the model. We want our models to represent a page that a test can perform actions on instead of acting as an interface to perform actions on that page. Here's an example. | ||
|
||
| Unclear Test Case | `jspage.createNewProject()` | | ||
| ----------------- | ------------------------------------------------------ | | ||
| Precise Test Case | `await page.header.createNewProject.pwLocator.click()` | | ||
|
||
The difference is suble, but in the precise exmaple, a reader will understand exactly what actions the test case is performing from a glance. This can be helpful when test cases fail and the person triaging the case isn't the same person who wrote the test. Utilities and abstarctions are still welcome, but this model pattern will elimate the need for as many! | ||
|
||
## How to use page models | ||
|
||
Simply instantiate a new page inside a test case or fixture. The page will come with properties representing the page. The `pwLocator` property gets the playwright locator which the model is representing. | ||
|
||
| Page model Autocomplete | Locator automcomplete | | ||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------- | | ||
|  |  | | ||
|
||
And here's a complete example with an `expect`: | ||
|
||
```js | ||
await detAuth.username.pwLocator.fill(username) | ||
await detAuth.password.pwLocator.fill(password); | ||
expect(await signInPage.detAuth.error.message.pwLocator.textContent()).toContain('Login failed'); | ||
``` | ||
|
||
## How to contribute | ||
|
||
First, if the model doesn't exist, create it. The file name and path in `src/e2e/models` with match the file name and path from it's counterpart in `src`. | ||
|
||
* Pages will inherit from `BasePage` and serve as the root of a component tree. Each page will need a `url`, and the `BasePage` class provides common properties for all pages like `goto`. | ||
* Named components and utilities will inherit form `NamedComponent`. This class is similar to `BaseComponent` but it enforces that a static `defaultSelector` be declared. Use this selector in the component's constructor as an alternative if `selector` isn't passed through. | ||
* If the component being represented returns a `React.Fragment`, inherit from the `BaseReactFragment` class | ||
|
||
All pages and components support subcomponents using instance properties. Initalize new components using instances of BaseComponent. They should support selector and parent arguments. | ||
|
||
| Argument | Description | | ||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| `selector` | The selector passed through to playwright's `locator` method. Required if initalizing a `BaseComponent`, but optional for `NamedComponent`s. | | ||
| `parent` | The component's parent. It could be `this` or any instance of `BaseComponent \| BasePage`. In some cases, it will be set to `this.root` if the component lives at the root of the DOM. `BasePage`s are always at the root of the tree. | | ||
|
||
Here's a simplified example using part of the `DeterminedAuth` component. Intrinsic elements are be represent with `BaseComponent`, and they can be as `parent`s for other elements. The amount of specificity is left to the author's discretion. If you're not sure, more is better to avoid conflicts with future additions. Deeper specificity will also optimize for searching through the DOM on larger pages. | ||
|
||
```js | ||
export class DeterminedAuth extends NamedComponent({ defaultSelector: "div[data-test-component='detAuth']"}) { | ||
constructor({ selector, parent }: NamedComponentArgs) { | ||
super({ parent: parent, selector: selector || DeterminedAuth.defaultSelector }); | ||
} | ||
readonly form: BaseComponent = new BaseComponent({ parent: this, selector: "form" }); | ||
readonly username: BaseComponent = new BaseComponent({ parent: this.form, selector: "input[data-testid='username']" }); | ||
readonly password: BaseComponent = new BaseComponent({ parent: this.form, selector: "input[data-testid='password']" }); | ||
readonly submit: BaseComponent = new BaseComponent({ parent: this.form, selector: "button[data-testid='submit']" }); | ||
... | ||
} | ||
``` | ||
|
||
### Default Selectors | ||
|
||
`NamedComponent`s will have a `static defaultSelector` to use if a selector isn't provided. Since this property is static, we can use it to create elements with more details. Here's an example using an imaginary `DeterminedTable` component: | ||
|
||
```js | ||
readonly userTable: DeterminedTable = new DeterminedTable({ parent: this, selector: DeterminedTable.defaultSelector + "[data-testid='userTable']" }); | ||
readonly roleTable: DeterminedTable = new DeterminedTable({ parent: this, selector: DeterminedTable.defaultSelector + "[data-testid='roleTable']" }); | ||
``` | ||
|
||
## Practices around test hooks | ||
|
||
When creating page models, you'll most likely want to author test hooks into the `src` model. | ||
|
||
| Test Hook | Usage | | ||
| ----------------- | ------------------------------------------------------ | | ||
| `data-test-component='my-component'` | Belongs at the top level element wrapping the component | | ||
| `data-testid='my-componentid'` | Attributed to any *instances* of components or any intrinsic element | | ||
|
||
Looking back to the exmaple with the imaginary `DeterminedTable`, we want to enable this pattern: | ||
|
||
```js | ||
// DeterminedTable.defaultSelector = "[data-test-component='DetTable']" | ||
readonly userTable: DeterminedTable = new DeterminedTable({ parent: this, selector: DeterminedTable.defaultSelector + "[data-testid='userTable']" }); | ||
readonly roleTable: DeterminedTable = new DeterminedTable({ parent: this, selector: DeterminedTable.defaultSelector + "[data-testid='roleTable']" }); | ||
``` | ||
|
||
The component `DeterminedTable` would have `data-test-component='DetTable'` as a top level attribute, and instances would each get their own `data-testid`. This way, the static attribute and the instance attribute don't conflict with each other. | ||
|
||
Not every component needs a data-testid, but, in general, more is better. It's better to select for *"a duck named Hoffman"* rather than "a duck" or "Hoffman". |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { BaseComponent, NamedComponent, NamedComponentArgs } from 'e2e/models/BaseComponent'; | ||
import { ErrorComponent } from 'e2e/models/utils/error'; | ||
|
||
/** | ||
* Returns a representation of the DeterminedAuth component. | ||
* | ||
* @remarks | ||
* This constructor represents the contents in src/components/DeterminedAuth.tsx. | ||
* | ||
* @param {Object} obj | ||
* @param {implementsGetLocator} obj.parent - The parent used to locate this DeterminedAuth | ||
* @param {string} [obj.selector] - Used instead of `defaultSelector` | ||
*/ | ||
|
||
export class DeterminedAuth extends NamedComponent { | ||
static override defaultSelector="div[data-test-component='detAuth']"; | ||
constructor({ selector, parent }: NamedComponentArgs) { | ||
super({ parent: parent, selector: selector || DeterminedAuth.defaultSelector }); | ||
} | ||
readonly form: BaseComponent = new BaseComponent({ parent: this, selector: "form" }); | ||
readonly username: BaseComponent = new BaseComponent({ parent: this.form, selector: "input[data-testid='username']" }); | ||
readonly password: BaseComponent = new BaseComponent({ parent: this.form, selector: "input[data-testid='password']" }); | ||
readonly submit: BaseComponent = new BaseComponent({ parent: this.form, selector: "button[data-testid='submit']" }); | ||
readonly docs: BaseComponent = new BaseComponent({ parent: this, selector: "link[data-testid='docs']" }); | ||
// TODO consdier a BaseComponents plural class | ||
readonly errors: ErrorComponent = new ErrorComponent({ parent: this.root, selector: ErrorComponent.defaultSelector+ErrorComponent.selectorTopRight }); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { BaseReactFragment } from 'e2e/models/BaseComponent'; | ||
|
||
/** | ||
* Returns a representation of the Page component. | ||
* | ||
* @remarks | ||
* This constructor represents the contents in src/components/Page.tsx. | ||
* | ||
* @param {Object} obj | ||
* @param {implementsGetLocator} obj.parent - The parent used to locate this Page | ||
* @param {string} [obj.selector] - Used instead of `defaultSelector` | ||
*/ | ||
export class PageComponent extends BaseReactFragment { | ||
// TODO define things like Spinners that are unique to Pagecomponent | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice! I was thinking about doing this and forgot.