From 83f555618404be6f8c31220eed9241a5ba5848c0 Mon Sep 17 00:00:00 2001 From: Alessandro Rabitti Date: Mon, 9 Sep 2024 18:02:22 +0200 Subject: [PATCH] Add e2e tests --- .env | 1 + .github/workflows/deployment.yml | 2 +- .github/workflows/playwright.yml | 43 ++++++++ .gitignore | 4 + README.md | 10 +- next-env.d.ts | 2 +- package-lock.json | 104 ++++++++++++++++++ package.json | 5 +- playwright.config.ts | 43 ++++++++ src/app/[locale]/__e2e__/fixtures/index.ts | 2 + .../[locale]/__e2e__/fixtures/page/AppPage.ts | 8 ++ .../__e2e__/fixtures/test/testAppPage.ts | 11 ++ src/app/[locale]/app.e2e.ts | 32 ++++++ src/tests/playwright/fixtures/index.ts | 1 + .../playwright/fixtures/page/BasePage.ts | 53 +++++++++ 15 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 src/app/[locale]/__e2e__/fixtures/index.ts create mode 100644 src/app/[locale]/__e2e__/fixtures/page/AppPage.ts create mode 100644 src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts create mode 100644 src/app/[locale]/app.e2e.ts create mode 100644 src/tests/playwright/fixtures/index.ts create mode 100644 src/tests/playwright/fixtures/page/BasePage.ts diff --git a/.env b/.env index 6f8ed9c..25031d0 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ MONGO_DB_URL= NEXT_PUBLIC_NODE_ENV= +PLAYWRIGHT_BASE_URL= diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 557644d..00350de 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -25,4 +25,4 @@ jobs: run: npm run lint - name: Unit Tests - run: npm run test + run: npm run test:unit diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..9a28a5d --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,43 @@ +name: E2e tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + timeout-minutes: 60 + + steps: + - name: Wait for HTTP Status Code 200 from the Vercel Preview Deploy + uses: patrickedqvist/wait-for-vercel-preview@v1.3.2 + id: waitForVercelPreviewDeploy + with: + token: ${{ secrets.GITHUB_TOKEN }} + max_timeout: 300 + + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + env: + PLAYWRIGHT_BASE_URL: ${{ steps.waitForVercelPreviewDeploy.outputs.url }} + run: npx playwright test + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 243dca9..ac6381b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # next.js /.next/ diff --git a/README.md b/README.md index 9339fa8..2c5e6bf 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ npm run fix ## Testing -Tests are built with [Vitest](https://vitest.dev/). +Unit Tests are built with [Vitest](https://vitest.dev/). ```bash -npm run test +npm run test:unit +``` + +E2e Tests are built with [Playwright](https://playwright.dev/). + +```bash +npm run test:e2e ``` ## Development configuration diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03..40c3d68 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 1b93754..7eaea64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-i18next": "^15.0.1" }, "devDependencies": { + "@playwright/test": "^1.47.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.1", "@types/exenv": "^1.2.2", @@ -34,6 +35,7 @@ "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "4.3.1", + "dotenv-cli": "^7.4.2", "eslint": "^8.56.0", "eslint-config-next": "^14.2.6", "eslint-plugin-import": "^2.29.1", @@ -1523,6 +1525,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz", + "integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.47.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -3509,6 +3527,45 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.2.tgz", + "integrity": "sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7009,6 +7066,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz", + "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.47.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz", + "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/package.json b/package.json index c3d5228..0668162 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "fix": "npm run fix:js && npm run fix:css", "fix:js": "next lint --fix", "fix:css": "stylelint \"**/*.css\" --fix", - "test": "vitest" + "test:unit": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "accept-language": "^3.0.20", @@ -31,6 +32,7 @@ "react-i18next": "^15.0.1" }, "devDependencies": { + "@playwright/test": "^1.47.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.1", "@types/exenv": "^1.2.2", @@ -41,6 +43,7 @@ "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "4.3.1", + "dotenv-cli": "^7.4.2", "eslint": "^8.56.0", "eslint-config-next": "^14.2.6", "eslint-plugin-import": "^2.29.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..93e70c8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,43 @@ +import path from 'node:path' + +import { defineConfig, devices } from '@playwright/test' +import dotenv from 'dotenv' + + +dotenv.config({ path: path.resolve(__dirname, '.env') }) + +const environment = process.env.NEXT_PUBLIC_NODE_ENV || 'development' + +export default defineConfig({ + forbidOnly: environment !== 'development', + fullyParallel: true, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + ], + reporter: 'html', + retries: environment !== 'development' ? 2 : 0, + testDir: './src/', + testMatch: '**/*.e2e.ts', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, + workers: environment !== 'development' ? 1 : undefined, +}) diff --git a/src/app/[locale]/__e2e__/fixtures/index.ts b/src/app/[locale]/__e2e__/fixtures/index.ts new file mode 100644 index 0000000..8abca46 --- /dev/null +++ b/src/app/[locale]/__e2e__/fixtures/index.ts @@ -0,0 +1,2 @@ +export * from './page/AppPage' +export * from './test/testAppPage' diff --git a/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts b/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts new file mode 100644 index 0000000..9205797 --- /dev/null +++ b/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts @@ -0,0 +1,8 @@ +import { BasePage } from 'src/tests/playwright/fixtures' + + +export class AppPage extends BasePage { + async goto(): Promise { + await this.page.goto('/') + } +} diff --git a/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts b/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts new file mode 100644 index 0000000..c0e6269 --- /dev/null +++ b/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts @@ -0,0 +1,11 @@ +import test from '@playwright/test' + +import { AppPage } from '../page/AppPage' + + +export const testAppPage = test.extend<{ appPage: AppPage }>({ + async appPage({ page }, use) { + const appPage = new AppPage(page) + await use(appPage) + } +}) diff --git a/src/app/[locale]/app.e2e.ts b/src/app/[locale]/app.e2e.ts new file mode 100644 index 0000000..02f7ba2 --- /dev/null +++ b/src/app/[locale]/app.e2e.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test' + +import { testAppPage as test } from './__e2e__/fixtures' + + +test.beforeEach(async ({ appPage }) => { + await appPage.goto() +}) + +test.describe('src / app / [locale] > app', () => { + test('check header existance', async ({ appPage }) => { + expect(appPage.header.getElement()).toBeDefined() + expect(appPage.header.getElement()).toBeInViewport() + + expect(appPage.header.getLogo()).toBeDefined() + expect(appPage.header.getLogo()).toBeInViewport() + }) + + test('check navigation menu', async ({ appPage }) => { + expect(appPage.navigation.getElement()).toBeInViewport() + + expect(appPage.navigation.getMenu('')).toBeDefined() + expect(appPage.navigation.getMenu('places')).toBeDefined() + }) + + test('check locales menu', async ({ appPage }) => { + expect(appPage.locales.getElement()).toBeInViewport() + + expect(appPage.locales.getLanguage('en')).toBeDefined() + expect(appPage.locales.getLanguage('it')).toBeDefined() + }) +}) diff --git a/src/tests/playwright/fixtures/index.ts b/src/tests/playwright/fixtures/index.ts new file mode 100644 index 0000000..67b781c --- /dev/null +++ b/src/tests/playwright/fixtures/index.ts @@ -0,0 +1 @@ +export * from './page/BasePage' diff --git a/src/tests/playwright/fixtures/page/BasePage.ts b/src/tests/playwright/fixtures/page/BasePage.ts new file mode 100644 index 0000000..3bdea89 --- /dev/null +++ b/src/tests/playwright/fixtures/page/BasePage.ts @@ -0,0 +1,53 @@ +import type { Locator, Page } from 'playwright/test' + + +export class BasePage { + constructor( + public readonly page: Page, + public readonly header: Header = new Header(page), + public readonly navigation: Navigation = new Navigation(page), + public readonly locales: Locales = new Locales(page), + ) {} +} + +class Header { + constructor( + private readonly page: Page, + ) {} + + getElement(): Locator { + return this.page.locator('header') + } + + getLogo(): Locator { + return this.getElement().getByRole('navigation', { name: /main logo/i }) + } +} + +class Navigation { + constructor( + private readonly page: Page, + ) {} + + getElement(): Locator { + return this.page.getByRole('menubar', { name: /menu/i }) + } + + getMenu(href: string) { + return this.getElement().locator(`a[href*="/${href}"]`) + } +} + +class Locales { + constructor( + private readonly page: Page, + ) {} + + getElement(): Locator { + return this.page.getByRole('menubar', { name: /language navigation/i }) + } + + getLanguage(language: string) { + return this.getElement().locator(`a[href*="/${language}"]`) + } +}