Skip to content

Commit

Permalink
add playwright
Browse files Browse the repository at this point in the history
  • Loading branch information
stephenkilbourn committed Feb 1, 2024
1 parent 76935dd commit 1d34a0f
Show file tree
Hide file tree
Showing 18 changed files with 588 additions and 3 deletions.
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,15 @@ dist

Makefile
.hypothesis

################################################
# Playwright Test Outputs
#
# Files generated by running Playwright tests
################################################

/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/e2e/playwrightTestData.json
35 changes: 35 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Playwright E2E Testing

This testing suite is for End to End testing the VEDA website via the UI using Playwright. It works by serving a local version of the site using yarn serve and performing UI checks against that locally hosted site. The suite is designed to generate a `playwrightTestData.json` that contains the list of all Catalog and Story names. This is done by parsing the `name` field in the `*.mdx` files of the `/datasets` and `/stories` directories.

## Running the test suite

The test suite can be run via the `yarn test:e2e` script. There is a `prtest:e2e` script that will generate a new playwrightTestData.json before beginning the actual playwright test run. This allows for new stories or catalogs to be added without updating the test suite.

## Directory Structure

The end to end tests are organized in the `/e2e` directory. The tests have been written following a [Page Object Model](https://martinfowler.com/bliki/PageObject.html) pattern.
Supporting files within the repo are in the following structure:

```text
/e2e
│─── README.md
│─── playwright.config.ts - imports our global setup, defines preferred browsers, & number of retries
│─── generateTestData.js - parses mdx files and creates a json based on their metadata
└─── /pages
│ └─── basePage.ts - imports all seeded data and PageObjects into our `test` object.
│ │
│ └─── [PAGENAME]Page.ts - The page objects. UI elements interacted with on a page are defined once to keep tests DRY and minimize test changes if layout changes.
└─── tests - our actual tests
```

## Updating Tests

If the layout of a page changes, then the tests may no longer be able to interact with locators. These locators are defined in the Page Objects defined in `/e2e/pages`. The Playwright framework provides multiple ways to choose elements to interact with. The recomended ones are defined in the [Playwright documentation](https://playwright.dev/docs/locators#quick-guide).

Any new pages will need to have new page objects created and then imported into the `basePage.ts` file following th format of existing pages. This allows all tests to reference the page.

## Output

Playwright will generate an html report with test results. This report will show all tests that were run, and will allow a user to view the results of any failed tests.
32 changes: 32 additions & 0 deletions e2e/generateTestData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const fg = require('fast-glob');

const catalogPaths = fg.globSync('**/datasets/*.mdx');
const storyPaths = fg.globSync('**/stories/*.mdx');
const catalogNames = [];
const storyNames = [];

for (const catalog of catalogPaths) {
const catalogData = matter.read(catalog).data;
catalogNames.push(catalogData['name']);
}

for (const story of storyPaths) {
const storyData = matter.read(story).data;
storyNames.push(storyData['name'])
}

const testDataJson = {
"catalogs": catalogNames,
"stories": storyNames
}

fs.writeFile(path.join(__dirname, 'playwrightTestData.json'), JSON.stringify(testDataJson), err => {
if (err) {
console.error(err);
} else {
console.info('new test data file generated')
}
});
11 changes: 11 additions & 0 deletions e2e/pages/aboutPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Locator, Page } from '@playwright/test';

export default class AboutPage {
readonly page: Page;
readonly mainContent: Locator;

constructor(page: Page) {
this.page = page;
this.mainContent = this.page.getByRole('main');
}
}
42 changes: 42 additions & 0 deletions e2e/pages/basePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { test as base } from '@playwright/test';
import AboutPage from './aboutPage';
import HomePage from './homePage';
import FooterComponent from './footerComponent';
import HeaderComponent from './headerComponent';
import CatalogPage from './catalogPage';
import DatasetPage from './datasetPage';
import StoryPage from './storyPage';

export const test = base.extend<{
aboutPage: AboutPage;
footerComponent: FooterComponent;
headerComponent: HeaderComponent;
homePage: HomePage;
catalogPage: CatalogPage;
datasetPage: DatasetPage;
storyPage: StoryPage
}> ({
aboutPage: async ({page}, use) => {
await use(new AboutPage(page));
},
catalogPage: async ({page}, use) => {
await use(new CatalogPage(page));
},
datasetPage: async ({page}, use) => {
await use(new DatasetPage(page));
},
homePage: async ({page}, use) => {
await use(new HomePage(page));
},
storyPage: async ({page}, use) => {
await use(new StoryPage(page));
},
headerComponent: async ({page}, use) => {
await use(new HeaderComponent(page));
},
footerComponent: async ({page}, use) => {
await use(new FooterComponent(page));
},
});

export const expect = test.expect;
14 changes: 14 additions & 0 deletions e2e/pages/catalogPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';

export default class CatalogPage {
readonly page: Page;
readonly mainContent: Locator;
readonly header: Locator;


constructor(page: Page) {
this.page = page;
this.mainContent = this.page.getByRole('main');
this.header = this.mainContent.getByRole('heading', {level: 1})
}
}
18 changes: 18 additions & 0 deletions e2e/pages/datasetPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Locator, Page } from '@playwright/test';

export default class DatasetPage {
readonly page: Page;
readonly mainContent: Locator;
readonly header: Locator;
readonly exploreDataButton: Locator;
readonly analyzeDataButton: Locator;


constructor(page: Page) {
this.page = page;
this.mainContent = this.page.getByRole('main');
this.header = this.mainContent.getByRole('heading', { level: 1 })
this.exploreDataButton = this.page.getByRole('link', {name: /explore data/i} );
this.analyzeDataButton = this.page.getByRole('button', {name: /analyze data/i} );
}
}
13 changes: 13 additions & 0 deletions e2e/pages/footerComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Locator, Page } from '@playwright/test';

export default class FooterComponent {
readonly page: Page;
readonly footer: Locator;
readonly partners: Locator;

constructor(page: Page) {
this.page = page;
this.footer = this.page.locator('footer');
this.partners = this.footer.locator('div');
}
}
23 changes: 23 additions & 0 deletions e2e/pages/headerComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Locator, Page } from '@playwright/test';

export default class HeaderComponent {
readonly page: Page;
readonly header: Locator;
readonly welcomeLink: Locator;
readonly dataCatalogLink: Locator;
readonly analysisLink: Locator;
readonly dataInsightsLink: Locator;
readonly aboutLink: Locator;
readonly feedbackLink: Locator;

constructor(page: Page) {
this.page = page;
this.header = this.page.getByRole('navigation');
this.welcomeLink = this.header.getByRole('link', {name: /welcome/i});
this.dataCatalogLink = this.header.getByRole('link', {name: / data catalog/i});
this.analysisLink = this.header.getByRole('link', {name: /analysis/i});
this.dataInsightsLink = this.header.getByRole('link', {name: /data insights/i});
this.aboutLink = this.header.getByRole('link', {name: /about/i});
this.feedbackLink = this.header.getByRole('link', {name: /feedback/i});
}
}
14 changes: 14 additions & 0 deletions e2e/pages/homePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';

export default class HomePage {
readonly page: Page;
readonly mainContent: Locator;
readonly headingContainer: Locator;


constructor(page: Page) {
this.page = page;
this.mainContent = this.page.getByRole('main');
this.headingContainer = this.mainContent.locator('div').filter({ hasText: 'U.S. Greenhouse Gas CenterUniting Data and Technology to Empower Tomorrow\'s' }).nth(2)
}
}
14 changes: 14 additions & 0 deletions e2e/pages/storyPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Locator, Page } from '@playwright/test';

export default class StoryPage {
readonly page: Page;
readonly mainContent: Locator;
readonly header: Locator;


constructor(page: Page) {
this.page = page;
this.mainContent = this.page.getByRole('main');
this.header = this.mainContent.getByRole('heading', {level: 1})
}
}
27 changes: 27 additions & 0 deletions e2e/tests/catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '../pages/basePage';

const catalogs = JSON.parse(require('fs').readFileSync('e2e/playwrightTestData.json', 'utf8'))['catalogs'];

test('load catalogs on /data-catalog route', async ({
page,
catalogPage,
}) => {
let pageErrorCalled = false;
// Log all uncaught errors to the terminal
page.on('pageerror', exception => {
console.log(`Uncaught exception: "${exception}"`);
pageErrorCalled = true;
});

await page.goto('/data-catalog');
await expect(catalogPage.header, `catalog page should load`).toHaveText(/data catalog/i);

for (const item of catalogs) {
const catalogCard = catalogPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).last();
await catalogCard.scrollIntoViewIfNeeded();
await expect(catalogCard, `${item} catalog card should load`).toBeVisible();
};

expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false)

});
35 changes: 35 additions & 0 deletions e2e/tests/catalogRouting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test, expect } from '../pages/basePage';

const catalogs = JSON.parse(require('fs').readFileSync('e2e/playwrightTestData.json', 'utf8'))['catalogs'];

test.describe('catalog card routing', () => {
for (const item of catalogs) {
test(`${item} routes to dataset details page`, async({
page,
catalogPage,
datasetPage,
}) => {
let pageErrorCalled = false;
// Log all uncaught errors to the terminal
page.on('pageerror', exception => {
console.log(`Uncaught exception: "${exception}"`);
pageErrorCalled = true;
});

await page.goto('/data-catalog');
await expect(catalogPage.header, `catalog page should load`).toHaveText(/data catalog/i);

const catalogCard = catalogPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).first();
await catalogCard.scrollIntoViewIfNeeded();
await catalogCard.click({force: true});

await expect(datasetPage.header.filter({ hasText: item}), `${item} page should load`).toBeVisible();

// scroll page to bottom
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));

expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false)
})
}

});
30 changes: 30 additions & 0 deletions e2e/tests/stories.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from '../pages/basePage';

const stories = JSON.parse(require('fs').readFileSync('e2e/playwrightTestData.json', 'utf8'))['stories'];

test('load stories on /stories route', async ({
page,
storyPage,
}) => {



let pageErrorCalled = false;
// Log all uncaught errors to the terminal
page.on('pageerror', exception => {
console.log(`Uncaught exception: "${exception}"`);
pageErrorCalled = true;
});

await page.goto('/stories');
await expect(storyPage.header, `data stories page should load`).toHaveText(/data stories/i);

for (const item of stories) {
const storiesCard = storyPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).first();
await storiesCard.scrollIntoViewIfNeeded();
await expect(storiesCard, `${item} story card should load`).toBeVisible();
};

expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false)

});
30 changes: 30 additions & 0 deletions e2e/tests/storiesRouting.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { test, expect } from '../pages/basePage';

const stories = JSON.parse(require('fs').readFileSync('e2e/playwrightTestData.json', 'utf8'))['stories'];

test.describe('stories card routing', () => {
for (const item of stories) {
test(`${item} routes to dataset details page`, async({
page,
storyPage,
datasetPage,
}) => {
let pageErrorCalled = false;
// Log all uncaught errors to the terminal
page.on('pageerror', exception => {
console.log(`Uncaught exception: "${exception}"`);
pageErrorCalled = true;
});

await page.goto('/stories');
await expect(storyPage.header, `stories page should load`).toHaveText(/data stories/i);

const storyCard = storyPage.mainContent.getByRole('article').getByRole('heading', { level: 3, name: item, exact: true}).last();
await storyCard.scrollIntoViewIfNeeded();
await storyCard.click({force: true});
await expect(datasetPage.header.filter({ hasText: item}), `${item} page should load`).toBeVisible();
expect(pageErrorCalled, 'no javascript exceptions thrown on page').toBe(false)
})
}

});
Loading

0 comments on commit 1d34a0f

Please sign in to comment.